#!/usr/bin/env python3 """Vierzielpakete bauen und optional auf dem Zielsystem prüfen.""" import argparse import hashlib import json import os from pathlib import Path import platform import re import shutil import subprocess import sys import tarfile import tempfile import tomllib ROOT = Path(__file__).resolve().parents[2] TARGETS = { 'x86_64-pc-windows-msvc': ('windows-amd64', '.7z'), 'aarch64-apple-darwin': ('macos-arm64', '.tar.gz'), 'x86_64-unknown-linux-gnu': ('linux-amd64', '.tar.gz'), 'aarch64-unknown-linux-gnu': ('linux-arm64', '.tar.gz'), } HOSTS = {('Windows', 'AMD64'): list(TARGETS)[0], ('Darwin', 'arm64'): list(TARGETS)[1], ('Linux', 'x86_64'): list(TARGETS)[2], ('Linux', 'aarch64'): list(TARGETS)[3]} TBL_SHA = '5d25c2f8c95ae535e55a6c84c1ddd0d964358863f4f3c780c50ac4a2eb3594df' def require(condition, message): if not condition: raise ValueError(message) def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() def run(command, **kwargs): print('+', ' '.join(map(str, command)), flush=True) return subprocess.run(list(map(str, command)), check=True, timeout=3600, **kwargs) def name(version, target): require(re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?', version), 'Ungültige Paketversion') label, extension = TARGETS[target] return f'terminalbasic-{version}-{label}{extension}' def sevenzip(): tool = shutil.which('7zz') or shutil.which('7z') require(tool, '7-Zip fehlt; Windows erfordert echte .7z-Archive') return tool def programs(target): suffix = '.exe' if target == list(TARGETS)[0] else '' return ['tb' + suffix, 'tbc' + suffix, f'runtimes/{target}/tbrt' + suffix] def files(target): return set(programs(target) + [programs(target)[2] + '.meta', 'LICENSE', 'INSTALL.txt', 'manifest.json']) def unpack(archive, dest, target): """Nur bekannte reguläre Dateien; keine Symlinks, Unterverzeichnisse oder Traversierung.""" if archive.name.endswith('.7z'): listing = subprocess.check_output([sevenzip(), 'l', '-slt', '--', str(archive)], text=True) members = listing.split('----------\n', 1)[-1].strip().split('\n\n') found = set() for member in members: fields = dict(line.split(' = ', 1) for line in member.splitlines() if ' = ' in line) path = fields.get('Path') require(path in files(target) and path not in found, f'Unerlaubter Archiveintrag: {path}') require(fields.get('Folder', '-') == '-' and not fields.get('Symbolic Link') and not fields.get('Hard Link') and not fields.get('Attributes', '').startswith('D'), 'Archivlink/-verzeichnis') found.add(path) require(found == files(target), 'Unvollständiges 7z-Archiv') run([sevenzip(), 'x', '-y', '-o' + str(dest), '--', archive], stdout=subprocess.DEVNULL) else: with tarfile.open(archive, 'r:gz') as tar: members = tar.getmembers() require(len(members) == len(files(target)) and {m.name for m in members} == files(target), 'Falscher Archivinhalt') require(all(m.isfile() for m in members), 'Nur reguläre Dateien erlaubt') for member in members: data = tar.extractfile(member).read() path = dest / member.name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) path.chmod(member.mode & 0o777) require({p.relative_to(dest).as_posix() for p in dest.rglob('*') if p.is_file()} == files(target), 'Falscher entpackter Inhalt') require(all(not p.is_symlink() and (p.is_file() or p.is_dir()) for p in dest.rglob('*')), 'Unerlaubter Dateityp') def verify(archive, target, inspector=None): with tempfile.TemporaryDirectory(prefix='tb-package-') as temp: dest = Path(temp) unpack(archive, dest, target) manifest = json.loads((dest / 'manifest.json').read_text()) require(manifest['target'] == target and archive.name == name(manifest['version'], target), 'Falsches Paketziel/-version') require(set(manifest['files']) == files(target) - {'manifest.json'}, 'Falsches Dateimanifest') for path, sha in manifest['files'].items(): require(digest(dest / path) == sha, f'Beschädigte Datei: {path}') for binary in programs(target): if inspector: run([inspector, '--inspect', dest / binary, target], stdout=subprocess.DEVNULL) if target != list(TARGETS)[0]: require((dest / binary).stat().st_mode & 0o111 == 0o111, 'Executable-Rechte fehlen') metadata = (dest / (programs(target)[2] + '.meta')).read_text() require(f'target={target}\n' in metadata and f'package={manifest["version"]}\n' in metadata, 'Vorlagenversion/-ziel falsch') return manifest def pack(binary_dir, target, output, inspector): version = tomllib.loads((ROOT / 'Cargo.toml').read_text())['workspace']['package']['version'] revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() output.mkdir(parents=True, exist_ok=True) archive = output / name(version, target) require(not archive.exists(), f'Paket existiert bereits: {archive}') with tempfile.TemporaryDirectory(prefix='tb-pack-') as temp: stage = Path(temp) for binary in programs(target): run([inspector, '--inspect', binary_dir / Path(binary).name, target], stdout=subprocess.DEVNULL) (stage / binary).parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(binary_dir / Path(binary).name, stage / binary) (stage / binary).chmod(0o755) run([inspector, stage / programs(target)[2], target]) shutil.copyfile(ROOT / 'LICENSE', stage / 'LICENSE') shutil.copyfile(ROOT / '.gitea/INSTALL.txt', stage / 'INSTALL.txt') manifest = dict(version=version, revision=revision, target=target, buildhost=platform.platform(), rustc=subprocess.check_output(['rustc', '--version'], text=True).strip(), build_image=os.environ.get('TB_BUILD_IMAGE', 'local'), cross_toolchain=json.loads(os.environ.get('TB_BUILD_METADATA', '{}')), source_dirty=bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT)), files={p.relative_to(stage).as_posix(): digest(p) for p in stage.rglob('*') if p.is_file()}) (stage / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') if target == list(TARGETS)[0]: run([sevenzip(), 'a', '-t7z', archive, *sorted(files(target))], cwd=stage, stdout=subprocess.DEVNULL) else: with tarfile.open(archive, 'w:gz') as tar: for filename in sorted(files(target)): tar.add(stage / filename, arcname=filename) verify(archive, target, inspector) (output / (archive.name + '.sha256')).write_text(f'{digest(archive)} {archive.name}\n') print(archive) def target_check(archive, target, output, windows_console_test=None): require(HOSTS.get((platform.system(), platform.machine())) == target, 'Echte Zielausführung erfordert passendes Hostsystem') require(not output.exists(), 'Nachweis existiert bereits') manifest = verify(archive, target) report = dict(target=target, revision=manifest['revision'], package=archive.name, package_sha256=digest(archive), tbl_sha256=TBL_SHA, host=platform.platform(), automated_passed=False, manual_terminal_passed=False, checks={}) output.mkdir(parents=True) try: with tempfile.TemporaryDirectory(prefix='tb-unpacked-') as temp: dest = Path(temp) unpack(archive, dest, target) env = dict(os.environ, PATH='') run([dest / programs(target)[0], '--help'], cwd=dest, env=env, stdout=subprocess.DEVNULL) source = dest / 'hello.bas'; source.write_text('PRINT 42\nEND\n') executable = dest / ('hello.exe' if os.name == 'nt' else 'hello') run([dest / programs(target)[1], 'build', source, '--exe', '--target', target, '-o', executable], cwd=dest, env=env) source.unlink() actual = subprocess.check_output([str(executable)], cwd=dest, env=env, timeout=30) require(actual.replace(b'\r\n', b'\n') == b' 42 \n', 'Standard-Vorlagenpfad funktioniert nicht') report['checks']['default-template'] = 'passed' commands = { 'native': [sys.executable, ROOT / 'tests/support/native-abnahme.py', '--bin-dir', dest, '--target', target], 'library': [sys.executable, ROOT / 'tests/support/library-abnahme.py', '--bin-dir', dest], } if os.name == 'posix': commands['ide-pty'] = [sys.executable, ROOT / 'tests/support/ide-execution-pty.py', '--binary', dest / 'tb'] if os.name == 'nt': require(windows_console_test and windows_console_test.is_file(), 'Windows-Konsolentest fehlt') report['windows_console_test_sha256'] = digest(windows_console_test) commands['windows-console'] = [windows_console_test, '--exact', 'terminal::tests::windows_console_restores_modes', '--ignored', '--nocapture'] for check, command in commands.items(): with (output / (check + '.log')).open('wb') as log: run(command, cwd=dest, stdout=log, stderr=subprocess.STDOUT) report['checks'][check] = 'passed' report['automated_passed'] = True finally: require(digest(archive) == report['package_sha256'], 'Paket während Zielprüfung verändert') (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest='command', required=True) p = sub.add_parser('pack'); p.add_argument('--bin-dir', type=Path, required=True); p.add_argument('--output', type=Path, required=True) p.add_argument('--inspector', type=Path, required=True); p.add_argument('--target', choices=TARGETS, required=True) for command in ('verify', 'target-check'): p = sub.add_parser(command); p.add_argument('--archive', type=Path, required=True); p.add_argument('--target', choices=TARGETS, required=True) if command == 'verify': p.add_argument('--inspector', type=Path) else: p.add_argument('--output', type=Path, required=True) p.add_argument('--windows-console-test', type=Path) args = parser.parse_args() if args.command == 'pack': pack(args.bin_dir.resolve(), args.target, args.output.resolve(), args.inspector.resolve()) elif args.command == 'verify': verify(args.archive.resolve(), args.target, args.inspector.resolve() if args.inspector else None) elif args.command == 'target-check': target_check(args.archive.resolve(), args.target, args.output.resolve(), args.windows_console_test.resolve() if args.windows_console_test else None) if __name__ == '__main__': main()