Phase 6: Native Executables implementieren und Change archivieren

This commit is contained in:
2026-09-07 14:16:32 +02:00
parent 993c3e7638
commit 60d37ec79d
28 changed files with 1951 additions and 215 deletions

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Native CLI-/Standalone-Parität. Vorher cargo build --release für tbc/tbrt/tb-template."""
import argparse
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import tempfile
def run(args, cwd, *, data=b'', env=None):
return subprocess.run([str(a) for a in args], cwd=cwd, input=data,
capture_output=True, timeout=30, env=env)
def must(result):
assert result.returncode == 0, (result.args, result.returncode, result.stdout, result.stderr)
return result
def terminal(binary, arguments, cwd, env, abort=False):
"""Echte Unix-PTY, Eingabe/Abbruch und unveränderte Terminalattribute."""
import fcntl
import select
import signal
import termios
import time
master, slave = os.openpty()
original = termios.tcgetattr(slave)
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 30, 100, 0, 0))
pid = os.fork()
if pid == 0:
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
for fd in (0, 1, 2): os.dup2(slave, fd)
os.close(master)
os.close(slave)
result = subprocess.run([str(binary), *map(str, arguments)], cwd=cwd, env=env, check=False)
restored = termios.tcgetattr(0) == original
os._exit(result.returncode if restored else 99)
transcript = bytearray()
sent = False
reaped = False
deadline = time.monotonic() + 15
try:
while True:
if select.select([master], [], [], .05)[0]:
transcript.extend(os.read(master, 65536))
if not sent and b'READY' in transcript:
os.write(master, b'\x03' if abort else b'Ada\r')
sent = True
child, status = os.waitpid(pid, os.WNOHANG)
if child:
reaped = True
assert os.waitstatus_to_exitcode(status) == (3 if abort else 0), bytes(transcript)
break
assert time.monotonic() < deadline, bytes(transcript[-2000:])
assert sent and b'?1049h' in transcript and b'?1049l' in transcript, transcript
assert (b'Abgebrochen' if abort else b'PTYAda') in transcript, transcript
finally:
if not reaped:
os.killpg(pid, signal.SIGKILL)
os.waitpid(pid, 0)
os.close(master)
os.close(slave)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--bin-dir', type=Path, default=Path('target/release'))
parser.add_argument('--target', required=True)
args = parser.parse_args()
binaries = args.bin_dir.resolve()
suffix = '.exe' if args.target == 'x86_64-pc-windows-msvc' else ''
tbc = binaries / ('tbc' + suffix)
rt = binaries / ('tbrt' + suffix)
prepare = binaries / ('tb-template' + suffix)
env = dict(os.environ, PATH='') # weder Cargo noch tbc/tbrt über PATH auflösbar
with tempfile.TemporaryDirectory(prefix='tb-native-') as work:
root = Path(work)
template = root / ('runtime' + suffix)
shutil.copy2(rt, template)
must(run([prepare, template, args.target], root))
empty = run([template], root, env=env)
assert empty.returncode == 1 and b'Nutzlast' in empty.stderr, empty
cases = {
'console': ('PRINT "Hallo, Welt!"\nPRINT COMMAND$\nINPUT "Name"; n$\nPRINT n$\nLPRINT "Papier"\nEND\n', b'Ada\n'),
'restart': ('OPEN "count.txt" FOR BINARY AS #1\nn%=LOF(1)\nCLOSE #1\nIF n%=0 THEN\nOPEN "count.txt" FOR OUTPUT AS #1\nPRINT #1, "einmal"\nCLOSE #1\nx%=99\nRUN\nEND IF\nPRINT x%; COMMAND$\nEND\n', b''),
'stop': ('PRINT "vor STOP"\nSTOP\nPRINT "falsch"\n', b''),
'error': ('PRINT "vor Fehler"\nERROR 6\n', b''),
'project': ('CALL Ergebnis\nEND\n', b''),
'form': ('VERSION 1.00\nBegin Form Form1\n Caption = "Native Form"\n Width = 40\n Height = 10\n Begin CommandButton Ok\n Caption = "OK"\n Default = -1\n Left = 2\n Top = 2\n Width = 10\n Height = 1\n End\nEnd\nForm1.Show\nSUB Ok_Click\nForm1.Hide\nCLS\nPRINT "FORMDONE"\nEND\nEND SUB\n', b'\n'),
'external': ('RUN "NEXT.TBC"\n', b''),
}
for name, (code, data) in cases.items():
case = root / name
sources, reference, clean = (case / p for p in ('sources', 'reference', 'clean'))
for d in (sources, reference, clean): d.mkdir(parents=True)
source = sources / ('main.frm' if name == 'form' else 'main.bas')
source.write_text(code, encoding='utf-8')
if name == 'project':
(sources / 'lib.bas').write_text("'$INCLUDE: 'OUTER.BI'\n")
(sources / 'outer.bi').write_text("'$INCLUDE: 'INNER.BI'\n")
(sources / 'inner.bi').write_text('SUB Ergebnis\nOPEN "seed.txt" FOR INPUT AS #1\nLINE INPUT #1, s$\nCLOSE #1\nOPEN "result.txt" FOR OUTPUT AS #2\nPRINT #2, s$\nCLOSE #2\nPRINT s$\nERROR 6\nEND SUB\n')
source = sources / 'app.mak'
source.write_text('MAIN.BAS\nLIB.BAS\n')
for d in (reference, clean): (d / 'seed.txt').write_text('Grüße\n', encoding='utf-8')
if name == 'form':
source.write_text(code.replace('PRINT "FORMDONE"', 'CALL Ergebnis'), encoding='utf-8')
(sources / 'lib.bas').write_text("'$INCLUDE: 'OUTER.BI'\n")
(sources / 'outer.bi').write_text("'$INCLUDE: 'INNER.BI'\n")
(sources / 'inner.bi').write_text('SUB Ergebnis\nPRINT "FORMDONE"\nEND SUB\n')
source = sources / 'app.mak'
source.write_text('MAIN.FRM\nLIB.BAS\n')
if name == 'external':
child = sources / 'next.bas'
child.write_text('PRINT "EXTERNAL"; COMMAND$\nEND\n')
must(run([tbc, 'build', child], root))
shutil.copy2(child.with_suffix('.tbc'), clean / 'next.tbc')
expected = run([tbc, 'run', source, 'alpha', 'ä beta'], reference, data=data)
exe = clean / ('program' + suffix)
build_source = source
if name == 'error':
must(run([tbc, 'build', source], root))
build_source = source.with_suffix('.tbc')
command = [tbc, 'build', build_source, '--exe', '--target', args.target, '--template', template, '-o', exe]
must(run(command, root, env=env))
original = exe.read_bytes()
conflict = run(command, root, env=env)
assert conflict.returncode == 1 and b'--force' in conflict.stderr
assert exe.read_bytes() == original
must(run(command + ['--force'], root, env=env))
if sys.platform == 'darwin':
must(run(['/usr/bin/codesign', '--verify', '--strict', exe], root))
shutil.rmtree(sources)
actual = run([exe, 'alpha', 'ä beta'], clean, data=data, env=env)
assert (actual.returncode, actual.stdout, actual.stderr) == (expected.returncode, expected.stdout, expected.stderr), (name, expected, actual)
if name == 'form': assert b'FORMDONE' in actual.stdout, actual
if name == 'restart': assert b'99' not in actual.stdout and b'alpha' in actual.stdout, actual
if name == 'project': assert b'inner.bi:9:1:' in actual.stderr.lower(), actual
for file in reference.iterdir():
assert (clean / file.name).read_bytes() == file.read_bytes(), file
assert sorted(p.name for p in clean.iterdir()) == sorted([exe.name] + [p.name for p in reference.iterdir()] + (['next.tbc'] if name == 'external' else []))
print('PASS native', name, flush=True)
if os.name == 'posix':
source = root / 'terminal.bas'
source.write_text('PRINT "READY"\nINPUT s$\nPRINT "PTY"; s$\nEND\n')
exe = root / 'terminal'
must(run([tbc, 'build', source, '--exe', '--template', template, '--target', args.target, '-o', exe], root, env=env))
for abort in (False, True):
terminal(tbc, ['run', source], root, env, abort)
source.unlink()
for abort in (False, True):
terminal(exe, [], root, env, abort)
print('PASS PTY Eingabe, Abbruch und Wiederherstellung (CLI/native)', flush=True)
if os.name == 'posix':
import signal
import time
source = root / 'cancel.bas'
source.write_text('PRINT "nicht veröffentlicht"\nEND\n')
output = root / 'cancelled'
for sig in (signal.SIGINT, signal.SIGTERM):
output.write_bytes(b'original')
process = subprocess.Popen([str(tbc), 'build', str(source), '--exe', '--template', str(template), '--target', args.target, '-o', str(output), '--force'], cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
deadline = time.monotonic() + 10
while not list(root.glob('.tb-export-*.tmp')):
assert process.poll() is None and time.monotonic() < deadline, 'Export endete vor Abbruchprobe'
time.sleep(.001)
process.send_signal(sig)
stdout, stderr = process.communicate(timeout=15)
assert process.returncode == 1 and b'abgebrochen' in stderr, (process.returncode, stdout, stderr)
assert output.read_bytes() == b'original'
assert not list(root.glob('.tb-export-*.tmp'))
finally:
if process.poll() is None:
process.kill()
process.wait()
print('PASS Exportabbruch SIGINT/SIGTERM, Original und Bereinigung', flush=True)
# Beschädigte Nutzlast wird auf macOS neu signiert, damit der Lader statt des Kernels prüft.
valid = (root / 'console/clean' / ('program' + suffix)).read_bytes()
footer = valid.rfind(b'TBPCODE!')
assert footer >= 0
for name, mutate in (
('length', lambda b: struct.pack_into('<Q', b, footer + 32, 2**64-1)),
('version', lambda b: struct.pack_into('<I', b, footer + 8, 99)),
('truncated', lambda b: b.__delitem__(slice(footer-10, footer))),
):
bad = root / ('bad-' + name + suffix)
data = bytearray(valid)
if sys.platform == 'darwin':
# Zunächst Signatur entfernen; Finalisierung wird anschließend erneut angewendet.
bad.write_bytes(data)
must(run(['/usr/bin/codesign', '--remove-signature', bad], root))
data = bytearray(bad.read_bytes())
mutate(data)
if sys.platform == 'darwin':
at = 32
for _ in range(struct.unpack_from('<I', data, 16)[0]):
cmd, size = struct.unpack_from('<II', data, at)
if cmd == 0x19 and data[at+8:at+24].rstrip(b'\0') == b'__LINKEDIT':
start = struct.unpack_from('<Q', data, at+40)[0]
struct.pack_into('<Q', data, at+48, len(data)-start)
at += size
bad.write_bytes(data)
bad.chmod(0o755)
if sys.platform == 'darwin':
must(run(['/usr/bin/codesign', '--force', '--sign', '-', '--timestamp=none', bad], root))
result = run([bad], root, env=env)
assert result.returncode == 1 and b'Ladefehler' in result.stderr and not result.stdout, result
print('PASS corrupted', name, flush=True)
print('Native Abnahme bestanden:', args.target)
if __name__ == '__main__':
main()