diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6dc1964..54fd342 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -102,6 +102,7 @@ jobs: tools/install_llvm_toolchain.py | tools/validate_llvm_toolchain.py | \ tools/tests/test_install_llvm_toolchain.py | tools/tests/test_workflow_contracts.py | .github/workflows/measure-classic-check.yml | \ .github/workflows/publish-windows.yml | \ + .github/workflows/windows-glib-qualification.yml | \ .github/workflows/validate.yml | windows/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ classic-curl-toolchain.json | tools/verify-classic-curl.sh | \ diff --git a/.github/workflows/windows-glib-qualification.yml b/.github/workflows/windows-glib-qualification.yml new file mode 100644 index 0000000..4707e6f --- /dev/null +++ b/.github/workflows/windows-glib-qualification.yml @@ -0,0 +1,220 @@ +# Copyright (c) 2026 Atrinik contributors +# SPDX-License-Identifier: MIT +name: Qualify Windows GLib and Cairo TLS repairs + +on: + pull_request: + paths: + - 'windows/**' + - 'llvm-toolchain.json' + - 'tools/tests/test_windows_glib_workflow.py' + - '.github/workflows/windows-glib-qualification.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-glib-qualification-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +# This scoped proof never publishes or promotes an SDK. Full image qualification +# remains owned by validate.yml and publish-windows.yml. +jobs: + cross-build: + name: Build scoped GLib and Cairo TLS qualification bundles + runs-on: ubuntu-26.04 + timeout-minutes: 10 + env: + SDK_IMAGE: ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 + steps: + - name: Check out qualification sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Stage and verify immutable library source + shell: bash + run: | + set -euo pipefail + mkdir -p build/glib-qualification/{source,downloads,provenance} + git archive HEAD | tar -x -C build/glib-qualification/source + # Only this pinned archive is fetched on the host; the build worker + # has neither registry credentials nor network access. + python3 - <<'PY' + import json + from pathlib import Path + records = json.loads(Path('windows/dependencies.json').read_text())['dependencies'] + inputs = { + 'glib': { + 'version': '2.89.3', + 'url': 'https://download.gnome.org/sources/glib/2.89/glib-2.89.3.tar.xz', + 'archive': 'glib-2.89.3.tar.xz', + 'sha256': '09fd1e99f991067749ad66090e482ec4bd6514ad53abb4e9fbbdc2a4d2753532', + }, + 'cairo': { + 'version': '1.18.4', + 'url': 'https://cairographics.org/releases/cairo-1.18.4.tar.xz', + 'archive': 'cairo-1.18.4.tar.xz', + 'sha256': '445ed8208a6e4823de1226a74ca319d3600e83f6369f99b14265006599c32ccb', + }, + } + for library, expected in inputs.items(): + package, = [item for item in records if item['name'] == library] + if any(package.get(key) != value for key, value in expected.items()): + raise SystemExit('Library source differs from the scoped qualification input') + Path(f'build/glib-qualification/provenance/{library}-source.json').write_text( + json.dumps({'library': library, **expected}, indent=2) + '\n') + Path(f'build/glib-qualification/downloads/{library}-url.txt').write_text(expected['url']) + Path(f'build/glib-qualification/downloads/{library}.sha256').write_text( + expected['sha256'] + f' build/glib-qualification/downloads/{library}.tar.xz\n') + PY + for library in glib cairo; do + curl --fail --location --proto '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 60 \ + "$(cat "build/glib-qualification/downloads/${library}-url.txt")" \ + -o "build/glib-qualification/downloads/${library}.tar.xz" + sha256sum --check --strict "build/glib-qualification/downloads/${library}.sha256" + done + git rev-parse HEAD > build/glib-qualification/provenance/source-commit.txt + git rev-parse 'HEAD^{tree}' > build/glib-qualification/provenance/source-tree.txt + printf '%s\n' "${SDK_IMAGE}" > build/glib-qualification/provenance/sdk-image.txt + sha256sum windows/qualify-glib-tls.py windows/run-glib-tls-qualification.ps1 \ + > build/glib-qualification/provenance/qualification-scripts.sha256 + + - name: Cross-build in an isolated LLVM SDK worker + shell: bash + run: | + set -euo pipefail + # Anonymous public digest access only; no login or package permissions. + timeout 120s docker pull "${SDK_IMAGE}" + cpuset=$(python3 - <<'PY' + import os + cpus = sorted(os.sched_getaffinity(0)) + if len(cpus) < 2: + raise SystemExit('Qualification requires two available runner CPUs') + print(','.join(map(str, cpus[:2]))) + PY + ) + printf 'cpuset=%s\ncpus=2\nmemory=8g\nmemory-swap=8g\npids=512\nnofile=4096\n' \ + "${cpuset}" > build/glib-qualification/provenance/worker-limits.txt + worker=$(docker create --network none --user 1000:1000 \ + --cpuset-cpus "${cpuset}" --cpus 2 --memory 8g --memory-swap 8g \ + --pids-limit 512 --ulimit nofile=4096:4096 --cap-drop ALL \ + --security-opt no-new-privileges --env HOME=/tmp/glib-home \ + --workdir /tmp/qualification/source "${SDK_IMAGE}" \ + sh -eu -c ' + python3 windows/qualify-glib-tls.py --library glib \ + --source-archive /tmp/qualification/downloads/glib.tar.xz \ + --work-dir /tmp/qualification/work/glib --output /tmp/qualification/bundle/glib + python3 windows/qualify-glib-tls.py --library cairo \ + --source-archive /tmp/qualification/downloads/cairo.tar.xz \ + --work-dir /tmp/qualification/work/cairo --output /tmp/qualification/bundle/cairo \ + --glib-bundle /tmp/qualification/bundle/glib + ') + cleanup() { docker rm --force "${worker}" >/dev/null; } + trap cleanup EXIT + # Preserve the fixed worker UID without mounting the runner workspace. + tar --owner=1000 --group=1000 --transform='s,^glib-qualification,qualification,' \ + -c -C build glib-qualification | docker cp --archive - "${worker}:/tmp" + status=0 + timeout 300s docker start --attach "${worker}" \ + > build/glib-cross-build.log 2>&1 || status=$? + cat build/glib-cross-build.log + if [[ "${status}" -eq 0 ]]; then + status=$(docker inspect --format '{{.State.ExitCode}}' "${worker}") + fi + if [[ "${status}" -ne 0 ]]; then + exit "${status}" + fi + docker cp "${worker}:/tmp/qualification/bundle" build/glib-native-bundle + for library in glib cairo; do + cp -R build/glib-qualification/provenance "build/glib-native-bundle/${library}/provenance" + cp windows/qualify-glib-tls.py windows/run-glib-tls-qualification.ps1 \ + "build/glib-native-bundle/${library}/provenance/" + cp "build/glib-qualification/downloads/${library}.tar.xz" \ + "build/glib-native-bundle/${library}/provenance/" + done + # Bound artifact size and reject links before uploading executable input. + python3 - <<'PY' + from pathlib import Path + root = Path('build/glib-native-bundle') + files = list(root.rglob('*')) + if any(item.is_symlink() for item in files): + raise SystemExit('Native bundle contains a link') + if sum(item.stat().st_size for item in files if item.is_file()) > 100 * 1024 * 1024: + raise SystemExit('Native bundle exceeds 100 MiB') + for library in ('glib', 'cairo'): + for lane in ('candidate', 'original', 'relinked'): + if not (root / library / lane).is_dir(): + raise SystemExit(f'Missing qualification lane: {library}/{lane}') + for name in ('qualification.json', 'run-glib-tls-qualification.ps1'): + if not (root / library / name).is_file(): + raise SystemExit(f'Missing qualification artifact: {library}/{name}') + PY + + - name: Upload bounded native library bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-glib-cairo-tls-native-bundle + path: build/glib-native-bundle + if-no-files-found: error + retention-days: 3 + + - name: Retain cross-build evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-glib-cairo-tls-cross-build-evidence + path: | + build/glib-cross-build.log + build/glib-qualification/provenance + if-no-files-found: warn + retention-days: 3 + + native-windows: + name: Verify GLib and Cairo TLS repairs and negative controls on Windows + needs: cross-build + permissions: {} + runs-on: windows-2025 + timeout-minutes: 5 + steps: + - name: Download scoped native GLib and Cairo bundles + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: windows-glib-cairo-tls-native-bundle + path: build/glib-native-bundle + + - name: Require native TLS lifecycle and negative controls + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($library in @('glib', 'cairo')) { + $bundle = (Resolve-Path "build/glib-native-bundle/$library").Path + $metadata = Get-Content -Raw (Join-Path $bundle 'qualification.json') | ConvertFrom-Json + if ($metadata.library -ne $library) { throw "Bundle library mismatch: $library" } + & pwsh -NoLogo -NoProfile -NonInteractive -File (Join-Path $bundle 'run-glib-tls-qualification.ps1') + if ($LASTEXITCODE -ne 0) { throw "Native $library qualification failed: $LASTEXITCODE" } + $results = @(Get-Content -Raw (Join-Path $bundle 'native-results.json') | ConvertFrom-Json) + if ($results.Count -ne 3 -or @($results | Where-Object { $_.passed -ne $true }).Count -ne 0) { + throw "Native $library qualification did not pass all three lanes" + } + $lanes = @($results.lane | Sort-Object) + if (($lanes -join ',') -ne 'candidate,original,relinked') { + throw "Native $library qualification lane mismatch" + } + } + + - name: Retain native TLS qualification evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-glib-tls-native-evidence + path: | + build/glib-native-bundle/*/qualification.json + build/glib-native-bundle/*/provenance + build/glib-native-bundle/**/*.json + build/glib-native-bundle/**/*.stdout.txt + build/glib-native-bundle/**/*.stderr.txt + if-no-files-found: error + retention-days: 3 diff --git a/AGENTS.md b/AGENTS.md index aa06fbf..6490e6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,10 @@ into both Linux images, and records the WSLg host mounts/environment. Its pinned Lavapipe/Xvfb packages remain the fork-safe headless path; consumers still pin a released image digest and supply WSLg adapter selection. +- Windows GLib/Cairo qualification requires actual PE TLS callback registration and + the hash-bound native callback/destructor or mutex-initialization probes. Keep original and + relinked failing DLL controls in the focused diagnostic; it does not replace + full candidate SDK and consumer qualification. - Keep a stable numeric runner UID when restoring a Classic ccache directory; the mode-1777 mount root supports non-root initialization but does not make ccache's owner-writable nested directories reusable across different UIDs. diff --git a/tools/run-classic-native-tests.ps1 b/tools/run-classic-native-tests.ps1 index 6567dec..94e29f1 100644 --- a/tools/run-classic-native-tests.ps1 +++ b/tools/run-classic-native-tests.ps1 @@ -32,6 +32,52 @@ function Invoke-QualifiedNativeTest { } } +foreach ($library in @('glib', 'cairo')) { + $dllName = if ($library -eq 'glib') { 'libglib-2.0-0.dll' } else { 'libcairo-2.dll' } + $probeName = "atrinik-$library-tls-probe.exe" + # Check the same DLL whose private callback/flag RVAs are compiled into the probe. + $tlsReceipt = Get-Content -Raw (Join-Path $bundle "$library-tls.json") | ConvertFrom-Json + $dllHash = (Get-FileHash -Algorithm SHA256 (Join-Path $bundle $dllName)).Hash.ToLowerInvariant() + if ($tlsReceipt.schema_version -ne 1 -or $tlsReceipt.dll -ne $dllName -or $dllHash -ne $tlsReceipt.sha256) { + throw 'Library TLS probe does not match the staged DLL' + } + $probeHash = (Get-FileHash -Algorithm SHA256 (Join-Path $bundle $probeName)).Hash.ToLowerInvariant() + if ($probeHash -ne $tlsReceipt.probe_sha256) { throw "Library TLS probe executable differs from its producer receipt" } + $probeStart = [Diagnostics.ProcessStartInfo]::new() + $probeStart.FileName = Join-Path $bundle $probeName + $probeStart.WorkingDirectory = $bundle + $probeStart.UseShellExecute = $false + $probeStart.RedirectStandardOutput = $true + $probeStart.RedirectStandardError = $true + $probeStart.Environment['G_DEBUG'] = 'fatal-criticals' + $probeProcess = [Diagnostics.Process]::Start($probeStart) + try { + Write-Host "$library TLS qualification pid=$($probeProcess.Id) dll_sha256=$dllHash" + $stdout = $probeProcess.StandardOutput.ReadToEndAsync() + $stderr = $probeProcess.StandardError.ReadToEndAsync() + $timedOut = -not $probeProcess.WaitForExit(15000) + if ($timedOut) { + $probeProcess.Kill($true) + if (-not $probeProcess.WaitForExit(5000)) { throw 'Library TLS probe did not stop after timeout' } + } + $out = $stdout.GetAwaiter().GetResult() + $err = $stderr.GetAwaiter().GetResult() + [IO.File]::WriteAllText((Join-Path $bundle "$library-tls.stdout.log"), $out) + [IO.File]::WriteAllText((Join-Path $bundle "$library-tls.stderr.log"), $err) + Write-Host $out + if ($err) { Write-Host $err } + if ($timedOut) { throw 'Library TLS probe exceeded 15000 ms' } + if ($probeProcess.ExitCode -ne 0) { throw "Library TLS probe failed: $($probeProcess.ExitCode)" } + $expected = if ($library -eq 'glib') { 'process_attach_invoked=1 private_destructors=1 explicit_unload=1' } else { 'process_attach_initialized=2 surface_font_smoke=1 explicit_unload=1 reload=1' } + if ($out -notmatch $expected -or $err) { + throw 'Library TLS callback/destructor/unload evidence is incomplete or emitted diagnostics' + } + } finally { + $probeProcess.Dispose() + } + +} + foreach ($test in $tests) { $arguments = @($test.arguments | ForEach-Object { if ([IO.Path]::IsPathRooted($_) -or $_ -like '../*' -or $_ -like '..\*') { diff --git a/tools/smoke-classic-check.sh b/tools/smoke-classic-check.sh index 698528f..04c4975 100755 --- a/tools/smoke-classic-check.sh +++ b/tools/smoke-classic-check.sh @@ -170,7 +170,13 @@ docker run --rm --user "$(id -u):$(id -g)" --network none \ --runtime-dir "${ATRINIK_WINDOWS_RUNTIME_DIR}" \ --output-dir "${stage}" \ "${native_sources[@]}" \ - "${ATRINIK_WINDOWS_RUNTIME_DIR}/atrinik-sdl3-mixer-probe.exe" + "${ATRINIK_WINDOWS_RUNTIME_DIR}/atrinik-sdl3-mixer-probe.exe" \ + "${ATRINIK_WINDOWS_RUNTIME_DIR}/atrinik-glib-tls-probe.exe" \ + "${ATRINIK_WINDOWS_RUNTIME_DIR}/libglib-2.0-0.dll" \ + "${ATRINIK_WINDOWS_RUNTIME_DIR}/atrinik-cairo-tls-probe.exe" \ + "${ATRINIK_WINDOWS_RUNTIME_DIR}/libcairo-2.dll" + cmake -E copy /opt/atrinik-windows/share/atrinik/windows-dependencies/glib-tls.json "${stage}/glib-tls.json" + cmake -E copy /opt/atrinik-windows/share/atrinik/windows-dependencies/cairo-tls.json "${stage}/cairo-tls.json" cmake -E copy_directory libatrinik/tests/fixtures "${stage}/fixtures" cmake -E copy /opt/atrinik-windows/share/atrinik/audio/opus-probe.opus "${stage}/opus-probe.opus" cmake -E copy /image-source/audio-toolchain.json "${stage}/audio-toolchain.json" diff --git a/tools/tests/test_pe_tls.py b/tools/tests/test_pe_tls.py new file mode 100644 index 0000000..93e010d --- /dev/null +++ b/tools/tests/test_pe_tls.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +import importlib.util +from pathlib import Path +import struct +import unittest + +SPEC = importlib.util.spec_from_file_location('pe_tls', Path(__file__).resolve().parents[2] / 'windows/pe_tls.py') +TLS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(TLS) +BASE = 0x180000000 + + +def fixture(): + data = bytearray(0x1400) + data[:2] = b'MZ' + struct.pack_into('> calls\nexit 23\n') + python.chmod(0o755) + result = subprocess.run(['sh', '-eu', '-c', command], cwd=root, + env=dict(os.environ, PATH=f'{root}:' + os.environ['PATH']), + capture_output=True, text=True) + self.assertEqual(result.returncode, 23, result.stderr) + calls = (root / 'calls').read_text().splitlines() + self.assertEqual(len(calls), 1) + self.assertIn('--library glib', calls[0]) + self.assertNotIn('--library cairo', calls[0]) + + def test_focused_workflow_change_selects_full_windows_validation(self): + selector = script('Detect image changes', ROOT / '.github/workflows/validate.yml') + for changed, selected in (('.github/workflows/windows-glib-qualification.yml', 'true'), + ('README.md', 'false')): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + git = root / 'git' + git.write_text('#!/bin/sh\nprintf "%s\\n" "$CHANGED_PATH"\n') + git.chmod(0o755) + output = root / 'output' + result = subprocess.run( + ['bash', '-euo', 'pipefail', '-c', selector], cwd=root, + env=dict(os.environ, PATH=f'{root}:' + os.environ['PATH'], + CHANGED_PATH=changed, GITHUB_OUTPUT=str(output), + BASE_SHA='b' * 40, HEAD_SHA='a' * 40), + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + values = dict(line.split('=', 1) for line in output.read_text().splitlines()) + self.assertEqual(values['windows'], selected) + + def test_native_job_requires_build_and_strict_native_runner(self): + text = WORKFLOW.read_text() + native = text.split(' native-windows:', 1)[1] + self.assertIn('needs: cross-build', native) + self.assertIn('runs-on: windows-2025', native) + self.assertIn('run-glib-tls-qualification.ps1', native) + self.assertIn("foreach ($library in @('glib', 'cairo'))", native) + self.assertIn('$metadata.library -ne $library', native) + self.assertIn('$results.Count -ne 3', native) + self.assertIn("'candidate,original,relinked'", native) + self.assertIn('name: windows-glib-cairo-tls-native-bundle', native) + self.assertNotIn('matrix.library', text) + self.assertIn('if ($LASTEXITCODE -ne 0)', native) + self.assertNotIn('continue-on-error', text) + self.assertNotIn('packages:', text) + self.assertNotIn('secrets.', text) + self.assertNotIn('login-action', text) + self.assertIn(' pull_request:\n paths:', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/windows/Dockerfile b/windows/Dockerfile index 19b4481..cd735c7 100644 --- a/windows/Dockerfile +++ b/windows/Dockerfile @@ -72,13 +72,39 @@ ENV ATRINIK_WINDOWS_TOOLCHAIN_FILE=/opt/atrinik-windows/share/cmake/llvm-mingw.c CC=clang CXX=clang++ AR=llvm-ar RANLIB=llvm-ranlib LD=ld.lld COPY --chown=vscode:vscode windows/llvm-mingw-toolchain.cmake ${ATRINIK_WINDOWS_TOOLCHAIN_FILE} COPY --chown=vscode:vscode windows/target-pkg-config.sh /opt/atrinik-windows/bin/x86_64-w64-mingw32-pkg-config -COPY --chown=vscode:vscode windows/build-dependencies.py windows/dependencies.json /tmp/windows-producer/ +COPY --chown=vscode:vscode windows/build-dependencies.py windows/pe_tls.py windows/dependencies.json /tmp/windows-producer/ USER vscode # Fresh target prefix: no binary or archive from the former GCC producer enters it. RUN python3 /tmp/windows-producer/build-dependencies.py \ --lock /tmp/windows-producer/dependencies.json --prefix /opt/atrinik-windows \ --work-dir /tmp/windows-dependencies --jobs "${WINDOWS_BUILD_JOBS}" \ && rm -rf /tmp/windows-dependencies +# Bind the native callback probe to the exact producer DLL's inspected symbols. +COPY --chown=vscode:vscode windows/glib-tls-probe.c windows/cairo-tls-probe.c /tmp/windows-producer/ +RUN python3 /tmp/windows-producer/pe_tls.py \ + --dll /opt/atrinik-windows/bin/libglib-2.0-0.dll \ + --report /opt/atrinik-windows/share/atrinik/windows-dependencies/glib-tls.json \ + --header /tmp/windows-producer/glib-tls-rvas.h \ + && x86_64-w64-mingw32-clang -std=c17 -O2 -Wall -Wextra -Werror \ + /tmp/windows-producer/glib-tls-probe.c \ + $(x86_64-w64-mingw32-pkg-config --cflags glib-2.0) \ + -o /opt/atrinik-windows/bin/atrinik-glib-tls-probe.exe \ + && python3 /tmp/windows-producer/pe_tls.py \ + --dll /opt/atrinik-windows/bin/libglib-2.0-0.dll \ + --report /opt/atrinik-windows/share/atrinik/windows-dependencies/glib-tls.json \ + --probe /opt/atrinik-windows/bin/atrinik-glib-tls-probe.exe +RUN python3 /tmp/windows-producer/pe_tls.py --library cairo \ + --dll /opt/atrinik-windows/bin/libcairo-2.dll \ + --report /opt/atrinik-windows/share/atrinik/windows-dependencies/cairo-tls.json \ + --header /tmp/windows-producer/cairo-tls-rvas.h \ + && x86_64-w64-mingw32-clang -std=c17 -O2 -Wall -Wextra -Werror \ + /tmp/windows-producer/cairo-tls-probe.c \ + $(x86_64-w64-mingw32-pkg-config --cflags cairo) \ + -o /opt/atrinik-windows/bin/atrinik-cairo-tls-probe.exe \ + && python3 /tmp/windows-producer/pe_tls.py --library cairo \ + --dll /opt/atrinik-windows/bin/libcairo-2.dll \ + --report /opt/atrinik-windows/share/atrinik/windows-dependencies/cairo-tls.json \ + --probe /opt/atrinik-windows/bin/atrinik-cairo-tls-probe.exe COPY --chown=vscode:vscode tools/verify-classic-curl.sh /tmp/verify-classic-curl.sh RUN /tmp/verify-classic-curl.sh /opt/atrinik-windows \ && test "$(x86_64-w64-mingw32-pkg-config --modversion openssl)" = 3.5.5 \ diff --git a/windows/README.md b/windows/README.md index 6e0bcbf..4c97e46 100644 --- a/windows/README.md +++ b/windows/README.md @@ -21,3 +21,35 @@ contracts. Native Windows test execution, shipped-font equivalence, audible playback and GPU qualification require separate evidence before release acceptance. A private recovered LLVM cohort can diagnose dependency builds; canonical publication still requires the production Dockerfile and CI. + +GLib's and Cairo's GNU TLS macros retain callback pointers but do not force the CRT TLS +registration directory into an LLVM/MinGW link. The target GLib and Cairo recipes require +`_tls_used`; the host recipe and library features remain unchanged. `pe_tls.py` +checks the actual PE TLS directory and callback array against each library's retained +symbols. It binds the native probe to that exact DLL's callback and process +attach flag. The producer's native bundle verifies the DLL hash, observes the +callback flag, exercises a real GPrivate destructor using the configured POSIX +thread backend, and explicitly unloads the DLL. Missing registration is also +fatal through GLib's existing critical diagnostic. + +For focused diagnosis, run `qualify-glib-tls.py --work-dir ABSENT_DIRECTORY +--output ABSENT_DIRECTORY` inside the pinned LLVM SDK, with an optional +`--source-archive` that must match the locked GLib hash. It rebuilds GLib with +and without the mandatory TLS reference, preserves linker-map evidence and +import/export comparison, and stages the original SDK DLL as another negative +control. Run the resulting `run-glib-tls-qualification.ps1` on native Windows; +all three lanes must meet their declared outcomes. This focused test does not +replace complete candidate SDK, consumer, font, media, or GPU qualification. + +The same focused helper accepts `--library cairo` (GLib is the default) with +Cairo's locked source archive and distinct work/output directories. Build GLib +first, then pass its output through `--glib-bundle` to the Cairo invocation. The +helper checks that bundle's source/recipe/tool hashes, sealed files and actual +PE callback registration, and stages the same repaired GLib bytes in all three +Cairo lanes. This isolates Cairo's result from its FreeType/HarfBuzz/GLib +dependency chain without changing the SDK's original ownership receipts. Its native +probe observes `_cairo_mutex_initialized == 2` immediately after loading, +before any API can initialize the mutexes lazily, exercises image/context and +Win32 font APIs, releases the objects, unloads and reloads the DLL. The original +and relinked controls must fail on missing TLS registration. Successful unload +is recorded without claiming direct observation of the detach callback's work. diff --git a/windows/build-dependencies.py b/windows/build-dependencies.py index d15df3b..a072b22 100644 --- a/windows/build-dependencies.py +++ b/windows/build-dependencies.py @@ -12,6 +12,7 @@ import fnmatch import hashlib import io +import importlib.util import json import math import os @@ -896,6 +897,16 @@ def verify(prefix: Path, llvm: Path, lock: dict | None = None) -> dict: reports[kind][name] = {**record, "imports": sorted(names)} if not any(name == "ucrtbase.dll" or name.startswith("api-ms-win-crt-") for name in imports): raise ValueError("dependency closure does not import UCRT") + if {"glib", "cairo"} & sources.keys(): + # Source-declared callbacks need an actual CRT TLS registration table. + spec = importlib.util.spec_from_file_location("pe_tls", Path(__file__).with_name("pe_tls.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for library in ("glib", "cairo"): + if library in sources: + dll = module.LIBRARIES[library][0] + tls = module.inspect_file(prefix / "bin" / dll, llvm, library) + reports["dlls"][dll]["tls"] = tls report_path = prefix / "share/atrinik/windows-dependencies/pe-closure.json" result = {"schema_version": 2, **reports, "notices": receipt["notices"]} report_path.write_text(json.dumps(result, indent=2) + "\n") diff --git a/windows/cairo-tls-probe.c b/windows/cairo-tls-probe.c new file mode 100644 index 0000000..9ce3766 --- /dev/null +++ b/windows/cairo-tls-probe.c @@ -0,0 +1,96 @@ +/* SPDX-License-Identifier: MIT + * Copyright 2026 The Atrinik Project + */ +#include +#include +#include +#include +#include +#include "cairo-tls-rvas.h" + +static int qualify(void) +{ + HMODULE module = LoadLibraryW(L"libcairo-2.dll"); + if (!module) return 1; + const unsigned char *base = (const unsigned char *)module; + const IMAGE_DOS_HEADER *dos = (const IMAGE_DOS_HEADER *)base; + const IMAGE_NT_HEADERS64 *nt = (const IMAGE_NT_HEADERS64 *)(base + dos->e_lfanew); + const IMAGE_DATA_DIRECTORY *dir = &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; + const DWORD size = nt->OptionalHeader.SizeOfImage; + if (!dir->VirtualAddress || dir->Size != sizeof(IMAGE_TLS_DIRECTORY64)) { + fprintf(stderr, "Cairo TLS registration missing\n"); + return 2; + } + if (nt->Signature != IMAGE_NT_SIGNATURE || nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC || + size < sizeof(IMAGE_TLS_DIRECTORY64) || dir->VirtualAddress > size - sizeof(IMAGE_TLS_DIRECTORY64) || + TLS_CALLBACK_RVA >= size || TLS_INITIALIZED_FLAG_RVA > size - sizeof(LONG)) return 3; + const IMAGE_TLS_DIRECTORY64 *tls = (const IMAGE_TLS_DIRECTORY64 *)(base + dir->VirtualAddress); + const uintptr_t callbacks = (uintptr_t)tls->AddressOfCallBacks; + const uintptr_t start = (uintptr_t)base; + if (callbacks < start || callbacks > start + size - sizeof(uintptr_t)) return 3; + int registered = 0, terminated = 0; + for (size_t i = 0; i < 256; ++i) { + if (callbacks + i * sizeof(uintptr_t) > start + size - sizeof(uintptr_t)) return 4; + const uintptr_t callback = ((const uintptr_t *)callbacks)[i]; + if (!callback) { terminated = 1; break; } + if (callback == start + TLS_CALLBACK_RVA) registered = 1; + } + /* Inspect before any Cairo API can lazily initialize these mutexes. */ + LONG initialized = *(const volatile LONG *)(base + TLS_INITIALIZED_FLAG_RVA); + if (!registered || !terminated || initialized != (LONG)TLS_INITIALIZED_VALUE) { + fprintf(stderr, "Cairo TLS callback registered=%d initialized=%ld\n", registered, initialized); + return 5; + } +#define RESOLVE(name) \ + __typeof__(&name) call_##name = (__typeof__(&name))GetProcAddress(module, #name); \ + if (!call_##name) return 6 + RESOLVE(cairo_image_surface_create); + RESOLVE(cairo_surface_status); + RESOLVE(cairo_surface_destroy); + RESOLVE(cairo_create); + RESOLVE(cairo_status); + RESOLVE(cairo_destroy); + RESOLVE(cairo_set_source_rgb); + RESOLVE(cairo_paint); + RESOLVE(cairo_win32_font_face_create_for_logfontw); + RESOLVE(cairo_font_face_status); + RESOLVE(cairo_font_face_destroy); + RESOLVE(cairo_set_font_face); + RESOLVE(cairo_set_font_size); + RESOLVE(cairo_text_extents); +#undef RESOLVE + cairo_surface_t *surface = call_cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 32, 32); + cairo_t *context = call_cairo_create(surface); + LOGFONTW font = {0}; + font.lfHeight = -12; + font.lfCharSet = DEFAULT_CHARSET; + lstrcpyW(font.lfFaceName, L"Arial"); + cairo_font_face_t *face = call_cairo_win32_font_face_create_for_logfontw(&font); + call_cairo_set_source_rgb(context, 0.25, 0.5, 0.75); + call_cairo_paint(context); + call_cairo_set_font_face(context, face); + call_cairo_set_font_size(context, 12.0); + cairo_text_extents_t extents; + call_cairo_text_extents(context, "TLS", &extents); + int valid = call_cairo_surface_status(surface) == CAIRO_STATUS_SUCCESS && + call_cairo_status(context) == CAIRO_STATUS_SUCCESS && + call_cairo_font_face_status(face) == CAIRO_STATUS_SUCCESS && extents.x_advance > 0; + call_cairo_font_face_destroy(face); + call_cairo_destroy(context); + call_cairo_surface_destroy(surface); + if (!valid) return 7; + if (!FreeLibrary(module) || GetModuleHandleW(L"libcairo-2.dll") != NULL) return 8; + return 0; +} + +int main(void) +{ + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + for (int cycle = 0; cycle < 2; ++cycle) { + int result = qualify(); + if (result) return result; + } + printf("Cairo TLS pid=%lu callback_rva=%u process_attach_initialized=2 surface_font_smoke=1 explicit_unload=1 reload=1\n", + (unsigned long)GetCurrentProcessId(), TLS_CALLBACK_RVA); + return 0; +} diff --git a/windows/dependencies.json b/windows/dependencies.json index a29447e..e123665 100644 --- a/windows/dependencies.json +++ b/windows/dependencies.json @@ -868,7 +868,10 @@ "file": "src/glib-1-fixes.patch", "sha256": "644a94dd812484da0405d0d9ce5270ddb4c56c86446ca3cfa386a25d4b7c2998" } - ] + ], + "environment": { + "LDFLAGS": "-L{prefix}/lib -Wl,--require-defined,_tls_used" + } }, { "name": "freetype-bootstrap", @@ -1024,7 +1027,8 @@ } ], "environment": { - "CFLAGS": "-Wno-incompatible-pointer-types" + "CFLAGS": "-Wno-incompatible-pointer-types", + "LDFLAGS": "-L{prefix}/lib -Wl,--require-defined,_tls_used" } }, { diff --git a/windows/glib-tls-probe.c b/windows/glib-tls-probe.c new file mode 100644 index 0000000..dc3e1ca --- /dev/null +++ b/windows/glib-tls-probe.c @@ -0,0 +1,70 @@ +/* SPDX-License-Identifier: MIT + * Copyright 2026 The Atrinik Project + */ +#include +#include +#include +#include +#include "glib-tls-rvas.h" + +static volatile LONG destroyed; +static void (*private_set)(GPrivate *, gpointer); +static gpointer (*private_get)(GPrivate *); +static void destroy_private(gpointer value) +{ + if (value == &destroyed) + InterlockedIncrement(&destroyed); +} +static GPrivate private_value = G_PRIVATE_INIT(destroy_private); +static gpointer thread_main(gpointer value) +{ + private_set(&private_value, value); + return private_get(&private_value); +} + +int main(void) +{ + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + HMODULE module = LoadLibraryW(L"libglib-2.0-0.dll"); + if (!module) return 1; + const unsigned char *base = (const unsigned char *)module; + const IMAGE_DOS_HEADER *dos = (const IMAGE_DOS_HEADER *)base; + const IMAGE_NT_HEADERS64 *nt = (const IMAGE_NT_HEADERS64 *)(base + dos->e_lfanew); + const IMAGE_DATA_DIRECTORY *dir = &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; + const DWORD size = nt->OptionalHeader.SizeOfImage; + if (nt->Signature != IMAGE_NT_SIGNATURE || nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC || + !dir->VirtualAddress || dir->Size != sizeof(IMAGE_TLS_DIRECTORY64) || + size < sizeof(IMAGE_TLS_DIRECTORY64) || dir->VirtualAddress > size - sizeof(IMAGE_TLS_DIRECTORY64) || + TLS_CALLBACK_RVA >= size || TLS_INITIALIZED_FLAG_RVA > size - sizeof(gboolean)) return 2; + const IMAGE_TLS_DIRECTORY64 *tls = (const IMAGE_TLS_DIRECTORY64 *)(base + dir->VirtualAddress); + const uintptr_t callbacks = (uintptr_t)tls->AddressOfCallBacks; + const uintptr_t start = (uintptr_t)base; + if (callbacks < start || callbacks > start + size - sizeof(uintptr_t)) return 3; + gboolean registered = FALSE, terminated = FALSE; + for (size_t i = 0; i < 256; ++i) { + if (callbacks + i * sizeof(uintptr_t) > start + size - sizeof(uintptr_t)) return 4; + const uintptr_t callback = ((const uintptr_t *)callbacks)[i]; + if (!callback) { terminated = TRUE; break; } + if (callback == start + TLS_CALLBACK_RVA) registered = TRUE; + } + const gboolean invoked = *(const volatile gboolean *)(base + TLS_INITIALIZED_FLAG_RVA); + if (!registered || !terminated || invoked != TRUE) { + fprintf(stderr, "GLib TLS callback registered=%d invoked=%d terminated=%d\n", registered, invoked, terminated); + return 5; + } + /* The producer deliberately uses POSIX threads. This exercises GPrivate's + * real thread destructor, not THREADS_WIN32-only callback cleanup. */ + private_set = (void (*)(GPrivate *, gpointer))GetProcAddress(module, "g_private_set"); + private_get = (gpointer (*)(GPrivate *))GetProcAddress(module, "g_private_get"); + GThread *(*thread_new)(const gchar *, GThreadFunc, gpointer) = + (GThread *(*)(const gchar *, GThreadFunc, gpointer))GetProcAddress(module, "g_thread_new"); + gpointer (*thread_join)(GThread *) = (gpointer (*)(GThread *))GetProcAddress(module, "g_thread_join"); + if (!private_set || !private_get || !thread_new || !thread_join) return 6; + GThread *thread = thread_new("tls-qualification", thread_main, (gpointer)&destroyed); + if (thread_join(thread) != (gpointer)&destroyed || InterlockedCompareExchange(&destroyed, 0, 0) != 1) + return 6; + if (!FreeLibrary(module) || GetModuleHandleW(L"libglib-2.0-0.dll") != NULL) return 7; + printf("GLib TLS pid=%lu callback_rva=%u process_attach_invoked=1 private_destructors=1 explicit_unload=1\n", + (unsigned long)GetCurrentProcessId(), TLS_CALLBACK_RVA); + return 0; +} diff --git a/windows/pe_tls.py b/windows/pe_tls.py new file mode 100644 index 0000000..dbf5bc0 --- /dev/null +++ b/windows/pe_tls.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +"""Inspect the actual AMD64 PE TLS callback table; never execute the DLL.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import struct +import subprocess + + +class PE: + def __init__(self, data: bytes): + self.data = data + if len(data) > 256 * 1024 * 1024 or self.bytes(0, 2) != b'MZ': + raise ValueError('expected bounded PE image') + pe = self.unpack(' self.image_size: + raise ValueError('section outside image') + self.bytes(raw, raw_size) + for previous in self.sections: + if rva < previous[0] + previous[1] and previous[0] < rva + extent: + raise ValueError('overlapping PE sections') + self.sections.append((rva, extent, raw, raw_size, flags)) + + def bytes(self, offset: int, size: int) -> bytes: + if offset < 0 or size < 0 or offset + size > len(self.data): + raise ValueError('truncated PE data') + return self.data[offset:offset + size] + + def unpack(self, fmt: str, offset: int): + return struct.unpack(fmt, self.bytes(offset, struct.calcsize(fmt))) + + def section(self, rva: int, size: int = 1): + for entry in self.sections: + if entry[0] <= rva and rva + size <= entry[0] + entry[1]: + return entry + raise ValueError('RVA outside PE sections') + + def at_rva(self, rva: int, size: int) -> bytes: + start, _, raw, raw_size, _ = self.section(rva, size) + if rva - start + size > raw_size: + raise ValueError('TLS structure is not backed by file data') + return self.bytes(raw + rva - start, size) + + def callbacks(self) -> list[int]: + if not self.tls_rva or self.tls_size != 40: + raise ValueError('missing or invalid PE TLS directory') + _, _, index, callbacks, _, _ = struct.unpack(' dict: + dll, callback_symbol, flag_symbol, initialized_value = LIBRARIES[library] + pe = PE(data) + callbacks = pe.callbacks() + wanted = {callback_symbol, flag_symbol, '_tls_used', '_ptr_' + callback_symbol} + found = {} + for line in symbols.splitlines(): + fields = line.split() + if fields and fields[0] in wanted: + if len(fields) < 3 or fields[0] in found: + raise ValueError('invalid or duplicate library TLS symbol') + found[fields[0]] = int(fields[2], 16) - pe.base + if set(found) != wanted: + raise ValueError('missing library TLS callback/flag symbols') + if found['_tls_used'] != pe.tls_rva: + raise ValueError('TLS directory does not identify _tls_used') + callback = found[callback_symbol] + flag = found[flag_symbol] + pointer, = struct.unpack(' dict: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 256 * 1024 * 1024: + raise ValueError('library DLL must be a bounded normal file') + data = path.read_bytes() + symbols = subprocess.run([str(llvm / 'bin/llvm-nm'), '--defined-only', '--format=posix', str(path)], + check=True, capture_output=True, text=True, timeout=30).stdout + report = inspect_callback(data, symbols, library) + if path.read_bytes() != data: + raise ValueError('library DLL changed during TLS inspection') + return report + + +def inspect_glib(data: bytes, symbols: str) -> dict: + return inspect_callback(data, symbols, 'glib') + + +def inspect_glib_file(path: Path, llvm: Path) -> dict: + return inspect_file(path, llvm, 'glib') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--library', choices=LIBRARIES, default='glib') + parser.add_argument('--dll', required=True, type=Path) + parser.add_argument('--llvm', default=Path('/opt/llvm-mingw'), type=Path) + parser.add_argument('--report', required=True, type=Path) + parser.add_argument('--header', type=Path) + parser.add_argument('--probe', type=Path) + args = parser.parse_args() + report = inspect_file(args.dll, args.llvm, args.library) + if args.probe: + if args.probe.is_symlink() or not args.probe.is_file() or args.probe.stat().st_size > 16 * 1024 * 1024: + raise ValueError('native probe must be a bounded normal file') + report['probe_sha256'] = hashlib.sha256(args.probe.read_bytes()).hexdigest() + args.report.write_text(json.dumps(report, indent=2) + '\n') + if args.header: + args.header.write_text('/* Generated from the inspected library DLL. */\n' + f'#define TLS_CALLBACK_RVA {report["callback_rva"]}u\n' + f'#define TLS_INITIALIZED_FLAG_RVA {report["initialized_flag_rva"]}u\n' + f'#define TLS_INITIALIZED_VALUE {report["initialized_value"]}u\n') + + +if __name__ == '__main__': + main() diff --git a/windows/qualify-glib-tls.py b/windows/qualify-glib-tls.py new file mode 100644 index 0000000..9c7a894 --- /dev/null +++ b/windows/qualify-glib-tls.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +"""Build a focused library TLS native qualification bundle in an offline LLVM SDK. + +This is diagnostic acceptance of one library, not qualification of a rebuilt SDK. +The SDK remains unchanged; all build outputs use a new caller-owned directory. +""" +import argparse +import importlib.util +import hashlib +import json +import os +import re +from pathlib import Path, PurePosixPath +import shutil +import subprocess + +from pe_tls import LIBRARIES, inspect_file + +ROOT = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('producer', ROOT / 'build-dependencies.py') +BUILDER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BUILDER) + + +def source_provenance(items, library): + return { + 'source_inputs': {name: items[name]['sha256'] for name in (library, 'meson', 'mxe-recipes')}, + 'recipe_sha256': hashlib.sha256(json.dumps(items[library], sort_keys=True).encode()).hexdigest(), + 'tools_sha256': {name: BUILDER.file_hash(ROOT / name) for name in + ('qualify-glib-tls.py', 'build-dependencies.py', 'pe_tls.py', library + '-tls-probe.c')}, + } + + +def validated_glib_dependency(bundle, items, llvm, prefix): + """Admit a freshly sealed repair, never the SDK's known-bad transitive DLL.""" + if bundle is None or bundle.is_symlink() or not bundle.is_dir(): + raise ValueError('Cairo requires --glib-bundle from the repaired GLib qualification') + manifest = bundle / 'qualification.json' + if manifest.is_symlink() or not manifest.is_file(): + raise ValueError('missing normal GLib qualification manifest') + metadata = json.loads(manifest.read_text()) + if (metadata.get('schema_version') != 1 or metadata.get('library') != 'glib' + or metadata.get('source_sha256') != items['glib']['sha256'] + or metadata.get('provenance') != source_provenance(items, 'glib')): + raise ValueError('GLib dependency source/provenance does not match this qualification') + files = metadata.get('files', {}) + required = {'candidate/libglib-2.0-0.dll', 'candidate/atrinik-glib-tls-probe.exe', + 'build-evidence/glib-tls.json'} + if not required.issubset(files): + raise ValueError('GLib dependency bundle lacks sealed candidate evidence') + for relative, digest in files.items(): + path = PurePosixPath(relative) + if path.is_absolute() or '..' in path.parts or not path.parts or '\\' in relative: + raise ValueError('unsafe GLib dependency evidence path') + current = bundle + for part in path.parts: + current /= part + if current.is_symlink(): + raise ValueError('linked GLib dependency evidence') + if not current.is_file() or BUILDER.file_hash(current) != digest: + raise ValueError('changed GLib dependency evidence: ' + relative) + candidate = bundle / 'candidate/libglib-2.0-0.dll' + tls = inspect_file(candidate, llvm, 'glib') + if tls != metadata.get('tls') or tls != json.loads((bundle / 'build-evidence/glib-tls.json').read_text()): + raise ValueError('GLib dependency TLS report does not match actual PE registration') + # Its supporting runtime must also come from the same immutable SDK. + for path in (bundle / 'candidate').glob('*.dll'): + if path == candidate: + continue + relative = 'candidate/' + path.name + sdk = prefix / 'bin' / path.name + if relative not in files or not sdk.is_file() or BUILDER.file_hash(sdk) != files[relative]: + raise ValueError('GLib dependency uses a different supporting SDK runtime') + return candidate, {'sha256': tls['sha256'], 'source_sha256': metadata['source_sha256'], + 'qualification_sha256': BUILDER.file_hash(manifest), 'tls': tls} + + +def run(args): + os.umask(0o022) + if args.work_dir.exists() or args.output.exists(): + raise ValueError('qualification requires absent work and output directories') + library = args.library + dll_name = LIBRARIES[library][0] + probe_name = f'atrinik-{library}-tls-probe.exe' + target = 'glib/libglib-2.0-0.dll' if library == 'glib' else 'src/libcairo-2.dll' + lock = BUILDER.load_lock(ROOT / 'dependencies.json') + items = {item['name']: item for item in lock['dependencies']} + dependency = None + if library == 'cairo': + dependency = validated_glib_dependency(args.glib_bundle, items, args.llvm, args.prefix) + elif args.glib_bundle is not None: + raise ValueError('--glib-bundle is only valid for Cairo') + inventory = args.prefix / 'share/atrinik/windows-dependencies' + ownership = json.loads((inventory / 'runtime-ownership.json').read_text()) + BUILDER.assert_receipt(args.prefix, ownership) + original = args.prefix / 'bin' / dll_name + if ownership['dlls'][original.name]['source_sha256'] != items[library]['sha256']: + raise ValueError('SDK library source does not match qualification source') + args.work_dir.mkdir(parents=True) + args.output.mkdir(parents=True) + logs = args.output / 'build-evidence' + logs.mkdir() + sources = {} + for name in (library, 'meson', 'mxe-recipes'): + item = items[name] + archive = args.source_archive if name == library and args.source_archive else inventory / item['archive'] + if BUILDER.file_hash(archive) != item['sha256']: + raise ValueError(f'qualification source checksum mismatch: {name}') + sources[name] = BUILDER.extract(archive, args.work_dir / 'sources' / name) + source = sources[library] + for patch in items[library]['patches']: + content = (sources['mxe-recipes'] / patch['file']).read_bytes() + if hashlib.sha256(content).hexdigest() != patch['sha256']: + raise ValueError('library upstream recipe patch mismatch') + for old, new in patch.get('replacements', {}).items(): + content = content.replace(old.encode(), new.encode()) + subprocess.run(['patch', '--batch', '--fuzz=0', '-p1'], cwd=source, input=content, check=True) + (args.work_dir / 'llvm-native.ini').write_text("[binaries]\nc = 'clang'\ncpp = 'clang++'\n") + (args.work_dir / 'llvm-cross.ini').write_text( + "[binaries]\nc = 'x86_64-w64-mingw32-clang'\ncpp = 'x86_64-w64-mingw32-clang++'\n" + "ar = 'llvm-ar'\nstrip = 'llvm-strip'\nwindres = 'x86_64-w64-mingw32-windres'\n" + "pkg-config = 'x86_64-w64-mingw32-pkg-config'\n[host_machine]\nsystem = 'windows'\n" + "cpu_family = 'x86_64'\ncpu = 'x86_64'\nendian = 'little'\n[properties]\nneeds_exe_wrapper = true\n") + env = BUILDER.environment(args.prefix, args.work_dir / 'host', args.llvm, + host_llvm=args.host_llvm, build_system='meson') + # Preserve recipe warnings/features; only omit the new linker flag in the + # deliberately failing diagnostic build. + for key, value in items[library].get('environment', {}).items(): + if key != 'LDFLAGS': + env[key] = value.replace('{prefix}', str(args.prefix)) + meson = ['python3', str(sources['meson'] / 'meson.py')] + build = args.work_dir / 'build' / library + build.parent.mkdir() + commands = BUILDER.commands(items[library], source, build, args.prefix, 2, env=env) + def command(name, argv): + with (logs / (name + '.log')).open('w') as output: + result = subprocess.run(argv, env=env, stdout=output, stderr=subprocess.STDOUT, timeout=300) + if result.returncode: + print((logs / (name + '.log')).read_text()[-16000:], flush=True) + result.check_returncode() + command('setup', meson + commands[0][1:]) + command('baseline', ['ninja', '-C', str(build), '-j2', target]) + baseline = args.work_dir / 'baseline.dll' + shutil.copy2(build / target, baseline) + fixed_flags = items[library]['environment']['LDFLAGS'].replace('{prefix}', str(args.prefix)).split() + if '-Wl,--require-defined,_tls_used' not in fixed_flags: + raise ValueError('target library recipe lacks mandatory TLS directory reference') + fixed_flags += ['-Wl,-Map,' + str(logs / (library + '-link.map'))] + command('configure-fixed', meson + ['configure', str(build), '-Dc_link_args=' + json.dumps(fixed_flags), + '-Dcpp_link_args=' + json.dumps(fixed_flags)]) + command('fixed', ['ninja', '-C', str(build), '-j2', '-v', target]) + fixed = build / target + interfaces = {} + for name, dll in (('baseline', baseline), ('fixed', fixed)): + result = subprocess.run([str(args.llvm / 'bin/llvm-readobj'), '--coff-imports', '--coff-exports', str(dll)], + check=True, capture_output=True, text=True, timeout=30) + (logs / (name + '-interfaces.txt')).write_text(result.stdout) + interfaces[name] = re.findall(r'^ Name: (.+)$', result.stdout, re.MULTILINE) + if interfaces['baseline'] != interfaces['fixed']: + raise ValueError('library linker correction changed imported/exported interfaces') + tls = inspect_file(fixed, args.llvm, library) + (logs / (library + '-tls.json')).write_text(json.dumps(tls, indent=2) + '\n') + # The map proves which CRT object supplied the previously absent directory. + link_map = (logs / (library + '-link.map')).read_text() + if '_tls_used' not in link_map or 'tlssup' not in link_map: + raise ValueError('link map lacks CRT TLS directory contribution') + for name, dll in (('original', original), ('relinked', baseline)): + try: + inspect_file(dll, args.llvm, library) + except ValueError as error: + if str(error) != 'missing or invalid PE TLS directory': + raise + else: + raise ValueError(f'{name} no longer reproduces missing TLS directory') + header = args.work_dir / (library + '-tls-rvas.h') + header.write_text(f'#define TLS_CALLBACK_RVA {tls["callback_rva"]}u\n' + f'#define TLS_INITIALIZED_FLAG_RVA {tls["initialized_flag_rva"]}u\n' + f'#define TLS_INITIALIZED_VALUE {tls["initialized_value"]}u\n') + compiler = str(args.llvm / 'bin/x86_64-w64-mingw32-clang') + cflags = subprocess.run(['x86_64-w64-mingw32-pkg-config', '--cflags', 'glib-2.0' if library == 'glib' else 'cairo'], + env=env, check=True, text=True, capture_output=True).stdout.split() + probe = args.work_dir / probe_name + command('probe-build', [compiler, '-std=c17', '-O2', '-Wall', '-Wextra', '-Werror', + '-I' + str(args.work_dir), str(ROOT / (library + '-tls-probe.c')), *cflags, '-o', str(probe)]) + readobj = str(args.llvm / 'bin/llvm-readobj') + def imports(path): + return BUILDER.parse_imports(subprocess.run([readobj, '--coff-imports', str(path)], + check=True, capture_output=True, text=True).stdout) + runtime = {p.name.lower(): p for p in (args.prefix / 'bin').glob('*.dll')} + if dependency is not None: + runtime['libglib-2.0-0.dll'] = dependency[0] + for name, dll in (('candidate', fixed), ('original', original), ('relinked', baseline)): + stage = args.output / name + stage.mkdir() + shutil.copy2(dll, stage / original.name) + shutil.copy2(probe, stage / probe.name) + pending = list(imports(dll) | imports(probe)) + copied = {original.name.lower()} + while pending: + needed = pending.pop() + if needed in copied or needed in BUILDER.SYSTEM_DLLS or needed.startswith(('api-ms-win-', 'ext-ms-win-')): + continue + if needed not in runtime: + raise ValueError(f'unresolved native probe import: {needed}') + shutil.copy2(runtime[needed], stage / runtime[needed].name) + copied.add(needed) + pending.extend(imports(runtime[needed])) + if dependency is not None: + # Recheck after the build/copy and inspect every staged instance. All + # controls vary Cairo only; they share exactly the repaired GLib bytes. + if validated_glib_dependency(args.glib_bundle, items, args.llvm, args.prefix) != dependency: + raise ValueError('GLib dependency changed during Cairo qualification') + for lane in ('original', 'relinked', 'candidate'): + path = args.output / lane / 'libglib-2.0-0.dll' + if not path.is_file() or inspect_file(path, args.llvm, 'glib') != dependency[1]['tls']: + raise ValueError('Cairo lane lacks the identical repaired GLib dependency') + shutil.copy2(ROOT / 'run-glib-tls-qualification.ps1', args.output) + metadata = {'schema_version': 1, 'scope': 'focused library TLS diagnostic, not complete SDK qualification', 'library': library, + 'source_sha256': items[library]['sha256'], 'tls': tls, + 'provenance': source_provenance(items, library), + 'repaired_dependencies': {'glib': dependency[1]} if dependency is not None else {}, + 'compiler': subprocess.run([compiler, '--version'], check=True, capture_output=True, text=True).stdout, + 'files': {p.relative_to(args.output).as_posix(): BUILDER.file_hash(p) + for p in sorted(args.output.rglob('*')) if p.is_file()}} + (args.output / 'qualification.json').write_text(json.dumps(metadata, indent=2) + '\n') + # No accepted SDK receipt is rewritten by this diagnostic rebuild. + BUILDER.assert_receipt(args.prefix, ownership) + print(json.dumps({'status': 'built', 'output': str(args.output), 'fixed_tls': tls}), flush=True) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--library', choices=LIBRARIES, default='glib') + parser.add_argument('--prefix', type=Path, default=Path('/opt/atrinik-windows')) + parser.add_argument('--llvm', type=Path, default=Path('/opt/llvm-mingw')) + parser.add_argument('--host-llvm', type=Path, default=Path('/opt/llvm-23.1.3')) + parser.add_argument('--glib-bundle', type=Path) + parser.add_argument('--source-archive', type=Path) + parser.add_argument('--work-dir', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + run(parser.parse_args()) diff --git a/windows/run-glib-tls-qualification.ps1 b/windows/run-glib-tls-qualification.ps1 new file mode 100644 index 0000000..a34c14e --- /dev/null +++ b/windows/run-glib-tls-qualification.ps1 @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +$ErrorActionPreference = 'Stop' +$bundle = $PSScriptRoot +$metadata = Get-Content -Raw (Join-Path $bundle 'qualification.json') | ConvertFrom-Json +if ($metadata.schema_version -ne 1 -or $metadata.library -notin @('glib', 'cairo')) { + throw 'Unsupported GLib qualification bundle' +} +foreach ($file in $metadata.files.PSObject.Properties) { + if ([IO.Path]::IsPathRooted($file.Name) -or '..' -in ($file.Name -split '[/\\]')) { + throw 'Unsafe GLib qualification path' + } + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $bundle $file.Name)).Hash.ToLowerInvariant() + if ($actual -ne $file.Value) { throw "Qualification file changed: $($file.Name)" } +} +$library = $metadata.library +$dllName = if ($library -eq 'glib') { 'libglib-2.0-0.dll' } else { 'libcairo-2.dll' } +$probeName = "atrinik-$library-tls-probe.exe" +if ($metadata.tls.dll -ne $dllName) { throw 'TLS report library mismatch' } +if ($library -eq 'cairo') { + $glibDependency = $metadata.repaired_dependencies.glib + if (-not $glibDependency -or $glibDependency.tls.library -ne 'glib' -or + $glibDependency.sha256 -ne $glibDependency.tls.sha256) { + throw 'Cairo lacks sealed repaired GLib dependency evidence' + } +} +$results = @() +foreach ($lane in @('original', 'relinked', 'candidate')) { + $directory = Join-Path $bundle $lane + foreach ($name in @($probeName, $dllName)) { + if (-not $metadata.files.PSObject.Properties["$lane/$name"]) { throw "Unsealed $lane/$name" } + } + $dllHash = (Get-FileHash -Algorithm SHA256 (Join-Path $directory $dllName)).Hash.ToLowerInvariant() + if ($lane -eq 'candidate' -and $dllHash -ne $metadata.tls.sha256) { throw 'Candidate TLS report mismatch' } + if ($library -eq 'cairo') { + $glibPath = "$lane/libglib-2.0-0.dll" + $sealed = $metadata.files.PSObject.Properties[$glibPath] + if (-not $sealed -or $sealed.Value -ne $glibDependency.sha256 -or + (Get-FileHash -Algorithm SHA256 (Join-Path $bundle $glibPath)).Hash.ToLowerInvariant() -ne $glibDependency.sha256) { + throw 'Cairo controls must share the identical repaired GLib dependency' + } + } + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $directory $probeName + $start.WorkingDirectory = $directory + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Environment['PATH'] = "${directory};$env:SystemRoot\System32;$env:SystemRoot" + $start.Environment['G_DEBUG'] = 'fatal-criticals' + $process = [Diagnostics.Process]::Start($start) + try { + $pidValue = $process.Id + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $timedOut = -not $process.WaitForExit(15000) + if ($timedOut) { + $process.Kill($true) + if (-not $process.WaitForExit(5000)) { throw "$lane did not stop after timeout" } + } + $out = $stdout.GetAwaiter().GetResult() + $err = $stderr.GetAwaiter().GetResult() + [IO.File]::WriteAllText((Join-Path $bundle "$lane.stdout.txt"), $out) + [IO.File]::WriteAllText((Join-Path $bundle "$lane.stderr.txt"), $err) + $result = [ordered]@{ lane = $lane; pid = $pidValue; dll_sha256 = $dllHash; + exit_code = $process.ExitCode; timed_out = $timedOut; passed = $false } + if (-not $timedOut) { + if ($lane -eq 'candidate') { + $expected = if ($library -eq 'glib') { 'process_attach_invoked=1 private_destructors=1 explicit_unload=1' } else { 'process_attach_initialized=2 surface_font_smoke=1 explicit_unload=1 reload=1' } + $result.passed = $process.ExitCode -eq 0 -and -not $err -and $out -match $expected + } else { + $expected = if ($library -eq 'glib') { 'TLS callback not invoked' } else { 'Cairo TLS registration missing' } + $result.passed = $process.ExitCode -ne 0 -and $err -match $expected + } + } + $results += $result + $results | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $bundle 'native-results.json') + Write-Host ($result | ConvertTo-Json -Compress) + if (-not $result.passed) { throw "$lane failed GLib TLS qualification; inspect separate stdout/stderr" } + } finally { + $process.Dispose() + } +}