From 233103c6fabff8b5926c18d4610852763718eccf Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 07:09:25 -0500 Subject: [PATCH 1/7] fix(windows): register and qualify GLib TLS callbacks --- AGENTS.md | 4 + tools/run-classic-native-tests.ps1 | 40 +++++++ tools/smoke-classic-check.sh | 5 +- tools/tests/test_pe_tls.py | 92 ++++++++++++++ windows/Dockerfile | 16 ++- windows/README.md | 19 +++ windows/build-dependencies.py | 9 ++ windows/dependencies.json | 5 +- windows/glib-tls-probe.c | 70 +++++++++++ windows/pe_tls.py | 154 ++++++++++++++++++++++++ windows/qualify-glib-tls.py | 158 +++++++++++++++++++++++++ windows/run-glib-tls-qualification.ps1 | 63 ++++++++++ 12 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 tools/tests/test_pe_tls.py create mode 100644 windows/glib-tls-probe.c create mode 100644 windows/pe_tls.py create mode 100644 windows/qualify-glib-tls.py create mode 100644 windows/run-glib-tls-qualification.ps1 diff --git a/AGENTS.md b/AGENTS.md index aa06fbf..bd8e8d4 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 qualification requires actual PE TLS callback registration and + the hash-bound native callback/destructor/unload probe. 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..6ec44f5 100644 --- a/tools/run-classic-native-tests.ps1 +++ b/tools/run-classic-native-tests.ps1 @@ -32,6 +32,46 @@ function Invoke-QualifiedNativeTest { } } +# Check the same DLL whose private callback/flag RVAs are compiled into the probe. +$glib = Get-Content -Raw (Join-Path $bundle 'glib-tls.json') | ConvertFrom-Json +$glibHash = (Get-FileHash -Algorithm SHA256 (Join-Path $bundle 'libglib-2.0-0.dll')).Hash.ToLowerInvariant() +if ($glib.schema_version -ne 1 -or $glib.dll -ne 'libglib-2.0-0.dll' -or $glibHash -ne $glib.sha256) { + throw 'GLib TLS probe does not match the staged DLL' +} +$probeHash = (Get-FileHash -Algorithm SHA256 (Join-Path $bundle 'atrinik-glib-tls-probe.exe')).Hash.ToLowerInvariant() +if ($probeHash -ne $glib.probe_sha256) { throw 'GLib TLS probe executable differs from its producer receipt' } +$glibStart = [Diagnostics.ProcessStartInfo]::new() +$glibStart.FileName = Join-Path $bundle 'atrinik-glib-tls-probe.exe' +$glibStart.WorkingDirectory = $bundle +$glibStart.UseShellExecute = $false +$glibStart.RedirectStandardOutput = $true +$glibStart.RedirectStandardError = $true +$glibStart.Environment['G_DEBUG'] = 'fatal-criticals' +$glibProcess = [Diagnostics.Process]::Start($glibStart) +try { + Write-Host "GLib TLS qualification pid=$($glibProcess.Id) dll_sha256=$glibHash" + $stdout = $glibProcess.StandardOutput.ReadToEndAsync() + $stderr = $glibProcess.StandardError.ReadToEndAsync() + $timedOut = -not $glibProcess.WaitForExit(15000) + if ($timedOut) { + $glibProcess.Kill($true) + if (-not $glibProcess.WaitForExit(5000)) { throw 'GLib TLS probe did not stop after timeout' } + } + $out = $stdout.GetAwaiter().GetResult() + $err = $stderr.GetAwaiter().GetResult() + [IO.File]::WriteAllText((Join-Path $bundle 'glib-tls.stdout.log'), $out) + [IO.File]::WriteAllText((Join-Path $bundle 'glib-tls.stderr.log'), $err) + Write-Host $out + if ($err) { Write-Host $err } + if ($timedOut) { throw 'GLib TLS probe exceeded 15000 ms' } + if ($glibProcess.ExitCode -ne 0) { throw "GLib TLS probe failed: $($glibProcess.ExitCode)" } + if ($out -notmatch 'process_attach_invoked=1 private_destructors=1 explicit_unload=1' -or $err) { + throw 'GLib TLS callback/destructor/unload evidence is incomplete or emitted diagnostics' + } +} finally { + $glibProcess.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..bb25464 100755 --- a/tools/smoke-classic-check.sh +++ b/tools/smoke-classic-check.sh @@ -170,7 +170,10 @@ 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" + cmake -E copy /opt/atrinik-windows/share/atrinik/windows-dependencies/glib-tls.json "${stage}/glib-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..d3f022e --- /dev/null +++ b/tools/tests/test_pe_tls.py @@ -0,0 +1,92 @@ +# 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(' 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" in sources: + # The CRT TLS directory must register GLib's actual retained callback. + # A .CRT callback pointer or a successful link alone cannot prove this. + 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) + tls = module.inspect_glib_file(prefix / "bin/libglib-2.0-0.dll", llvm) + reports["dlls"]["libglib-2.0-0.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/dependencies.json b/windows/dependencies.json index a29447e..9db5e72 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", diff --git a/windows/glib-tls-probe.c b/windows/glib-tls-probe.c new file mode 100644 index 0000000..a863763 --- /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) || + GLIB_TLS_CALLBACK_RVA >= size || GLIB_TLS_INVOKED_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 + GLIB_TLS_CALLBACK_RVA) registered = TRUE; + } + const gboolean invoked = *(const volatile gboolean *)(base + GLIB_TLS_INVOKED_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(), GLIB_TLS_CALLBACK_RVA); + return 0; +} diff --git a/windows/pe_tls.py b/windows/pe_tls.py new file mode 100644 index 0000000..b869789 --- /dev/null +++ b/windows/pe_tls.py @@ -0,0 +1,154 @@ +#!/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: + pe = PE(data) + callbacks = pe.callbacks() + wanted = {'glib_priv_tls_callback', 'tls_callback_invoked', '_tls_used', '_ptr_glib_priv_tls_callback'} + 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 GLib TLS symbol') + found[fields[0]] = int(fields[2], 16) - pe.base + if set(found) != wanted: + raise ValueError('missing GLib TLS callback/flag symbols') + if found['_tls_used'] != pe.tls_rva: + raise ValueError('TLS directory does not identify _tls_used') + callback = found['glib_priv_tls_callback'] + flag = found['tls_callback_invoked'] + pointer, = struct.unpack(' dict: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 256 * 1024 * 1024: + raise ValueError('GLib 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_glib(data, symbols) + if path.read_bytes() != data: + raise ValueError('GLib DLL changed during TLS inspection') + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + 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_glib_file(args.dll, args.llvm) + 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 GLib DLL. */\n' + f'#define GLIB_TLS_CALLBACK_RVA {report["glib_callback_rva"]}u\n' + f'#define GLIB_TLS_INVOKED_FLAG_RVA {report["glib_invoked_flag_rva"]}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..2bc5129 --- /dev/null +++ b/windows/qualify-glib-tls.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +"""Build a focused GLib TLS native qualification bundle in an offline LLVM SDK. + +This is diagnostic acceptance of GLib, 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 json +import os +import re +from pathlib import Path +import shutil +import subprocess + +from pe_tls import inspect_glib_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 run(args): + os.umask(0o022) + if args.work_dir.exists() or args.output.exists(): + raise ValueError('qualification requires absent work and output directories') + lock = BUILDER.load_lock(ROOT / 'dependencies.json') + items = {item['name']: item for item in lock['dependencies']} + 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/libglib-2.0-0.dll' + if ownership['dlls'][original.name]['source_sha256'] != items['glib']['sha256']: + raise ValueError('SDK GLib 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 ('glib', 'meson', 'mxe-recipes'): + item = items[name] + archive = args.source_archive if name == 'glib' 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['glib'] + for patch in items['glib']['patches']: + content = (sources['mxe-recipes'] / patch['file']).read_bytes() + import hashlib + if hashlib.sha256(content).hexdigest() != patch['sha256']: + raise ValueError('GLib upstream recipe patch mismatch') + 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') + meson = ['python3', str(sources['meson'] / 'meson.py')] + build = args.work_dir / 'build/glib' + build.parent.mkdir() + commands = BUILDER.commands(items['glib'], 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', 'glib/libglib-2.0-0.dll']) + baseline = args.work_dir / 'baseline.dll' + shutil.copy2(build / 'glib/libglib-2.0-0.dll', baseline) + fixed_flags = items['glib']['environment']['LDFLAGS'].replace('{prefix}', str(args.prefix)).split() + if '-Wl,--require-defined,_tls_used' not in fixed_flags: + raise ValueError('target GLib recipe lacks mandatory TLS directory reference') + fixed_flags += ['-Wl,-Map,' + str(logs / 'glib-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', 'glib/libglib-2.0-0.dll']) + fixed = build / 'glib/libglib-2.0-0.dll' + 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('GLib linker correction changed imported/exported interfaces') + tls = inspect_glib_file(fixed, args.llvm) + (logs / 'glib-tls.json').write_text(json.dumps(tls, indent=2) + '\n') + # The map proves which CRT object supplied the previously absent directory. + link_map = (logs / 'glib-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_glib_file(dll, args.llvm) + 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 / 'glib-tls-rvas.h' + header.write_text(f'#define GLIB_TLS_CALLBACK_RVA {tls["glib_callback_rva"]}u\n' + f'#define GLIB_TLS_INVOKED_FLAG_RVA {tls["glib_invoked_flag_rva"]}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'], + env=env, check=True, text=True, capture_output=True).stdout.split() + probe = args.work_dir / 'atrinik-glib-tls-probe.exe' + command('probe-build', [compiler, '-std=c17', '-O2', '-Wall', '-Wextra', '-Werror', + '-I' + str(args.work_dir), str(ROOT / 'glib-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')} + 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])) + shutil.copy2(ROOT / 'run-glib-tls-qualification.ps1', args.output) + metadata = {'schema_version': 1, 'scope': 'focused GLib TLS diagnostic, not complete SDK qualification', + 'glib_source_sha256': items['glib']['sha256'], 'tls': tls, + '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('--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('--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..df136e3 --- /dev/null +++ b/windows/run-glib-tls-qualification.ps1 @@ -0,0 +1,63 @@ +# 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.tls.dll -ne 'libglib-2.0-0.dll') { + 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)" } +} +$results = @() +foreach ($lane in @('original', 'relinked', 'candidate')) { + $directory = Join-Path $bundle $lane + foreach ($name in @('atrinik-glib-tls-probe.exe', 'libglib-2.0-0.dll')) { + if (-not $metadata.files.PSObject.Properties["$lane/$name"]) { throw "Unsealed $lane/$name" } + } + $dllHash = (Get-FileHash -Algorithm SHA256 (Join-Path $directory 'libglib-2.0-0.dll')).Hash.ToLowerInvariant() + if ($lane -eq 'candidate' -and $dllHash -ne $metadata.tls.sha256) { throw 'Candidate TLS report mismatch' } + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $directory 'atrinik-glib-tls-probe.exe' + $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') { + $result.passed = $process.ExitCode -eq 0 -and -not $err -and + $out -match 'process_attach_invoked=1 private_destructors=1 explicit_unload=1' + } else { + $result.passed = $process.ExitCode -ne 0 -and $err -match 'TLS callback not invoked' + } + } + $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() + } +} From b3c4b2cf0dfbc2fa2efcb74a6f00f11f4846d328 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 07:16:34 -0500 Subject: [PATCH 2/7] fix(windows): register and qualify Cairo TLS initialization --- AGENTS.md | 4 +- tools/run-classic-native-tests.ps1 | 78 +++++++++++---------- tools/smoke-classic-check.sh | 5 +- tools/tests/test_pe_tls.py | 15 +++- windows/Dockerfile | 14 +++- windows/README.md | 16 +++-- windows/build-dependencies.py | 12 ++-- windows/cairo-tls-probe.c | 96 ++++++++++++++++++++++++++ windows/dependencies.json | 3 +- windows/glib-tls-probe.c | 8 +-- windows/pe_tls.py | 59 ++++++++++------ windows/qualify-glib-tls.py | 79 ++++++++++++--------- windows/run-glib-tls-qualification.ps1 | 19 +++-- 13 files changed, 291 insertions(+), 117 deletions(-) create mode 100644 windows/cairo-tls-probe.c diff --git a/AGENTS.md b/AGENTS.md index bd8e8d4..6490e6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,8 +63,8 @@ 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 qualification requires actual PE TLS callback registration and - the hash-bound native callback/destructor/unload probe. Keep original and +- 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; diff --git a/tools/run-classic-native-tests.ps1 b/tools/run-classic-native-tests.ps1 index 6ec44f5..94e29f1 100644 --- a/tools/run-classic-native-tests.ps1 +++ b/tools/run-classic-native-tests.ps1 @@ -32,44 +32,50 @@ function Invoke-QualifiedNativeTest { } } -# Check the same DLL whose private callback/flag RVAs are compiled into the probe. -$glib = Get-Content -Raw (Join-Path $bundle 'glib-tls.json') | ConvertFrom-Json -$glibHash = (Get-FileHash -Algorithm SHA256 (Join-Path $bundle 'libglib-2.0-0.dll')).Hash.ToLowerInvariant() -if ($glib.schema_version -ne 1 -or $glib.dll -ne 'libglib-2.0-0.dll' -or $glibHash -ne $glib.sha256) { - throw 'GLib TLS probe does not match the staged DLL' -} -$probeHash = (Get-FileHash -Algorithm SHA256 (Join-Path $bundle 'atrinik-glib-tls-probe.exe')).Hash.ToLowerInvariant() -if ($probeHash -ne $glib.probe_sha256) { throw 'GLib TLS probe executable differs from its producer receipt' } -$glibStart = [Diagnostics.ProcessStartInfo]::new() -$glibStart.FileName = Join-Path $bundle 'atrinik-glib-tls-probe.exe' -$glibStart.WorkingDirectory = $bundle -$glibStart.UseShellExecute = $false -$glibStart.RedirectStandardOutput = $true -$glibStart.RedirectStandardError = $true -$glibStart.Environment['G_DEBUG'] = 'fatal-criticals' -$glibProcess = [Diagnostics.Process]::Start($glibStart) -try { - Write-Host "GLib TLS qualification pid=$($glibProcess.Id) dll_sha256=$glibHash" - $stdout = $glibProcess.StandardOutput.ReadToEndAsync() - $stderr = $glibProcess.StandardError.ReadToEndAsync() - $timedOut = -not $glibProcess.WaitForExit(15000) - if ($timedOut) { - $glibProcess.Kill($true) - if (-not $glibProcess.WaitForExit(5000)) { throw 'GLib TLS probe did not stop after timeout' } +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' } - $out = $stdout.GetAwaiter().GetResult() - $err = $stderr.GetAwaiter().GetResult() - [IO.File]::WriteAllText((Join-Path $bundle 'glib-tls.stdout.log'), $out) - [IO.File]::WriteAllText((Join-Path $bundle 'glib-tls.stderr.log'), $err) - Write-Host $out - if ($err) { Write-Host $err } - if ($timedOut) { throw 'GLib TLS probe exceeded 15000 ms' } - if ($glibProcess.ExitCode -ne 0) { throw "GLib TLS probe failed: $($glibProcess.ExitCode)" } - if ($out -notmatch 'process_attach_invoked=1 private_destructors=1 explicit_unload=1' -or $err) { - throw 'GLib TLS callback/destructor/unload evidence is incomplete or emitted diagnostics' + $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() } -} finally { - $glibProcess.Dispose() + } foreach ($test in $tests) { diff --git a/tools/smoke-classic-check.sh b/tools/smoke-classic-check.sh index bb25464..04c4975 100755 --- a/tools/smoke-classic-check.sh +++ b/tools/smoke-classic-check.sh @@ -172,8 +172,11 @@ docker run --rm --user "$(id -u):$(id -g)" --network none \ "${native_sources[@]}" \ "${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}/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 index d3f022e..93e010d 100644 --- a/tools/tests/test_pe_tls.py +++ b/tools/tests/test_pe_tls.py @@ -40,7 +40,18 @@ def test_resolves_callback_and_source_flag(self): data, symbols = fixture() result = TLS.inspect_glib(data, symbols) self.assertEqual(result['callback_rvas'], [0x1010]) - self.assertEqual(result['glib_invoked_flag_rva'], 0x2090) + self.assertEqual(result['initialized_flag_rva'], 0x2090) + + def test_resolves_cairo_callback_and_pre_api_initialization_flag(self): + data, symbols = fixture() + symbols = symbols.replace('glib_priv_tls_callback', 'cairo_win32_tls_callback').replace( + 'tls_callback_invoked', '_cairo_mutex_initialized') + result = TLS.inspect_callback(data, symbols, 'cairo') + self.assertEqual(result['dll'], 'libcairo-2.dll') + self.assertEqual(result['initialized_value'], 2) + self.assertEqual(result['callback_rva'], 0x1010) + with self.assertRaisesRegex(ValueError, 'missing'): + TLS.inspect_callback(data, symbols.replace('_cairo_mutex_initialized', 'unrelated'), 'cairo') def test_rejects_retained_callback_without_tls_directory(self): data, symbols = fixture() @@ -76,7 +87,7 @@ def test_rejects_unterminated_array(self): def test_rejects_missing_and_duplicate_symbols(self): data, symbols = fixture() for bad in ('', symbols + symbols): - with self.subTest(symbols=bad), self.assertRaisesRegex(ValueError, 'GLib TLS'): + with self.subTest(symbols=bad), self.assertRaisesRegex(ValueError, 'library TLS'): TLS.inspect_glib(data, bad) def test_rejects_truncated_and_overlapping_sections(self): diff --git a/windows/Dockerfile b/windows/Dockerfile index a62f83d..cd735c7 100644 --- a/windows/Dockerfile +++ b/windows/Dockerfile @@ -80,7 +80,7 @@ RUN python3 /tmp/windows-producer/build-dependencies.py \ --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 /tmp/windows-producer/ +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 \ @@ -93,6 +93,18 @@ 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 \ --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 bb4f28c..7efa2a9 100644 --- a/windows/README.md +++ b/windows/README.md @@ -22,10 +22,10 @@ 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 GNU TLS macro retains a callback pointer but does not force the CRT TLS -registration directory into an LLVM/MinGW link. The target GLib recipe requires -`_tls_used`; the host recipe and GLib features remain unchanged. `pe_tls.py` -checks the actual PE TLS directory and callback array against GLib's retained +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 @@ -40,3 +40,11 @@ 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. 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 5fe79e2..a072b22 100644 --- a/windows/build-dependencies.py +++ b/windows/build-dependencies.py @@ -897,14 +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" in sources: - # The CRT TLS directory must register GLib's actual retained callback. - # A .CRT callback pointer or a successful link alone cannot prove this. + 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) - tls = module.inspect_glib_file(prefix / "bin/libglib-2.0-0.dll", llvm) - reports["dlls"]["libglib-2.0-0.dll"]["tls"] = tls + 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 9db5e72..e123665 100644 --- a/windows/dependencies.json +++ b/windows/dependencies.json @@ -1027,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 index a863763..dc3e1ca 100644 --- a/windows/glib-tls-probe.c +++ b/windows/glib-tls-probe.c @@ -35,7 +35,7 @@ int main(void) 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) || - GLIB_TLS_CALLBACK_RVA >= size || GLIB_TLS_INVOKED_FLAG_RVA > size - sizeof(gboolean)) return 2; + 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; @@ -45,9 +45,9 @@ int main(void) 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 + GLIB_TLS_CALLBACK_RVA) registered = TRUE; + if (callback == start + TLS_CALLBACK_RVA) registered = TRUE; } - const gboolean invoked = *(const volatile gboolean *)(base + GLIB_TLS_INVOKED_FLAG_RVA); + 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; @@ -65,6 +65,6 @@ int main(void) 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(), GLIB_TLS_CALLBACK_RVA); + (unsigned long)GetCurrentProcessId(), TLS_CALLBACK_RVA); return 0; } diff --git a/windows/pe_tls.py b/windows/pe_tls.py index b869789..dbf5bc0 100644 --- a/windows/pe_tls.py +++ b/windows/pe_tls.py @@ -87,67 +87,84 @@ def callbacks(self) -> list[int]: raise ValueError('unterminated PE TLS callback table') -def inspect_glib(data: bytes, symbols: str) -> dict: +LIBRARIES = { + 'glib': ('libglib-2.0-0.dll', 'glib_priv_tls_callback', 'tls_callback_invoked', 1), + 'cairo': ('libcairo-2.dll', 'cairo_win32_tls_callback', '_cairo_mutex_initialized', 2), +} + + +def inspect_callback(data: bytes, symbols: str, library: str) -> dict: + dll, callback_symbol, flag_symbol, initialized_value = LIBRARIES[library] pe = PE(data) callbacks = pe.callbacks() - wanted = {'glib_priv_tls_callback', 'tls_callback_invoked', '_tls_used', '_ptr_glib_priv_tls_callback'} + 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 GLib TLS symbol') + raise ValueError('invalid or duplicate library TLS symbol') found[fields[0]] = int(fields[2], 16) - pe.base if set(found) != wanted: - raise ValueError('missing GLib TLS callback/flag symbols') + 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['glib_priv_tls_callback'] - flag = found['tls_callback_invoked'] - pointer, = struct.unpack(' dict: +def inspect_file(path: Path, llvm: Path, library: str) -> dict: if path.is_symlink() or not path.is_file() or path.stat().st_size > 256 * 1024 * 1024: - raise ValueError('GLib DLL must be a bounded normal file') + 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_glib(data, symbols) + report = inspect_callback(data, symbols, library) if path.read_bytes() != data: - raise ValueError('GLib DLL changed during TLS inspection') + 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_glib_file(args.dll, args.llvm) + 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 GLib DLL. */\n' - f'#define GLIB_TLS_CALLBACK_RVA {report["glib_callback_rva"]}u\n' - f'#define GLIB_TLS_INVOKED_FLAG_RVA {report["glib_invoked_flag_rva"]}u\n') + 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__': diff --git a/windows/qualify-glib-tls.py b/windows/qualify-glib-tls.py index 2bc5129..043b5af 100644 --- a/windows/qualify-glib-tls.py +++ b/windows/qualify-glib-tls.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # Copyright 2026 The Atrinik Project -"""Build a focused GLib TLS native qualification bundle in an offline LLVM SDK. +"""Build a focused library TLS native qualification bundle in an offline LLVM SDK. -This is diagnostic acceptance of GLib, not qualification of a rebuilt 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 @@ -15,7 +15,7 @@ import shutil import subprocess -from pe_tls import inspect_glib_file +from pe_tls import LIBRARIES, inspect_file ROOT = Path(__file__).resolve().parent SPEC = importlib.util.spec_from_file_location('producer', ROOT / 'build-dependencies.py') @@ -27,31 +27,37 @@ 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']} 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/libglib-2.0-0.dll' - if ownership['dlls'][original.name]['source_sha256'] != items['glib']['sha256']: - raise ValueError('SDK GLib source does not match qualification source') + 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 ('glib', 'meson', 'mxe-recipes'): + for name in (library, 'meson', 'mxe-recipes'): item = items[name] - archive = args.source_archive if name == 'glib' and args.source_archive else inventory / item['archive'] + 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['glib'] - for patch in items['glib']['patches']: + source = sources[library] + for patch in items[library]['patches']: content = (sources['mxe-recipes'] / patch['file']).read_bytes() import hashlib if hashlib.sha256(content).hexdigest() != patch['sha256']: - raise ValueError('GLib upstream recipe patch mismatch') + 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( @@ -61,10 +67,15 @@ def run(args): "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/glib' + build = args.work_dir / 'build' / library build.parent.mkdir() - commands = BUILDER.commands(items['glib'], source, build, args.prefix, 2, env=env) + 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) @@ -72,17 +83,17 @@ def command(name, argv): 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', 'glib/libglib-2.0-0.dll']) + command('baseline', ['ninja', '-C', str(build), '-j2', target]) baseline = args.work_dir / 'baseline.dll' - shutil.copy2(build / 'glib/libglib-2.0-0.dll', baseline) - fixed_flags = items['glib']['environment']['LDFLAGS'].replace('{prefix}', str(args.prefix)).split() + 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 GLib recipe lacks mandatory TLS directory reference') - fixed_flags += ['-Wl,-Map,' + str(logs / 'glib-link.map')] + 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', 'glib/libglib-2.0-0.dll']) - fixed = build / 'glib/libglib-2.0-0.dll' + 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)], @@ -90,30 +101,31 @@ def command(name, argv): (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('GLib linker correction changed imported/exported interfaces') - tls = inspect_glib_file(fixed, args.llvm) - (logs / 'glib-tls.json').write_text(json.dumps(tls, indent=2) + '\n') + 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 / 'glib-link.map').read_text() + 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_glib_file(dll, args.llvm) + 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 / 'glib-tls-rvas.h' - header.write_text(f'#define GLIB_TLS_CALLBACK_RVA {tls["glib_callback_rva"]}u\n' - f'#define GLIB_TLS_INVOKED_FLAG_RVA {tls["glib_invoked_flag_rva"]}u\n') + 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'], + 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 / 'atrinik-glib-tls-probe.exe' + probe = args.work_dir / probe_name command('probe-build', [compiler, '-std=c17', '-O2', '-Wall', '-Wextra', '-Werror', - '-I' + str(args.work_dir), str(ROOT / 'glib-tls-probe.c'), *cflags, '-o', str(probe)]) + '-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)], @@ -136,8 +148,8 @@ def imports(path): copied.add(needed) pending.extend(imports(runtime[needed])) shutil.copy2(ROOT / 'run-glib-tls-qualification.ps1', args.output) - metadata = {'schema_version': 1, 'scope': 'focused GLib TLS diagnostic, not complete SDK qualification', - 'glib_source_sha256': items['glib']['sha256'], 'tls': tls, + metadata = {'schema_version': 1, 'scope': 'focused library TLS diagnostic, not complete SDK qualification', 'library': library, + 'source_sha256': items[library]['sha256'], 'tls': tls, '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()}} @@ -149,6 +161,7 @@ def imports(path): 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')) diff --git a/windows/run-glib-tls-qualification.ps1 b/windows/run-glib-tls-qualification.ps1 index df136e3..1e1b082 100644 --- a/windows/run-glib-tls-qualification.ps1 +++ b/windows/run-glib-tls-qualification.ps1 @@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop' $bundle = $PSScriptRoot $metadata = Get-Content -Raw (Join-Path $bundle 'qualification.json') | ConvertFrom-Json -if ($metadata.schema_version -ne 1 -or $metadata.tls.dll -ne 'libglib-2.0-0.dll') { +if ($metadata.schema_version -ne 1 -or $metadata.library -notin @('glib', 'cairo')) { throw 'Unsupported GLib qualification bundle' } foreach ($file in $metadata.files.PSObject.Properties) { @@ -13,16 +13,20 @@ foreach ($file in $metadata.files.PSObject.Properties) { $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' } $results = @() foreach ($lane in @('original', 'relinked', 'candidate')) { $directory = Join-Path $bundle $lane - foreach ($name in @('atrinik-glib-tls-probe.exe', 'libglib-2.0-0.dll')) { + 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 'libglib-2.0-0.dll')).Hash.ToLowerInvariant() + $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' } $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = Join-Path $directory 'atrinik-glib-tls-probe.exe' + $start.FileName = Join-Path $directory $probeName $start.WorkingDirectory = $directory $start.UseShellExecute = $false $start.RedirectStandardOutput = $true @@ -47,10 +51,11 @@ foreach ($lane in @('original', 'relinked', 'candidate')) { exit_code = $process.ExitCode; timed_out = $timedOut; passed = $false } if (-not $timedOut) { if ($lane -eq 'candidate') { - $result.passed = $process.ExitCode -eq 0 -and -not $err -and - $out -match 'process_attach_invoked=1 private_destructors=1 explicit_unload=1' + $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 { - $result.passed = $process.ExitCode -ne 0 -and $err -match 'TLS callback not invoked' + $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 From aa90f077a16b855c663230f7e44928420aa182f1 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 07:27:49 -0500 Subject: [PATCH 3/7] fix(windows): isolate Cairo qualification with repaired GLib --- tools/tests/test_tls_qualification.py | 96 ++++++++++++++++++++++++++ windows/README.md | 7 +- windows/qualify-glib-tls.py | 76 +++++++++++++++++++- windows/run-glib-tls-qualification.ps1 | 15 ++++ 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 tools/tests/test_tls_qualification.py diff --git a/tools/tests/test_tls_qualification.py b/tools/tests/test_tls_qualification.py new file mode 100644 index 0000000..df3ef3a --- /dev/null +++ b/tools/tests/test_tls_qualification.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026 The Atrinik Project +import hashlib +import importlib.util +import json +from pathlib import Path +import struct +import sys +import tempfile +import unittest +from unittest.mock import patch + +from test_pe_tls import fixture, TLS + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'windows')) +try: + SPEC = importlib.util.spec_from_file_location('tls_qualification', ROOT / 'windows/qualify-glib-tls.py') + QUALIFY = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(QUALIFY) +finally: + sys.path.pop(0) + + +class RepairedDependencyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bundle = self.root / 'bundle' + self.candidate = self.bundle / 'candidate/libglib-2.0-0.dll' + self.candidate.parent.mkdir(parents=True) + (self.bundle / 'build-evidence').mkdir() + data, self.symbols = fixture() + self.candidate.write_bytes(data) + (self.candidate.parent / 'atrinik-glib-tls-probe.exe').write_bytes(b'probe fixture') + self.tls = TLS.inspect_callback(data, self.symbols, 'glib') + (self.bundle / 'build-evidence/glib-tls.json').write_text(json.dumps(self.tls)) + lock = QUALIFY.BUILDER.load_lock(ROOT / 'windows/dependencies.json') + self.items = {item['name']: item for item in lock['dependencies']} + self.metadata = {'schema_version': 1, 'library': 'glib', 'source_sha256': self.items['glib']['sha256'], + 'provenance': QUALIFY.source_provenance(self.items, 'glib'), 'tls': self.tls} + self.seal() + self.inspect = patch.object(QUALIFY, 'inspect_file', side_effect=lambda path, llvm, library: + TLS.inspect_callback(path.read_bytes(), self.symbols, library)) + self.inspect.start() + self.addCleanup(self.inspect.stop) + + def seal(self): + self.metadata['files'] = {p.relative_to(self.bundle).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in self.bundle.rglob('*') if p.is_file() and p.name != 'qualification.json'} + (self.bundle / 'qualification.json').write_text(json.dumps(self.metadata)) + + def validate(self): + return QUALIFY.validated_glib_dependency(self.bundle, self.items, Path('/llvm'), self.root / 'sdk') + + def test_accepts_sealed_registered_dependency(self): + path, record = self.validate() + self.assertEqual(path, self.candidate) + self.assertEqual(record['sha256'], self.tls['sha256']) + + def test_rejects_missing_or_wrong_source_bundle(self): + with self.assertRaisesRegex(ValueError, 'requires --glib-bundle'): + QUALIFY.validated_glib_dependency(None, self.items, Path('/llvm'), self.root) + self.metadata['source_sha256'] = '0' * 64 + self.seal() + with self.assertRaisesRegex(ValueError, 'source/provenance'): + self.validate() + + def test_rejects_tampered_dependency_bytes(self): + self.candidate.write_bytes(self.candidate.read_bytes() + b'changed') + with self.assertRaisesRegex(ValueError, 'changed GLib dependency evidence'): + self.validate() + + def test_rejects_resealed_missing_registration(self): + data = bytearray(self.candidate.read_bytes()) + struct.pack_into(' Date: Sun, 11 Oct 2026 07:05:03 -0500 Subject: [PATCH 4/7] ci(windows): qualify scoped GLib TLS repair natively --- .../workflows/windows-glib-qualification.yml | 186 ++++++++++++++++++ tools/tests/test_windows_glib_workflow.py | 109 ++++++++++ 2 files changed, 295 insertions(+) create mode 100644 .github/workflows/windows-glib-qualification.yml create mode 100644 tools/tests/test_windows_glib_workflow.py diff --git a/.github/workflows/windows-glib-qualification.yml b/.github/workflows/windows-glib-qualification.yml new file mode 100644 index 0000000..7ca0307 --- /dev/null +++ b/.github/workflows/windows-glib-qualification.yml @@ -0,0 +1,186 @@ +# Copyright (c) 2026 Atrinik contributors +# SPDX-License-Identifier: MIT +name: Qualify Windows GLib TLS repair + +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 TLS qualification bundle + 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 GLib 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'] + package, = [item for item in records if item['name'] == 'glib'] + expected = { + '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', + } + if any(package.get(key) != value for key, value in expected.items()): + raise SystemExit('GLib source differs from the scoped qualification input') + Path('build/glib-qualification/provenance/glib-source.json').write_text( + json.dumps(expected, indent=2) + '\n') + PY + curl --fail --location --proto '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 120 \ + https://download.gnome.org/sources/glib/2.89/glib-2.89.3.tar.xz \ + -o build/glib-qualification/downloads/glib-2.89.3.tar.xz + printf '%s %s\n' \ + 09fd1e99f991067749ad66090e482ec4bd6514ad53abb4e9fbbdc2a4d2753532 \ + build/glib-qualification/downloads/glib-2.89.3.tar.xz | sha256sum --check --strict + 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}" \ + python3 windows/qualify-glib-tls.py \ + --source-archive /tmp/qualification/downloads/glib-2.89.3.tar.xz \ + --work-dir /tmp/qualification/work --output /tmp/qualification/bundle) + 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 + cp -R build/glib-qualification/provenance build/glib-native-bundle/provenance + cp windows/qualify-glib-tls.py windows/run-glib-tls-qualification.ps1 \ + build/glib-native-bundle/provenance/ + cp build/glib-qualification/downloads/glib-2.89.3.tar.xz \ + build/glib-native-bundle/provenance/ + # 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 lane in ('candidate', 'original', 'relinked'): + if not (root / lane).is_dir(): + raise SystemExit(f'Missing qualification lane: {lane}') + for name in ('qualification.json', 'run-glib-tls-qualification.ps1'): + if not (root / name).is_file(): + raise SystemExit(f'Missing qualification artifact: {name}') + PY + + - name: Upload bounded native GLib bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: windows-glib-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-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 TLS repair and negative controls on Windows + needs: cross-build + permissions: {} + runs-on: windows-2025 + timeout-minutes: 5 + steps: + - name: Download scoped native GLib bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: windows-glib-tls-native-bundle + path: build/glib-native-bundle + + - name: Require native TLS lifecycle and negative controls + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + & pwsh -NoLogo -NoProfile -NonInteractive -File (Join-Path (Resolve-Path 'build/glib-native-bundle').Path 'run-glib-tls-qualification.ps1') + if ($LASTEXITCODE -ne 0) { throw "Native GLib qualification failed: $LASTEXITCODE" } + + - name: Retain native GLib 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/tools/tests/test_windows_glib_workflow.py b/tools/tests/test_windows_glib_workflow.py new file mode 100644 index 0000000..25c8154 --- /dev/null +++ b/tools/tests/test_windows_glib_workflow.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026 Atrinik contributors +# SPDX-License-Identifier: MIT +"""Check scoped qualification fences without building or executing Windows code.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / '.github/workflows/windows-glib-qualification.yml' + + +def script(step): + block = WORKFLOW.read_text().split(f' - name: {step}\n', 1)[1] + block = block.split('\n - name:', 1)[0] + body = block.split(' run: |\n', 1)[1] + return '\n'.join(line[10:] for line in body.splitlines()) + '\n' + + +class ScopedWindowsGLibWorkflow(unittest.TestCase): + def test_changed_source_digest_fails_before_network(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'windows').mkdir() + manifest = json.loads((ROOT / 'windows/dependencies.json').read_text()) + for package in manifest['dependencies']: + if package['name'] == 'glib': + package['sha256'] = '0' * 64 + (root / 'windows/dependencies.json').write_text(json.dumps(manifest)) + git = root / 'git' + git.write_text('#!/bin/sh\ntar -cf - --files-from /dev/null\n') + git.chmod(0o755) + curl = root / 'curl' + curl.write_text('#!/bin/sh\ntouch curl-was-called\nexit 99\n') + curl.chmod(0o755) + result = subprocess.run( + ['bash', '-euo', 'pipefail', '-c', script('Stage and verify immutable GLib source')], + cwd=root, env=dict(os.environ, PATH=f'{root}:' + os.environ['PATH']), + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('GLib source differs', result.stderr) + self.assertFalse((root / 'curl-was-called').exists()) + + def test_worker_failure_stops_bundle_and_always_cleans_owned_container(self): + for build_status in (0, 19): + with self.subTest(status=build_status), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'windows').mkdir() + for name in ('qualify-glib-tls.py', 'run-glib-tls-qualification.ps1'): + (root / 'windows' / name).write_text('# source fixture') + (root / 'build/glib-qualification/provenance').mkdir(parents=True) + (root / 'build/glib-qualification/downloads').mkdir() + (root / 'build/glib-qualification/downloads/glib-2.89.3.tar.xz').write_bytes(b'fixture') + docker = root / 'docker' + docker.write_text('''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +args = sys.argv[1:] +with Path('docker-calls.jsonl').open('a') as handle: + handle.write(json.dumps(args) + '\\n') +if args[0] == 'create': + print('fixture-owned-container') +elif args[0] == 'inspect': + print(os.environ['BUILD_STATUS']) +elif args[0] == 'cp' and args[1].endswith(':/tmp/qualification/bundle'): + root = Path(args[2]) + for lane in ('original', 'relinked', 'candidate'): + (root / lane).mkdir(parents=True) + (root / 'qualification.json').write_text('{}') + (root / 'run-glib-tls-qualification.ps1').write_text('# fixture') +''') + docker.chmod(0o755) + result = subprocess.run( + ['bash', '-euo', 'pipefail', '-c', script('Cross-build in an isolated LLVM SDK worker')], + cwd=root, env=dict(os.environ, PATH=f'{root}:' + os.environ['PATH'], + SDK_IMAGE='fixture@sha256:' + '1' * 64, + BUILD_STATUS=str(build_status)), capture_output=True, text=True) + self.assertEqual(result.returncode, build_status, result.stderr) + calls = [json.loads(line) for line in (root / 'docker-calls.jsonl').read_text().splitlines()] + create, = [call for call in calls if call[0] == 'create'] + for flag, value in (('--network', 'none'), ('--user', '1000:1000'), + ('--cpus', '2'), ('--memory', '8g'), ('--memory-swap', '8g'), + ('--pids-limit', '512'), ('--cap-drop', 'ALL'), + ('--security-opt', 'no-new-privileges'), ('--ulimit', 'nofile=4096:4096')): + self.assertEqual(create[create.index(flag) + 1], value) + self.assertNotIn('--volume', create) + self.assertNotIn('--mount', create) + self.assertIn('--source-archive', create) + self.assertEqual(calls[-1], ['rm', '--force', 'fixture-owned-container']) + self.assertEqual((root / 'build/glib-native-bundle').exists(), build_status == 0) + + 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('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() From 2a2b3f16c2a31185b766f5651fc5b7430c3a02ac Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 07:20:18 -0500 Subject: [PATCH 5/7] ci(windows): require native Cairo TLS qualification alongside GLib --- .../workflows/windows-glib-qualification.yml | 96 +++++++++++++------ tools/tests/test_windows_glib_workflow.py | 63 +++++++----- 2 files changed, 103 insertions(+), 56 deletions(-) diff --git a/.github/workflows/windows-glib-qualification.yml b/.github/workflows/windows-glib-qualification.yml index 7ca0307..a3d465d 100644 --- a/.github/workflows/windows-glib-qualification.yml +++ b/.github/workflows/windows-glib-qualification.yml @@ -1,6 +1,6 @@ # Copyright (c) 2026 Atrinik contributors # SPDX-License-Identifier: MIT -name: Qualify Windows GLib TLS repair +name: Qualify Windows GLib and Cairo TLS repairs on: pull_request: @@ -22,10 +22,15 @@ concurrency: # remains owned by validate.yml and publish-windows.yml. jobs: cross-build: - name: Build scoped GLib TLS qualification bundle + name: Build scoped ${{ matrix.library }} TLS qualification bundle + strategy: + fail-fast: false + matrix: + library: [glib, cairo] runs-on: ubuntu-26.04 timeout-minutes: 10 env: + LIBRARY: ${{ matrix.library }} SDK_IMAGE: ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 steps: - name: Check out qualification sources @@ -33,7 +38,7 @@ jobs: with: persist-credentials: false - - name: Stage and verify immutable GLib source + - name: Stage and verify immutable library source shell: bash run: | set -euo pipefail @@ -43,27 +48,39 @@ jobs: # has neither registry credentials nor network access. python3 - <<'PY' import json + import os from pathlib import Path records = json.loads(Path('windows/dependencies.json').read_text())['dependencies'] - package, = [item for item in records if item['name'] == 'glib'] - expected = { - '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', + library = os.environ['LIBRARY'] + package, = [item for item in records if item['name'] == library] + 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', + }, } + expected = inputs[library] if any(package.get(key) != value for key, value in expected.items()): - raise SystemExit('GLib source differs from the scoped qualification input') - Path('build/glib-qualification/provenance/glib-source.json').write_text( - json.dumps(expected, indent=2) + '\n') + raise SystemExit('Library source differs from the scoped qualification input') + Path('build/glib-qualification/provenance/library-source.json').write_text( + json.dumps({'library': library, **expected}, indent=2) + '\n') + Path('build/glib-qualification/downloads/source-url.txt').write_text(expected['url']) + Path('build/glib-qualification/downloads/source.sha256').write_text( + expected['sha256'] + ' build/glib-qualification/downloads/source.tar.xz\n') PY curl --fail --location --proto '=https' --tlsv1.2 \ --connect-timeout 15 --max-time 120 \ - https://download.gnome.org/sources/glib/2.89/glib-2.89.3.tar.xz \ - -o build/glib-qualification/downloads/glib-2.89.3.tar.xz - printf '%s %s\n' \ - 09fd1e99f991067749ad66090e482ec4bd6514ad53abb4e9fbbdc2a4d2753532 \ - build/glib-qualification/downloads/glib-2.89.3.tar.xz | sha256sum --check --strict + "$(cat build/glib-qualification/downloads/source-url.txt)" \ + -o build/glib-qualification/downloads/source.tar.xz + sha256sum --check --strict build/glib-qualification/downloads/source.sha256 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 @@ -91,8 +108,8 @@ jobs: --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}" \ - python3 windows/qualify-glib-tls.py \ - --source-archive /tmp/qualification/downloads/glib-2.89.3.tar.xz \ + python3 windows/qualify-glib-tls.py --library "${LIBRARY}" \ + --source-archive /tmp/qualification/downloads/source.tar.xz \ --work-dir /tmp/qualification/work --output /tmp/qualification/bundle) cleanup() { docker rm --force "${worker}" >/dev/null; } trap cleanup EXIT @@ -113,7 +130,7 @@ jobs: cp -R build/glib-qualification/provenance build/glib-native-bundle/provenance cp windows/qualify-glib-tls.py windows/run-glib-tls-qualification.ps1 \ build/glib-native-bundle/provenance/ - cp build/glib-qualification/downloads/glib-2.89.3.tar.xz \ + cp build/glib-qualification/downloads/source.tar.xz \ build/glib-native-bundle/provenance/ # Bound artifact size and reject links before uploading executable input. python3 - <<'PY' @@ -132,10 +149,10 @@ jobs: raise SystemExit(f'Missing qualification artifact: {name}') PY - - name: Upload bounded native GLib bundle + - name: Upload bounded native library bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: windows-glib-tls-native-bundle + name: windows-${{ matrix.library }}-tls-native-bundle path: build/glib-native-bundle if-no-files-found: error retention-days: 3 @@ -144,7 +161,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: windows-glib-tls-cross-build-evidence + name: windows-${{ matrix.library }}-tls-cross-build-evidence path: | build/glib-cross-build.log build/glib-qualification/provenance @@ -152,7 +169,7 @@ jobs: retention-days: 3 native-windows: - name: Verify GLib TLS repair and negative controls on Windows + name: Verify GLib and Cairo TLS repairs and negative controls on Windows needs: cross-build permissions: {} runs-on: windows-2025 @@ -162,23 +179,42 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-glib-tls-native-bundle - path: build/glib-native-bundle + path: build/glib-native-bundle/glib + + - name: Download scoped native Cairo bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: windows-cairo-tls-native-bundle + path: build/glib-native-bundle/cairo - name: Require native TLS lifecycle and negative controls shell: pwsh run: | $ErrorActionPreference = 'Stop' - & pwsh -NoLogo -NoProfile -NonInteractive -File (Join-Path (Resolve-Path 'build/glib-native-bundle').Path 'run-glib-tls-qualification.ps1') - if ($LASTEXITCODE -ne 0) { throw "Native GLib qualification failed: $LASTEXITCODE" } + 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 GLib qualification evidence + - 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/*/qualification.json + build/glib-native-bundle/*/provenance build/glib-native-bundle/**/*.json build/glib-native-bundle/**/*.stdout.txt build/glib-native-bundle/**/*.stderr.txt diff --git a/tools/tests/test_windows_glib_workflow.py b/tools/tests/test_windows_glib_workflow.py index 25c8154..010a04c 100644 --- a/tools/tests/test_windows_glib_workflow.py +++ b/tools/tests/test_windows_glib_workflow.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 Atrinik contributors # SPDX-License-Identifier: MIT """Check scoped qualification fences without building or executing Windows code.""" +import itertools import json import os from pathlib import Path @@ -21,38 +22,39 @@ def script(step): class ScopedWindowsGLibWorkflow(unittest.TestCase): def test_changed_source_digest_fails_before_network(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / 'windows').mkdir() - manifest = json.loads((ROOT / 'windows/dependencies.json').read_text()) - for package in manifest['dependencies']: - if package['name'] == 'glib': - package['sha256'] = '0' * 64 - (root / 'windows/dependencies.json').write_text(json.dumps(manifest)) - git = root / 'git' - git.write_text('#!/bin/sh\ntar -cf - --files-from /dev/null\n') - git.chmod(0o755) - curl = root / 'curl' - curl.write_text('#!/bin/sh\ntouch curl-was-called\nexit 99\n') - curl.chmod(0o755) - result = subprocess.run( - ['bash', '-euo', 'pipefail', '-c', script('Stage and verify immutable GLib source')], - cwd=root, env=dict(os.environ, PATH=f'{root}:' + os.environ['PATH']), - capture_output=True, text=True) - self.assertNotEqual(result.returncode, 0) - self.assertIn('GLib source differs', result.stderr) - self.assertFalse((root / 'curl-was-called').exists()) - + for library, field in itertools.product(('glib', 'cairo'), ('sha256', 'url')): + with self.subTest(library=library, field=field): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'windows').mkdir() + manifest = json.loads((ROOT / 'windows/dependencies.json').read_text()) + for package in manifest['dependencies']: + if package['name'] == library: + package[field] = 'invalid' + (root / 'windows/dependencies.json').write_text(json.dumps(manifest)) + git = root / 'git' + git.write_text('#!/bin/sh\ntar -cf - --files-from /dev/null\n') + git.chmod(0o755) + curl = root / 'curl' + curl.write_text('#!/bin/sh\ntouch curl-was-called\nexit 99\n') + curl.chmod(0o755) + result = subprocess.run( + ['bash', '-euo', 'pipefail', '-c', script('Stage and verify immutable library source')], + cwd=root, env=dict(os.environ, PATH=f'{root}:' + os.environ['PATH'], LIBRARY=library), + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Library source differs', result.stderr) + self.assertFalse((root / 'curl-was-called').exists()) def test_worker_failure_stops_bundle_and_always_cleans_owned_container(self): - for build_status in (0, 19): - with self.subTest(status=build_status), tempfile.TemporaryDirectory() as directory: + for library, build_status in itertools.product(('glib', 'cairo'), (0, 19)): + with self.subTest(library=library, status=build_status), tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / 'windows').mkdir() for name in ('qualify-glib-tls.py', 'run-glib-tls-qualification.ps1'): (root / 'windows' / name).write_text('# source fixture') (root / 'build/glib-qualification/provenance').mkdir(parents=True) (root / 'build/glib-qualification/downloads').mkdir() - (root / 'build/glib-qualification/downloads/glib-2.89.3.tar.xz').write_bytes(b'fixture') + (root / 'build/glib-qualification/downloads/source.tar.xz').write_bytes(b'fixture') docker = root / 'docker' docker.write_text('''#!/usr/bin/env python3 import json, os, sys @@ -76,7 +78,7 @@ def test_worker_failure_stops_bundle_and_always_cleans_owned_container(self): ['bash', '-euo', 'pipefail', '-c', script('Cross-build in an isolated LLVM SDK worker')], cwd=root, env=dict(os.environ, PATH=f'{root}:' + os.environ['PATH'], SDK_IMAGE='fixture@sha256:' + '1' * 64, - BUILD_STATUS=str(build_status)), capture_output=True, text=True) + BUILD_STATUS=str(build_status), LIBRARY=library), capture_output=True, text=True) self.assertEqual(result.returncode, build_status, result.stderr) calls = [json.loads(line) for line in (root / 'docker-calls.jsonl').read_text().splitlines()] create, = [call for call in calls if call[0] == 'create'] @@ -88,6 +90,7 @@ def test_worker_failure_stops_bundle_and_always_cleans_owned_container(self): self.assertNotIn('--volume', create) self.assertNotIn('--mount', create) self.assertIn('--source-archive', create) + self.assertEqual(create[create.index('--library') + 1], library) self.assertEqual(calls[-1], ['rm', '--force', 'fixture-owned-container']) self.assertEqual((root / 'build/glib-native-bundle').exists(), build_status == 0) @@ -97,6 +100,14 @@ def test_native_job_requires_build_and_strict_native_runner(self): 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('library: [glib, cairo]', text) + for library in ('glib', 'cairo'): + self.assertIn(f'name: windows-{library}-tls-native-bundle', native) + self.assertIn(f'path: build/glib-native-bundle/{library}', native) self.assertIn('if ($LASTEXITCODE -ne 0)', native) self.assertNotIn('continue-on-error', text) self.assertNotIn('packages:', text) From f65b23144712f97ab7fce6ee150d6251d1b2ba90 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 07:28:06 -0500 Subject: [PATCH 6/7] ci(windows): build Cairo controls with the sealed GLib repair --- .../workflows/windows-glib-qualification.yml | 92 +++++++++---------- tools/tests/test_windows_glib_workflow.py | 47 +++++++--- 2 files changed, 79 insertions(+), 60 deletions(-) diff --git a/.github/workflows/windows-glib-qualification.yml b/.github/workflows/windows-glib-qualification.yml index a3d465d..4707e6f 100644 --- a/.github/workflows/windows-glib-qualification.yml +++ b/.github/workflows/windows-glib-qualification.yml @@ -22,15 +22,10 @@ concurrency: # remains owned by validate.yml and publish-windows.yml. jobs: cross-build: - name: Build scoped ${{ matrix.library }} TLS qualification bundle - strategy: - fail-fast: false - matrix: - library: [glib, cairo] + name: Build scoped GLib and Cairo TLS qualification bundles runs-on: ubuntu-26.04 timeout-minutes: 10 env: - LIBRARY: ${{ matrix.library }} SDK_IMAGE: ghcr.io/atrinik/windows-build:candidate-sha-1d82af6036b831d97cc470e2230357047e4c78f8@sha256:914acf5c3c05546ca4fb45e47362d754ec717a9062f6ebcc1b81b3ab7d0ad325 steps: - name: Check out qualification sources @@ -48,11 +43,8 @@ jobs: # has neither registry credentials nor network access. python3 - <<'PY' import json - import os from pathlib import Path records = json.loads(Path('windows/dependencies.json').read_text())['dependencies'] - library = os.environ['LIBRARY'] - package, = [item for item in records if item['name'] == library] inputs = { 'glib': { 'version': '2.89.3', @@ -67,20 +59,23 @@ jobs: 'sha256': '445ed8208a6e4823de1226a74ca319d3600e83f6369f99b14265006599c32ccb', }, } - expected = inputs[library] - if any(package.get(key) != value for key, value in expected.items()): - raise SystemExit('Library source differs from the scoped qualification input') - Path('build/glib-qualification/provenance/library-source.json').write_text( - json.dumps({'library': library, **expected}, indent=2) + '\n') - Path('build/glib-qualification/downloads/source-url.txt').write_text(expected['url']) - Path('build/glib-qualification/downloads/source.sha256').write_text( - expected['sha256'] + ' build/glib-qualification/downloads/source.tar.xz\n') + 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 - curl --fail --location --proto '=https' --tlsv1.2 \ - --connect-timeout 15 --max-time 120 \ - "$(cat build/glib-qualification/downloads/source-url.txt)" \ - -o build/glib-qualification/downloads/source.tar.xz - sha256sum --check --strict build/glib-qualification/downloads/source.sha256 + 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 @@ -108,9 +103,15 @@ jobs: --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}" \ - python3 windows/qualify-glib-tls.py --library "${LIBRARY}" \ - --source-archive /tmp/qualification/downloads/source.tar.xz \ - --work-dir /tmp/qualification/work --output /tmp/qualification/bundle) + 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. @@ -127,11 +128,13 @@ jobs: exit "${status}" fi docker cp "${worker}:/tmp/qualification/bundle" build/glib-native-bundle - cp -R build/glib-qualification/provenance build/glib-native-bundle/provenance - cp windows/qualify-glib-tls.py windows/run-glib-tls-qualification.ps1 \ - build/glib-native-bundle/provenance/ - cp build/glib-qualification/downloads/source.tar.xz \ - build/glib-native-bundle/provenance/ + 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 @@ -141,18 +144,19 @@ jobs: 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 lane in ('candidate', 'original', 'relinked'): - if not (root / lane).is_dir(): - raise SystemExit(f'Missing qualification lane: {lane}') - for name in ('qualification.json', 'run-glib-tls-qualification.ps1'): - if not (root / name).is_file(): - raise SystemExit(f'Missing qualification artifact: {name}') + 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-${{ matrix.library }}-tls-native-bundle + name: windows-glib-cairo-tls-native-bundle path: build/glib-native-bundle if-no-files-found: error retention-days: 3 @@ -161,7 +165,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: windows-${{ matrix.library }}-tls-cross-build-evidence + name: windows-glib-cairo-tls-cross-build-evidence path: | build/glib-cross-build.log build/glib-qualification/provenance @@ -175,17 +179,11 @@ jobs: runs-on: windows-2025 timeout-minutes: 5 steps: - - name: Download scoped native GLib bundle + - name: Download scoped native GLib and Cairo bundles uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: windows-glib-tls-native-bundle - path: build/glib-native-bundle/glib - - - name: Download scoped native Cairo bundle - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: windows-cairo-tls-native-bundle - path: build/glib-native-bundle/cairo + name: windows-glib-cairo-tls-native-bundle + path: build/glib-native-bundle - name: Require native TLS lifecycle and negative controls shell: pwsh diff --git a/tools/tests/test_windows_glib_workflow.py b/tools/tests/test_windows_glib_workflow.py index 010a04c..6efce9b 100644 --- a/tools/tests/test_windows_glib_workflow.py +++ b/tools/tests/test_windows_glib_workflow.py @@ -46,15 +46,16 @@ def test_changed_source_digest_fails_before_network(self): self.assertIn('Library source differs', result.stderr) self.assertFalse((root / 'curl-was-called').exists()) def test_worker_failure_stops_bundle_and_always_cleans_owned_container(self): - for library, build_status in itertools.product(('glib', 'cairo'), (0, 19)): - with self.subTest(library=library, status=build_status), tempfile.TemporaryDirectory() as directory: + for build_status in (0, 19): + with self.subTest(status=build_status), tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / 'windows').mkdir() for name in ('qualify-glib-tls.py', 'run-glib-tls-qualification.ps1'): (root / 'windows' / name).write_text('# source fixture') (root / 'build/glib-qualification/provenance').mkdir(parents=True) (root / 'build/glib-qualification/downloads').mkdir() - (root / 'build/glib-qualification/downloads/source.tar.xz').write_bytes(b'fixture') + for library in ('glib', 'cairo'): + (root / f'build/glib-qualification/downloads/{library}.tar.xz').write_bytes(b'fixture') docker = root / 'docker' docker.write_text('''#!/usr/bin/env python3 import json, os, sys @@ -68,10 +69,11 @@ def test_worker_failure_stops_bundle_and_always_cleans_owned_container(self): print(os.environ['BUILD_STATUS']) elif args[0] == 'cp' and args[1].endswith(':/tmp/qualification/bundle'): root = Path(args[2]) - for lane in ('original', 'relinked', 'candidate'): - (root / lane).mkdir(parents=True) - (root / 'qualification.json').write_text('{}') - (root / 'run-glib-tls-qualification.ps1').write_text('# fixture') + for library in ('glib', 'cairo'): + for lane in ('original', 'relinked', 'candidate'): + (root / library / lane).mkdir(parents=True) + (root / library / 'qualification.json').write_text('{}') + (root / library / 'run-glib-tls-qualification.ps1').write_text('# fixture') ''') docker.chmod(0o755) result = subprocess.run( @@ -89,11 +91,32 @@ def test_worker_failure_stops_bundle_and_always_cleans_owned_container(self): self.assertEqual(create[create.index(flag) + 1], value) self.assertNotIn('--volume', create) self.assertNotIn('--mount', create) - self.assertIn('--source-archive', create) - self.assertEqual(create[create.index('--library') + 1], library) + command = create[-1] + self.assertIn('--source-archive /tmp/qualification/downloads/glib.tar.xz', command) + self.assertIn('--source-archive /tmp/qualification/downloads/cairo.tar.xz', command) + self.assertLess(command.index('--library glib'), command.index('--library cairo')) + self.assertIn('--glib-bundle /tmp/qualification/bundle/glib', command) + self.assertIn('sh', create) + self.assertIn('-eu', create) self.assertEqual(calls[-1], ['rm', '--force', 'fixture-owned-container']) self.assertEqual((root / 'build/glib-native-bundle').exists(), build_status == 0) + def test_cairo_is_not_built_after_glib_failure(self): + command = script('Cross-build in an isolated LLVM SDK worker').split("sh -eu -c '", 1)[1].split("\n ')", 1)[0] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + python = root / 'python3' + python.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> 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_native_job_requires_build_and_strict_native_runner(self): text = WORKFLOW.read_text() native = text.split(' native-windows:', 1)[1] @@ -104,10 +127,8 @@ def test_native_job_requires_build_and_strict_native_runner(self): self.assertIn('$metadata.library -ne $library', native) self.assertIn('$results.Count -ne 3', native) self.assertIn("'candidate,original,relinked'", native) - self.assertIn('library: [glib, cairo]', text) - for library in ('glib', 'cairo'): - self.assertIn(f'name: windows-{library}-tls-native-bundle', native) - self.assertIn(f'path: build/glib-native-bundle/{library}', 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) From 8e027151021091fd5eb95ec3e78bc2453586df7c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 07:40:56 -0500 Subject: [PATCH 7/7] ci(windows): require full validation for TLS workflow changes --- .github/workflows/validate.yml | 1 + tools/tests/test_windows_glib_workflow.py | 24 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) 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/tools/tests/test_windows_glib_workflow.py b/tools/tests/test_windows_glib_workflow.py index 6efce9b..9fd247f 100644 --- a/tools/tests/test_windows_glib_workflow.py +++ b/tools/tests/test_windows_glib_workflow.py @@ -13,8 +13,8 @@ WORKFLOW = ROOT / '.github/workflows/windows-glib-qualification.yml' -def script(step): - block = WORKFLOW.read_text().split(f' - name: {step}\n', 1)[1] +def script(step, path=WORKFLOW): + block = path.read_text().split(f' - name: {step}\n', 1)[1] block = block.split('\n - name:', 1)[0] body = block.split(' run: |\n', 1)[1] return '\n'.join(line[10:] for line in body.splitlines()) + '\n' @@ -117,6 +117,26 @@ def test_cairo_is_not_built_after_glib_failure(self): 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]