Files
ESP32_Serial_Swiss_Army_Knife/tools/validate_phase9.py
T
Commander1024 797d2681ac Migrate to IDF 5.5.3 candidate
Pin PlatformIO packages and toolchains, rebase protected SDK
overrides, and add WebSocket receive regression coverage. Document
isolated candidate validation, archive provenance, and remaining gates.
2026-09-18 14:23:13 +02:00

215 lines
9.5 KiB
Python

#!/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())