Files
simplex-chat/scripts/nix/test-windows-packaging.py
T

154 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Exercise the shared packaging shell with real PE imports, without executing PE code."""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import time
import zipfile
ROOT = Path(__file__).resolve().parent
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cc', required=True, help='cached MinGW C compiler')
parser.add_argument('--baseline-flake', type=Path)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
deadline = time.monotonic() + 120
commands = []
def run(argv, *, cwd=None, env=None):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError('Packaging check reached its 120-second deadline')
result = subprocess.run(argv, cwd=cwd, env=env, capture_output=True,
text=True, timeout=min(remaining, 30))
commands.append({'argv': list(map(str, argv)), 'returncode': result.returncode,
'stdout': result.stdout, 'stderr': result.stderr})
(args.output / 'commands.json').write_text(json.dumps(commands, indent=2) + '\n')
if result.returncode:
raise RuntimeError(f'Command failed ({result.returncode}): {argv[0]}: {result.stderr}')
return result.stdout
with tempfile.TemporaryDirectory(prefix='windows-packaging-') as temporary:
fixture = Path(temporary)
tools = fixture / 'tools'
(tools / 'bin').mkdir(parents=True)
for name in ['strings', 'zip']:
target = shutil.which(name)
if not target:
raise RuntimeError(f'Missing cached host tool: {name}')
(tools / 'bin' / name).symlink_to(target)
tree = tools / 'bin' / 'tree'
tree.write_text('#!/bin/sh\nexit 0\n')
tree.chmod(0o755)
providers = {}
for name in ['openssl', 'zlib', 'libffi', 'gmp', 'gcc', 'mcfgthreads']:
providers[name] = str(fixture / name)
(fixture / name).mkdir()
# Integer-only C fixtures establish a direct and a transitive DLL import.
# They implement no cryptography and are never executed.
sources = {
'leaf.c': 'int fixture_leaf(void) { return 17; }\n',
'first.c': 'extern int fixture_leaf(void);\nint fixture_first(void) { return fixture_leaf(); }\n',
'root.c': 'extern int fixture_first(void);\nint fixture_root(void) { return fixture_first(); }\n',
'main.c': 'extern int fixture_first(void);\nint fixture_entry(void) { return fixture_first(); }\n',
}
for name, contents in sources.items():
(fixture / name).write_text(contents)
flags = ['-std=c11', '-Werror=vla', '-pedantic', '-Wall', '-Wextra', '-nostdlib']
leaf = fixture / 'zlib' / 'fixture_leaf.dll'
first = fixture / 'openssl' / 'fixture_first.dll'
run([args.cc, *flags, '-shared', 'leaf.c', '-Wl,--entry,0',
'-Wl,--out-implib,leaf.a', '-o', str(leaf)], cwd=fixture)
run([args.cc, *flags, '-shared', 'first.c', 'leaf.a', '-Wl,--entry,0',
'-Wl,--out-implib,first.a', '-o', str(first)], cwd=fixture)
run([args.cc, *flags, '-shared', 'root.c', 'first.a', '-Wl,--entry,0',
'-Wl,--out-implib,libsimplex.dll.a', '-o', 'libsimplex.dll'], cwd=fixture)
run([args.cc, *flags, 'main.c', 'first.a', '-Wl,--entry,fixture_entry',
'-o', 'simplex-chat.exe'], cwd=fixture)
# A duplicated read-only provider must keep the first collected bytes.
duplicate = fixture / 'gcc' / first.name
shutil.copyfile(first, duplicate)
duplicate.chmod(0o444)
windows = {name: providers[name] for name in ['openssl', 'zlib', 'libffi', 'gmp']}
windows.update({'stdenv': {'cc': {'cc': providers['gcc']}},
'windows': {'mcfgthreads': providers['mcfgthreads']}})
data = {'pkgs': {'binutils': str(tools), 'tree': str(tools), 'zip': str(tools)},
'windowsPkgs': windows}
data_path = fixture / 'data.json'
data_path.write_text(json.dumps(data))
baseline = args.baseline_flake.read_text() if args.baseline_flake else None
flake = (ROOT.parent.parent / 'flake.nix').read_text()
calls = re.findall(r'(?:installPhase = old\.installPhase \+ |postInstall = )import \./scripts/nix/package-windows\.nix (\{.*?\n\s*\});', flake, re.S)
assert len(calls) == 2, 'Expected exactly the two Windows packaging callers'
results = []
for kind in ['cli', 'library']:
entry = 'simplex-chat.exe' if kind == 'cli' else 'libsimplex.dll'
expected = {entry, 'fixture_first.dll', 'fixture_leaf.dll'}
if kind == 'library':
expected.add('libsimplex.dll.a')
expression = fixture / 'render.nix'
arguments = calls[0 if kind == 'cli' else 1]
expression.write_text(f'''let
data = builtins.fromJSON (builtins.readFile {json.dumps(str(data_path))});
pkgs = data.pkgs // {{
lib.getBin = value: value;
lib.escapeShellArg = builtins.toJSON;
pkgsCross.mingwW64 = data.windowsPkgs // {{ hostPlatform.system = "fixture-windows"; }};
}};
in import {ROOT / 'package-windows.nix'} {arguments}
''')
script = run(['nix', 'eval', '--offline', '--raw', '--file', str(expression)])
variants = [('fixed', script)]
if baseline:
if kind == 'cli':
match = re.search(r'installPhase = old\.installPhase \+ \'\'(.*?)\n\s*\'\';', baseline, re.S)
else:
part = baseline[baseline.index('kind = "windows";'):]
match = re.search(r'postInstall = \'\'(.*?)\n\s*\'\';', part, re.S)
if not match:
raise RuntimeError('Cannot find exact baseline Windows packaging phase')
body = match.group(1)
body = re.sub(r'\$\{pkgs\.tree\}', str(tools), body)
body = re.sub(r'\$\{pkgs\.zip\}', str(tools), body)
body = re.sub(r'\$\{pkgs\.binutils\}', str(tools), body)
names = {'openssl': 'openssl', 'libffi': 'libffi', 'gmp': 'gmp',
'stdenv.cc.cc': 'gcc', 'windows.mcfgthreads': 'mcfgthreads'}
for nix_name, name in names.items():
body = body.replace('${pkgs.lib.getBin pkgs.pkgsCross.mingwW64.' + nix_name + '}', providers[name])
body = body.replace('${pkgs.pkgsCross.mingwW64.hostPlatform.system}', 'fixture-windows')
assert '${' not in body, 'Unresolved baseline Nix interpolation'
variants.append(('original', body))
for variant, shell in variants:
out = fixture / (kind + '-' + variant)
(out / 'bin').mkdir(parents=True)
shutil.copyfile(fixture / 'simplex-chat.exe', out / 'bin' / 'simplex-chat.exe')
env = dict(os.environ, out=str(out))
run(['/bin/bash', '-e', '-c', shell], cwd=fixture, env=env)
archive_path = next(out.glob('*.zip'))
with zipfile.ZipFile(archive_path) as archive:
assert archive.testzip() is None
actual = set(archive.namelist())
if variant == 'fixed':
assert actual == expected, (kind, actual, expected)
else:
assert expected - actual, 'Original unexpectedly includes the DLL closure'
results.append({'kind': kind, 'variant': variant, 'members': sorted(actual),
'missingExpectedDLLs': sorted(expected - actual)})
(args.output / 'result.json').write_text(json.dumps({'checks': results,
'realPEInputs': True, 'targetExecuted': False}, indent=2) + '\n')
print(json.dumps(results, indent=2))
(args.output / 'commands.json').write_text(json.dumps(commands, indent=2) + '\n')
if __name__ == '__main__':
main()