#!/usr/bin/env python3 """Bounded Phase 9 host validation; build and local interop are explicit opt-ins.""" import argparse from dataclasses import dataclass import math import hashlib import json import os from pathlib import Path import signal import subprocess import sys import time ROOT = Path(__file__).resolve().parents[1] BUILD = Path('.pio/build/esp32-s3-devkitc-1-n16r8') @dataclass(frozen=True) class Command: name: str argv: tuple timeout: float required: tuple = () def positive_seconds(value): seconds = float(value) if not math.isfinite(seconds) or not 0 < seconds <= 3600: raise argparse.ArgumentTypeError('timeout must be finite and in (0, 3600] seconds') return seconds def parser(): cli = argparse.ArgumentParser(description=__doc__) cli.add_argument('--build-dir', type=Path, default=BUILD, help='existing firmware build directory') cli.add_argument('--idf-path', type=Path, help='explicit ESP-IDF SDK for SDK-aware checks') cli.add_argument('--platformio-core-dir', type=Path, help='explicit isolated PlatformIO core directory') cli.add_argument('--web-performance', action='store_true', help='also validate generated WebSocket performance contracts') cli.add_argument('--build', action='store_true', help='run pio run before host checks (may fetch dependencies)') cli.add_argument('--interop', action='store_true', help='enable ordering OpenSSH local Unix-socket matrix') cli.add_argument('--dry-run', action='store_true', help='print plan only; no execution or prerequisite validation') cli.add_argument('--fail-fast', action='store_true', help='stop after first failure; default collects failures') cli.add_argument('--timeout', type=positive_seconds, default=180, help='seconds per host command (default: 180, maximum: 3600)') cli.add_argument('--build-timeout', type=positive_seconds, default=600, help='seconds for optional build (default: 600, maximum: 3600)') return cli def plan(options): commands = [] if options.build: commands.append(Command('build', ('pio', 'run'), options.build_timeout)) def suite(name, *args, required=(), label=None): path = Path('tests') / name / 'run.py' commands.append(Command(label or name, (sys.executable, '-B', str(path), *map(str, args)), options.timeout, (path, *required))) build = options.build_dir idf_args = ('--idf-path', options.idf_path) if options.idf_path else () header = build / 'config/sdkconfig.h' database = build / 'compile_commands.json' suite('security_build_policy', '--sdkconfig-header', header, required=(header,)) for name in ('ssh_auth_policy', 'ssh_auth_transport', 'hidden_input'): suite(name) suite('ssh_memory', *idf_args) suite('sdk_security_overrides', '--build-dir', build, *idf_args, required=(database,)) for name in ('wolfssh_auth_contract', 'ssh_protocol_policy', 'wolf_crypto_policy'): suite(name, '--compile-commands', database, required=(database,)) suite('wolfssh_parser_contract') suite('wolfssh_order_contract', *(['--interop'] if options.interop else [])) suite('release_notices') for name in ('ssh_management', 'admin_console_boundary', 'admin_ssh_policy', 'web_admin_transport', 'web_admin_tickets', 'web_httpd_idle'): suite(name) # The current runner has no --admission branch: its default covers admission. for mode in ('base', 'admin', 'accounts', 'ssh', 'lifecycle'): suite('web_cookie_auth', *([] if mode == 'base' else ['--' + mode]), label='web_cookie_auth:' + mode) if options.web_performance: suite('web_serial_performance', '--build-dir', build, *idf_args, required=(database,)) return commands def verify_snapshot(root, build): """Compare source inputs, not path-dependent generated build configuration.""" entries = json.loads((build / 'compile_commands.json').read_text()) mains = [(Path(e['directory']) / e['file']).resolve() for e in entries if Path(e['file']).parts[-2:] == ('src', 'main.c')] if len(mains) != 1: raise RuntimeError('expected exactly one application main.c compilation input') snapshot = mains[0].parents[1] scopes = ('src', 'cmake', 'boards', 'managed_components', 'tools/wolfssh_order', 'tools/security_overrides.py', 'CMakeLists.txt', 'extra_script.py', 'sdkconfig.defaults', 'partitions.csv') digest = hashlib.sha256() count = 0 for scope in scopes: def files(base): path = base / scope if path.is_file(): return {Path(scope)} if not path.is_dir(): raise RuntimeError(f'missing snapshot input: {path}') return {p.relative_to(base) for p in path.rglob('*') if p.is_file() and not {'.git', '__pycache__'} & set(p.parts)} paths = files(root) if paths != files(snapshot): raise RuntimeError(f'root/build snapshot file set differs: {scope}') for relative in sorted(paths): data = (root / relative).read_bytes() if data != (snapshot / relative).read_bytes(): raise RuntimeError(f'root/build snapshot content differs: {relative}') digest.update(str(relative).encode() + b'\0' + hashlib.sha256(data).digest()) count += 1 evidence = (snapshot, count, digest.hexdigest()) print(f'SNAPSHOT source equality: {snapshot}; {count} files; SHA256 {evidence[2]}', flush=True) return evidence def kill_group(process): try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass process.wait(timeout=5) def run_command(command, root, env): missing = [str(path) for path in command.required if not (root / path).is_file()] if missing: return 'PREREQ', 'missing ' + ', '.join(missing) try: # Inherit stdout/stderr: no persistent captures or environment dumps. process = subprocess.Popen(command.argv, cwd=root, env=env, stdin=subprocess.DEVNULL, start_new_session=True) except OSError as error: return 'PREREQ', f'cannot launch command (errno {error.errno})' try: code = process.wait(timeout=command.timeout) except subprocess.TimeoutExpired: kill_group(process) return 'TIMEOUT', f'exceeded {command.timeout:g}s; process group killed' except KeyboardInterrupt: kill_group(process) raise # Also retire any descendants left by a runner that exited early. try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass return ('PASS', 'exit 0') if code == 0 else ('FAIL', f'exit {code}') def execute(commands, root, *, dry_run=False, fail_fast=False, idf_path=None, platformio_core_dir=None): if os.name != 'posix': print('PREREQ: POSIX process groups required', flush=True) return 1 env = os.environ.copy() env['CCACHE_DISABLE'] = '1' if idf_path is not None: env['IDF_PATH'] = str(idf_path.resolve()) if platformio_core_dir is not None: env['PLATFORMIO_CORE_DIR'] = str(platformio_core_dir.resolve()) results = [] stopped = False for command in commands: print(f'COMMAND {command.name} timeout={command.timeout:g}s argv={list(command.argv)!r}', flush=True) if dry_run: status, detail = 'PLAN', 'not executed; prerequisites not checked' elif stopped: status, detail = 'SKIP', 'earlier failure (fail-fast or failed build)' else: started = time.monotonic() try: status, detail = run_command(command, root, env) except KeyboardInterrupt: print(f'INTERRUPTED {command.name}; process group killed', flush=True) return 130 detail += f' ({time.monotonic() - started:.1f}s)' if status != 'PASS' and (fail_fast or command.name == 'build'): stopped = True results.append((command.name, status, detail)) print(f'{status} {command.name}: {detail}', flush=True) print('\nSUMMARY (command exits, not target validation):', flush=True) for name, status, detail in results: print(f'{status} {name}: {detail}', flush=True) return int(any(status not in ('PASS', 'PLAN') for _, status, _ in results)) def main(argv=None): options = parser().parse_args(argv) if not options.build: print('SKIP optional build: --build not requested; existing artifacts required', flush=True) if not options.interop: print('SKIP optional interop: --interop not requested', flush=True) if options.build and options.build_dir != BUILD: parser().error('--build cannot be combined with a non-default --build-dir; build it separately') try: evidence = None if not options.dry_run and options.build_dir != BUILD: evidence = verify_snapshot(ROOT, options.build_dir.resolve()) result = execute(plan(options), ROOT, dry_run=options.dry_run, fail_fast=options.fail_fast, idf_path=options.idf_path, platformio_core_dir=options.platformio_core_dir) if evidence is not None and verify_snapshot(ROOT, options.build_dir.resolve()) != evidence: raise RuntimeError('snapshot changed during validation') return result except (OSError, ValueError, RuntimeError) as error: print(f'PREREQ snapshot: {error}', flush=True) return 1 if __name__ == '__main__': sys.exit(main())