Pin PlatformIO packages and toolchains, rebase protected SDK overrides, and add WebSocket receive regression coverage. Document isolated candidate validation, archive provenance, and remaining gates.
215 lines
9.5 KiB
Python
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())
|