ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_bench_lock.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: bench users take the lock, and programmers inspect their image first.
5
6Why this is a gate and not a convention
7---------------------------------------
8There is exactly ONE EK-RA8D2, and the owner, another maintainer, ~20
9concurrent agents over ssh and a nightly CI job all reach it. Before #497
10nothing serialised that at all -- no flock, no lockfile, no PID file anywhere
11in the tree. ``scripts/hil/bench.sh`` fixed the mechanism; this fixes the
12enforcement, because a lock nobody is forced to take is decoration.
13
14The set of bench-touching entry points is DERIVED, never listed. Anything that
15invokes ``JLinkExe``, ``JLinkGDBServer``, ``rfp-cli``, ``openocd``,
16``esptool``, ``uhubctl`` or ``tapo_control.py``, opens a ``/dev/ttyACM*``
17console, or shells into the bench host over ``ssh $PI_HOST``, is a bench
18operation by construction. A hand-maintained list would go stale the first time
19somebody added a script -- exactly the way the deleted HIL suite runner held a
20small static table against a much larger derived catalogue.
21
22What counts as guarded
23----------------------
24- the file sources ``lib/bench_lock.sh`` AND calls ``ra8_bench_require`` (or
25 the ``_recovery`` form), or
26- the file drives the hardware through ``bench.sh run --``, which is the same
27 hold in wrapper form, or
28- the file is on :data:`CARVE_OUTS` **with a stated reason**. A carve-out
29 without a reason is a rejected carve-out; the reason is what a reviewer
30 argues with.
31
32Matching is invocation-shaped, not substring-shaped: a tool name has to appear
33in COMMAND position (start of a line, or after a pipe, ``&&``, ``;``, ``sudo``,
34``exec`` and friends). Every file in this tree that mentions ``JLinkExe`` in
35prose -- and most of them do, because the comments are good -- would otherwise
36be flagged, and a gate that cries wolf gets a blanket carve-out list within a
37week.
38
39Every script that actually programs an image has a second obligation:
40``ra8_preflash_guard`` must inspect the full image before ``loadfile``, GDB
41``load``, OpenOCD ``program`` or Ozone ``File.Open`` can write it. That call-site
42set is derived from the programming commands rather than hand-maintained.
43
44Non-vacuity
45-----------
46This repo's dominant tooling defect is a checker that quietly stopped matching
47and reported a clean tree forever; it has happened at least four times.
48``--selftest`` therefore asserts three things, not one:
49
501. a synthetic script that shells out to JLinkExe with no guard must FAIL;
512. the same script with the guard call must PASS, as must one that only
52 mentions the tool in a comment or an echo;
533. a DISCOVERY FLOOR -- the live scan must still find at least
54 :data:`DISCOVERY_FLOOR` bench-touching files, and must still find every
55 file in :data:`MUST_DISCOVER`. A regex that stopped matching turns those
56 red instead of turning the tree green.
57
58Run::
59
60 check_bench_lock.py # gate
61 check_bench_lock.py --selftest # prove the detector, both ways, plus floor
62 check_bench_lock.py --list # what it considers bench-touching, and why
63
64Exit 0 clean, 1 on a violation or a failing selftest, 2 when the tree cannot
65be read.
66"""
67
68from __future__ import annotations
69
70import re
71import shutil
72import subprocess
73import sys
74import tempfile
75from pathlib import Path
76
77REPO_ROOT = Path(__file__).resolve().parents[2]
78
79EXIT_OK = 0
80EXIT_FAIL = 1
81EXIT_CONFIG = 2
82
83# Files whose CONTENT is scanned. Everything else in the tree either cannot
84# invoke a tool (headers, C sources, fixtures) or reaches the hardware only by
85# calling one of these -- and the delegate is what carries the guard.
86SCANNED_SUFFIXES = (".sh", ".py", ".mk", ".just", ".yml", ".yaml")
87SCANNED_BASENAMES = ("justfile", "Justfile")
88
89# Directories that are somebody else's code, or generated, or not first-party.
90EXCLUDE_FRAGMENTS = (
91 "libs/third_party/",
92 "apps/shared_libs/third_party/",
93 "port/threadx/",
94 "coprocessor/esp32c6/esp-hosted-mcu/",
95 "recon/",
96)
97
98# The tools. Each is matched only in COMMAND position -- see _command_word_re.
99BENCH_TOOLS = (
100 "JLinkExe",
101 "JLinkGDBServer",
102 "rfp-cli",
103 "openocd",
104 "uhubctl",
105 "esptool",
106 "esptool.py",
107 "tapo_control.py",
108 "ra8-hil-privileged",
109)
110
111# Words that may legitimately precede a command without changing the fact that
112# it is being invoked. `command` is deliberately NOT here: `command -v JLinkExe`
113# is an availability test, and treating it as an invocation would flag every
114# script that checks for its own dependency.
115_PREFIXES = (
116 r"(?:sudo(?:\s+-n)?|nohup|setsid|exec|time|timeout\s+\S+|env"
117 r"|python3?\s+-m|bash|sh)"
118)
119# What can appear immediately before a command word. A bare `(` is NOT here:
120# `# ... power-cycle (uhubctl) then flash` is prose, and subshell-wrapped
121# invocations of these tools do not occur in this tree.
122_CMD_START = r"(?:^|[|;&`]|\$\‍(|&&|\|\||\bthen\b|\bdo\b|\bif\b|\belif\b|\bcmd:|--\s)"
123
124
125def _command_word_re(tool: str) -> re.Pattern[str]:
126 """A regex matching *tool* in command position, allowing a path prefix."""
127 esc = re.escape(tool)
128 return re.compile(rf"{_CMD_START}\s*!?\s*(?:{_PREFIXES}\s+)*(?:[\w./$'\"{{}}-]*/)?{esc}\b")
129
130
131TOOL_RES = tuple((t, _command_word_re(t)) for t in BENCH_TOOLS)
132
133# Availability tests, which are not invocations. `if ! command -v JLinkExe`
134# appears in most of these scripts and means the opposite of driving the board.
135PRESENCE_RE = re.compile(
136 r"\b(?:command\s+-v|which|type\s+-?\w?)\s+\S*(?:"
137 + "|".join(re.escape(t) for t in BENCH_TOOLS)
138 + r")\b"
139)
140
141# A console OPEN, not a mention. `for d in /dev/ttyACM*` in a presence test is
142# not an open; `stty -F /dev/ttyACM0` and `cat /dev/ttyACM0` are.
143TTY_OPEN_RE = re.compile(
144 r"(?:\b(?:stty|cat|dd|fuser|tee|screen|minicom|picocom)\b[^\n]*|[<>]\s*)"
145 r"/dev/ttyACM"
146)
147
148# Shelling into the bench host. The remote command is a bench operation by
149# definition -- that host exists to hold the board.
150SSH_PI_RE = re.compile(r"\bssh\b[^\n]*\$\{?PI_HOST\b|\bssh\b[^\n]*\"\$PI\"")
151
152# The guard, in any of its accepted forms.
153GUARD_RE = re.compile(
154 r"\bra8_bench_require(?:_recovery)?\b"
155 r"|\bbench\.sh\s+(?:run|acquire)\b"
156 r"|\bbench_host\.sh\s+hold\b"
157)
158
159# Programming actions, as distinct from read-only probe/debug operations. Pure
160# comments are removed before these expressions run.
161PROGRAM_RES: tuple[tuple[str, re.Pattern[str]], ...] = (
162 ("J-Link loadfile", re.compile(r"^\s*loadfile\b", re.MULTILINE)),
163 ("GDB load", re.compile(r"(?:^|\s)-ex\s+[\"']load[\"']")),
164 ("OpenOCD program", re.compile(r"(?:^|\s)-c\s+[\"']program\b")),
165 ("Ozone File.Open", re.compile(r"^\s*File\.Open\s*\‍(", re.MULTILINE)),
166 ("Renesas programmer write", re.compile(r"\brfp-cli\b[^\n]*(?:-write|-program)\b")),
167)
168PREFLASH_GUARD_RE = re.compile(r"\bra8_preflash_guard\b")
169
170# --------------------------------------------------------------------------
171# Carve-outs. Every entry states WHY, because the reason is the thing a
172# reviewer argues with; a bare path is an exemption nobody can evaluate.
173# --------------------------------------------------------------------------
174CARVE_OUTS: dict[str, str] = {
175 # The lock itself. Guarding the guard is a deadlock, not a safety property.
176 "scripts/hil/bench.sh": "IS the lock CLI -- it cannot take a lock to take a lock",
177 "scripts/hil/lib/bench_client.sh": (
178 "IS the lock's client transport; its ssh to the bench host is how a "
179 "hold is taken in the first place"
180 ),
181 # Bootstrap: this is how JLINK_SN gets into .env in the first place, so it
182 # must work before any rig config exists. ShowEmuList enumerates attached
183 # probes; it does not connect to, halt, or program the target.
184 "scripts/hil/find_jlink.sh": (
185 "bootstrap -- enumerates attached probes to populate .env, before the "
186 "rig is configured at all; it never connects to the target"
187 ),
188 # The one recovery path that must survive the rig being down.
189 "scripts/hil/dlm_reset_local.sh": (
190 "the board has been physically moved off the rig onto this "
191 "workstation, so the bench host is not in the path; requiring a lock "
192 "held on that host would make recovery impossible exactly when the "
193 "rig is the thing that is broken"
194 ),
195 "scripts/checks/check_no_antirecovery.py": (
196 "a checker whose PATTERNS name the tools; it invokes nothing"
197 ),
198 "scripts/checks/check_hil_privilege_boundary.py": (
199 "an AST/static policy proof whose exact expected argv and negative fixtures "
200 "name uhubctl; it executes no hardware command"
201 ),
202 "scripts/checks/check_python_lock_policy.py": (
203 "the dependency consumer catalogue names tapo_control.py as data; it invokes nothing"
204 ),
205 "scripts/checks/hil_convergence_safety_roles.py": (
206 "the role checker compares the exact J-Link health-check command as data; "
207 "it invokes nothing"
208 ),
209 "scripts/checks/lint_coverage_rules.py": (
210 "the lint ownership registry names privileged helper artefacts as data; it invokes nothing"
211 ),
212 "scripts/checks/check_shell_just_invocations.py": (
213 "the sensitive-boundary registry records the OBSOLETE direct-ssh supervisor line as a "
214 "must-be-absent literal, so the only bench command in the file is the one it forbids; "
215 "it invokes nothing but git ls-files"
216 ),
217 "scripts/checks/check_hil_rig_contract.py": (
218 "its hermetic selftest harness shadows ssh and scp with shell functions that assert "
219 "argument shape and print a marker, so the fixture proves PI_HOST survives sourcing "
220 "without a bench in the path; it must run in CI where no bench exists, so it cannot "
221 "take a lock"
222 ),
223 "infra/ansible/roles/dev_box/files/ra8-hil-privileged.py": (
224 "the fixed root delegate is reached only by guarded HIL callers; guarding the "
225 "delegate again would deadlock the caller's already-held bench lease"
226 ),
227 "infra/ansible/roles/hil_bench/tasks/transaction.yml": (
228 "the fleet dispatcher holds the authenticated whole-play bench lease, and the "
229 "convergence safety gate rejects direct playbook/task-selector bypasses"
230 ),
231 "scripts/hil/lib/privileged_helper.sh": (
232 "the library performs only read-only helper identity probes itself; mutating "
233 "helper calls remain in derived, guarded HIL entrypoints"
234 ),
235 # The contention harness. It must NOT hold the lock: it drives several
236 # independent machines that compete for it, and a driver holding the thing
237 # under test would prevent the very contention it exists to measure. It
238 # touches no hardware itself -- it ships a read-only /proc witness to the
239 # bench host, reads the journal, and leaves every actual bench operation to
240 # the actors, each of which goes through the guard.
241 "scripts/hil/bench_contention.sh": (
242 "drives the contention EXPERIMENT; holding the lock would prevent the "
243 "contention it measures. It never touches hardware -- the actors it "
244 "launches each take the lock through the ordinary guard"
245 ),
246 # The negative control, and the only file in the tree allowed to reach the
247 # bench unguarded. Without it, "the witness saw no collision" could equally
248 # mean "the witness sees nothing", which is this repo's most common tooling
249 # failure. It is read-only, refuses to run without an explicit opt-in, and
250 # refuses outright while anybody holds the lock.
251 "scripts/hil/bench_unguarded_probe.sh": (
252 "IS the negative control -- it proves the bench witness can see two "
253 "machines on the board at once, which is what makes a clean guarded "
254 "run mean anything. Read-only, gated behind "
255 "RA8_BENCH_NEGATIVE_CONTROL=1, and refuses while the bench is held"
256 ),
257}
258
259# --------------------------------------------------------------------------
260# Anti-rot. A detector that stops matching must FAIL, not report a clean tree.
261# --------------------------------------------------------------------------
262DISCOVERY_FLOOR = 20
263
264MUST_DISCOVER = (
265 "scripts/hil/flash.sh",
266 "scripts/hil/run_direct.sh",
267 "scripts/hil/recover.sh",
268 "scripts/hil/erase.sh",
269 "scripts/hil/dlm_reset.sh",
270 "scripts/hil/flash_retry.sh",
271 "scripts/hil/probe.sh",
272 "scripts/hil/jlink_memprobe.sh",
273 "scripts/hil/ppps.sh",
274 "scripts/dev/flash.sh",
275 "scripts/dev/debug.sh",
276 "scripts/dev/openocd_flash.sh",
277 "coprocessor/esp32c6/flash.sh",
278)
279
280PREFLASH_DISCOVERY_FLOOR = 10
281PREFLASH_MUST_DISCOVER = (
282 "scripts/dev/flash.sh",
283 "scripts/dev/debug.sh",
284 "scripts/dev/openocd_flash.sh",
285 "scripts/dev/openocd_debug.sh",
286 "scripts/dev/ozone.sh",
287 "scripts/hil/run_direct.sh",
288 "scripts/hil/eth_tcp.sh",
289 "scripts/hil/rtt_scrape.sh",
290 "scripts/hil/exit_low_power.sh",
291)
292
293
294def _tracked_files() -> list[str]:
295 """Every authored path, including new non-ignored worktree files."""
296 git = shutil.which("git")
297 if git is None:
298 sys.stderr.write("check_bench_lock.py: git is not installed\n")
299 raise SystemExit(EXIT_CONFIG)
300 out = subprocess.run( # noqa: S603 -- fixed git argv, executable resolved above
301 [
302 git,
303 "ls-files",
304 "--cached",
305 "--others",
306 "--exclude-standard",
307 ],
308 cwd=REPO_ROOT,
309 capture_output=True,
310 text=True,
311 check=False,
312 )
313 if out.returncode != 0:
314 sys.stderr.write("check_bench_lock.py: `git ls-files` failed\n")
315 raise SystemExit(EXIT_CONFIG)
316 return [line for line in out.stdout.splitlines() if line]
317
318
319def _in_scope(rel: str) -> bool:
320 if any(frag in rel for frag in EXCLUDE_FRAGMENTS):
321 return False
322 name = rel.rsplit("/", 1)[-1]
323 return name in SCANNED_BASENAMES or rel.endswith(SCANNED_SUFFIXES)
324
325
326def _strip_comments(text: str) -> list[tuple[int, str]]:
327 """Return (1-based line number, code) for lines that are not pure comments.
328
329 Only whole-line comments are dropped. A trailing comment is left alone --
330 stripping it properly needs a shell parser, and the command-position match
331 below does not fire on prose anyway.
332 """
333 kept: list[tuple[int, str]] = []
334 for n, line in enumerate(text.splitlines(), start=1):
335 stripped = line.lstrip()
336 if stripped.startswith("#"):
337 continue
338 kept.append((n, line))
339 return kept
340
341
342def bench_touches(text: str) -> list[tuple[int, str]]:
343 """Every (line number, reason) at which *text* drives the bench."""
344 hits: list[tuple[int, str]] = []
345 for n, line in _strip_comments(text):
346 if PRESENCE_RE.search(line):
347 continue
348 for tool, rx in TOOL_RES:
349 if rx.search(line):
350 hits.append((n, f"invokes {tool}"))
351 break
352 else:
353 if TTY_OPEN_RE.search(line):
354 hits.append((n, "opens a /dev/ttyACM* console"))
355 elif SSH_PI_RE.search(line):
356 hits.append((n, "runs a command on the bench host over ssh"))
357 return hits
358
359
360def is_guarded(text: str) -> bool:
361 """True when *text* takes the bench lock in any of its accepted forms."""
362 return any(GUARD_RE.search(line) for _, line in _strip_comments(text))
363
364
365def programming_touches(text: str) -> list[tuple[int, str]]:
366 """Return programming actions found outside pure-comment lines."""
367 hits: list[tuple[int, str]] = []
368 for line_no, line in _strip_comments(text):
369 for label, pattern in PROGRAM_RES:
370 if pattern.search(line):
371 hits.append((line_no, label))
372 break
373 return hits
374
375
376def has_preflash_guard(text: str) -> bool:
377 """True when a caller invokes the anti-recovery image guard."""
378 return any(PREFLASH_GUARD_RE.search(line) for _, line in _strip_comments(text))
379
380
381def scan(files: list[str]) -> tuple[dict[str, list[tuple[int, str]]], list[str]]:
382 """Return {rel: hits} for every bench-touching file, and the unguarded ones."""
383 touching: dict[str, list[tuple[int, str]]] = {}
384 unguarded: list[str] = []
385 for rel in files:
386 if not _in_scope(rel):
387 continue
388 path = REPO_ROOT / rel
389 try:
390 text = path.read_text(encoding="utf-8", errors="replace")
391 except OSError:
392 continue
393 hits = bench_touches(text)
394 if not hits:
395 continue
396 touching[rel] = hits
397 if rel in CARVE_OUTS:
398 continue
399 if not is_guarded(text):
400 unguarded.append(rel)
401 return touching, unguarded
402
403
404def scan_preflash(files: list[str]) -> tuple[dict[str, list[tuple[int, str]]], list[str]]:
405 """Return every physical programmer and those missing the image guard."""
406 programming: dict[str, list[tuple[int, str]]] = {}
407 unsafe: list[str] = []
408 for rel in files:
409 if rel == "scripts/checks/check_bench_lock.py":
410 continue # detector fixtures deliberately contain both directions
411 if not rel.endswith((".sh", ".just", ".yml", ".yaml")):
412 continue # command snippets in Python docstrings are not entry points
413 if not _in_scope(rel):
414 continue
415 try:
416 text = (REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace")
417 except OSError:
418 continue
419 hits = programming_touches(text)
420 if not hits:
421 continue
422 programming[rel] = hits
423 if not has_preflash_guard(text):
424 unsafe.append(rel)
425 return programming, unsafe
426
427
428# --------------------------------------------------------------------------
429# selftest
430# --------------------------------------------------------------------------
431
432_FIRE_UNGUARDED = """#!/usr/bin/env bash
433set -euo pipefail
434APP="$1"
435JLinkExe -nogui 1 -CommanderScript /tmp/x.jlink
436"""
437
438_QUIET_GUARDED = """#!/usr/bin/env bash
439set -euo pipefail
440source "$_hil_dir/lib/bench_lock.sh"
441ra8_bench_require "flash $1" || exit $?
442JLinkExe -nogui 1 -CommanderScript /tmp/x.jlink
443"""
444
445_QUIET_RECOVERY = """#!/usr/bin/env bash
446source "$_hil_dir/lib/bench_lock.sh"
447ra8_bench_require_recovery "erase" || exit $?
448rfp-cli -d ra -erase-chip
449"""
450
451_QUIET_WRAPPED = """#!/usr/bin/env bash
452bash scripts/hil/bench.sh run --intent "probe" -- JLinkExe -nogui 1
453"""
454
455_QUIET_PROSE = """#!/usr/bin/env bash
456# JLinkExe is what scripts/dev/flash.sh runs; rfp-cli does the DLM reset.
457echo " flash runs JLinkExe via scripts/dev/flash.sh"
458bash "$ROOT/scripts/dev/flash.sh" "$HEX"
459"""
460
461_QUIET_TTY_PRESENCE = """#!/usr/bin/env bash
462for _d in /dev/ttyACM*; do
463 [ -e "$_d" ] && return 0
464done
465"""
466
467_FIRE_TTY_OPEN = """#!/usr/bin/env bash
468stty -F /dev/ttyACM0 115200 raw -echo
469cat /dev/ttyACM0 > /tmp/log
470"""
471
472_FIRE_SSH = """#!/usr/bin/env bash
473ssh "$PI_HOST" "rfp-cli -d ra -erase-chip"
474"""
475
476_FIRE_SUDO_PREFIX = """#!/usr/bin/env bash
477sudo -n uhubctl -l 2-1.3 -p 2 -a off
478"""
479
480_FIRE_PRIVILEGED_HELPER = """#!/usr/bin/env bash
481sudo -n -- /usr/local/libexec/ra8-hil-privileged usb-root-cycle
482"""
483
484_QUIET_PRIVILEGED_HELPER = """#!/usr/bin/env bash
485source "$ROOT/scripts/hil/lib/bench_lock.sh"
486ra8_bench_require_recovery "cycle" || exit $?
487sudo -n -- /usr/local/libexec/ra8-hil-privileged usb-root-cycle
488"""
489
490_FIRE_PROGRAM_UNGUARDED = """#!/usr/bin/env bash
491cat >/tmp/flash.jlink <<EOF
492loadfile $HEX
493EOF
494JLinkExe -commanderscript /tmp/flash.jlink
495"""
496
497_QUIET_PROGRAM_GUARDED = """#!/usr/bin/env bash
498source "$ROOT/scripts/hil/lib/preflash_guard.sh"
499ra8_preflash_guard "$HEX" || exit $?
500openocd -f board.cfg -c "program $HEX verify reset exit"
501"""
502
503_QUIET_READ_ONLY_DEBUG = """#!/usr/bin/env bash
504JLinkGDBServer -device R7KA8D2KF_CPU0 -port 2331
505"""
506
507SELFTEST_CASES: tuple[tuple[str, str, bool, bool, str], ...] = (
508 # (name, body, must_be_detected, must_be_guarded, label)
509 ("fire_unguarded.sh", _FIRE_UNGUARDED, True, False, "a bare JLinkExe call is caught"),
510 ("quiet_guarded.sh", _QUIET_GUARDED, True, True, "ra8_bench_require satisfies it"),
511 (
512 "quiet_recovery.sh",
513 _QUIET_RECOVERY,
514 True,
515 True,
516 "the recovery guard satisfies it",
517 ),
518 ("quiet_wrapped.sh", _QUIET_WRAPPED, True, True, "`bench.sh run --` satisfies it"),
519 ("quiet_prose.sh", _QUIET_PROSE, False, False, "prose and echo text do not fire"),
520 (
521 "quiet_tty_presence.sh",
522 _QUIET_TTY_PRESENCE,
523 False,
524 False,
525 "a /dev/ttyACM* presence test is not an open",
526 ),
527 ("fire_tty_open.sh", _FIRE_TTY_OPEN, True, False, "opening a console is caught"),
528 ("fire_ssh.sh", _FIRE_SSH, True, False, "ssh onto the bench host is caught"),
529 (
530 "fire_sudo_prefix.sh",
531 _FIRE_SUDO_PREFIX,
532 True,
533 False,
534 "a sudo-prefixed uhubctl is caught",
535 ),
536 (
537 "fire_privileged_helper.sh",
538 _FIRE_PRIVILEGED_HELPER,
539 True,
540 False,
541 "an unguarded privileged helper mutation is caught",
542 ),
543 (
544 "quiet_privileged_helper.sh",
545 _QUIET_PRIVILEGED_HELPER,
546 True,
547 True,
548 "the recovery guard covers a privileged helper mutation",
549 ),
550)
551
552
553def _selftest_detector() -> list[str]:
554 failures: list[str] = []
555 with tempfile.TemporaryDirectory():
556 for name, body, want_hit, want_guard, label in SELFTEST_CASES:
557 hits = bench_touches(body)
558 if bool(hits) != want_hit:
559 verb = "did not fire" if want_hit else "fired"
560 failures.append(f" detector {verb} (unexpected): {name} -- {label}")
561 continue
562 if not want_hit:
563 continue
564 if is_guarded(body) != want_guard:
565 verb = "not recognised as guarded" if want_guard else "wrongly guarded"
566 failures.append(f" guard {verb}: {name} -- {label}")
567 return failures
568
569
570def _selftest_floor(touching: dict[str, list[tuple[int, str]]]) -> list[str]:
571 failures: list[str] = []
572 if len(touching) < DISCOVERY_FLOOR:
573 failures.append(
574 f" DISCOVERY FLOOR: found {len(touching)} bench-touching file(s), "
575 f"floor is {DISCOVERY_FLOOR}. The detector has stopped matching; "
576 f"a clean tree is NOT the explanation."
577 )
578 missing = [rel for rel in MUST_DISCOVER if rel not in touching]
579 if missing:
580 failures.append(
581 " MUST_DISCOVER: these drive the bench and were not detected -- "
582 "the detector is broken, or they were renamed:"
583 )
584 failures.extend(f" {rel}" for rel in missing)
585 return failures
586
587
588def _selftest_preflash_detector() -> list[str]:
589 failures: list[str] = []
590 cases = (
591 (
592 "unguarded programmer is caught",
593 _FIRE_PROGRAM_UNGUARDED,
594 True,
595 False,
596 ),
597 (
598 "guarded programmer passes",
599 _QUIET_PROGRAM_GUARDED,
600 True,
601 True,
602 ),
603 (
604 "read-only debugger needs no image guard",
605 _QUIET_READ_ONLY_DEBUG,
606 False,
607 False,
608 ),
609 )
610 for label, body, want_programmer, want_guard in cases:
611 if bool(programming_touches(body)) != want_programmer:
612 failures.append(f" preflash detector mismatch: {label}")
613 if has_preflash_guard(body) != want_guard:
614 failures.append(f" preflash guard mismatch: {label}")
615 return failures
616
617
618def _selftest_preflash_floor(
619 programming: dict[str, list[tuple[int, str]]],
620) -> list[str]:
621 failures: list[str] = []
622 if len(programming) < PREFLASH_DISCOVERY_FLOOR:
623 failures.append(
624 f" PREFLASH DISCOVERY FLOOR: found {len(programming)} programmer(s), "
625 f"floor is {PREFLASH_DISCOVERY_FLOOR}"
626 )
627 missing = [rel for rel in PREFLASH_MUST_DISCOVER if rel not in programming]
628 if missing:
629 failures.append(" PREFLASH MUST_DISCOVER missed physical programmer(s):")
630 failures.extend(f" {rel}" for rel in missing)
631 return failures
632
633
634def run_selftest() -> int:
635 """Prove the detector fires, stays quiet, and still discovers the tree."""
636 touching, _ = scan(_tracked_files())
637 programming, _ = scan_preflash(_tracked_files())
638 failures = (
639 _selftest_detector()
640 + _selftest_floor(touching)
641 + _selftest_preflash_detector()
642 + _selftest_preflash_floor(programming)
643 )
644 if failures:
645 sys.stderr.write("check_bench_lock.py: --selftest FAILED:\n")
646 sys.stderr.write("\n".join(failures) + "\n")
647 return EXIT_FAIL
648 print(
649 f"check_bench_lock.py: --selftest OK "
650 f"({len(SELFTEST_CASES)} detector cases both directions; live scan finds "
651 f"{len(touching)} bench-touching file(s), floor {DISCOVERY_FLOOR}, "
652 f"{len(MUST_DISCOVER)} named files all present; {len(programming)} physical "
653 f"programmer(s), floor {PREFLASH_DISCOVERY_FLOOR})."
654 )
655 return EXIT_OK
656
657
658def _print_listing(
659 touching: dict[str, list[tuple[int, str]]],
660 unguarded: list[str],
661 programming: dict[str, list[tuple[int, str]]],
662 unsafe_programmers: list[str],
663) -> None:
664 """Print the derived bench and programming call-site inventory."""
665 for rel in sorted(touching):
666 mark = "CARVE-OUT" if rel in CARVE_OUTS else "guarded "
667 if rel in unguarded:
668 mark = "UNGUARDED"
669 print(f"{mark} {rel}")
670 for line_no, why in touching[rel][:3]:
671 print(f" line {line_no}: {why}")
672 if rel in CARVE_OUTS:
673 print(f" reason: {CARVE_OUTS[rel]}")
674 print("\nphysical programmers:")
675 for rel in sorted(programming):
676 mark = "guarded" if rel not in unsafe_programmers else "UNGUARDED"
677 print(f"{mark:9} {rel}")
678 for line_no, why in programming[rel][:3]:
679 print(f" line {line_no}: {why}")
680
681
682def _report_bench_failures(
683 touching: dict[str, list[tuple[int, str]]], unguarded: list[str]
684) -> None:
685 """Explain unguarded physical-bench entry points."""
686 if not unguarded:
687 return
688 sys.stderr.write("check_bench_lock.py: file(s) drive the bench without taking the lock:\n")
689 for rel in sorted(unguarded):
690 sys.stderr.write(f"\n {rel}\n")
691 for line_no, why in touching[rel][:4]:
692 sys.stderr.write(f" line {line_no}: {why}\n")
693 sys.stderr.write(
694 "\nAdd the guard immediately before the first hardware operation:\n"
695 " # shellcheck source=scripts/hil/lib/bench_lock.sh\n"
696 ' source "$_hil_dir/lib/bench_lock.sh"\n'
697 ' ra8_bench_require "<what you are doing>" || exit $?\n'
698 "\nRecovery paths (erase, DLM reset, power cycle) use\n"
699 "ra8_bench_require_recovery instead -- recovery is MORE\n"
700 "destructive than a normal flash, not less, so it is not exempt.\n"
701 "\nIf it genuinely cannot take the lock, add it to CARVE_OUTS in\n"
702 "this file WITH A REASON. A reason a reviewer would reject is not\n"
703 "a reason.\n"
704 )
705
706
707def _report_programmer_failures(
708 programming: dict[str, list[tuple[int, str]]], unsafe_programmers: list[str]
709) -> None:
710 """Explain programming entry points missing the image guard."""
711 if not unsafe_programmers:
712 return
713 sys.stderr.write(
714 "\ncheck_bench_lock.py: physical programmer(s) do not invoke "
715 "ra8_preflash_guard on the full image:\n"
716 )
717 for rel in sorted(unsafe_programmers):
718 sys.stderr.write(f"\n {rel}\n")
719 for line_no, why in programming[rel][:4]:
720 sys.stderr.write(f" line {line_no}: {why}\n")
721 sys.stderr.write(
722 "\nSource scripts/hil/lib/preflash_guard.sh and call "
723 "ra8_preflash_guard on the original image before stripping or programming it.\n"
724 )
725
726
727def main(argv: list[str]) -> int:
728 """Gate entry point: --selftest, --list, or the scan itself."""
729 if "--selftest" in argv:
730 return run_selftest()
731
732 tracked = _tracked_files()
733 touching, unguarded = scan(tracked)
734 programming, unsafe_programmers = scan_preflash(tracked)
735 if "--list" in argv:
736 _print_listing(touching, unguarded, programming, unsafe_programmers)
737 return EXIT_OK
738
739 # A stale carve-out hides nothing but itself and is an error in its own right.
740 stale = [rel for rel in CARVE_OUTS if rel not in touching and (REPO_ROOT / rel).exists()]
741 if unguarded or stale or unsafe_programmers:
742 _report_bench_failures(touching, unguarded)
743 if stale:
744 sys.stderr.write("\ncheck_bench_lock.py: stale carve-out(s):\n")
745 for rel in sorted(stale):
746 sys.stderr.write(f" {rel}\n")
747 _report_programmer_failures(programming, unsafe_programmers)
748 return EXIT_FAIL
749
750 print(
751 f"check_bench_lock.py: {len(touching)} bench-touching file(s), all guarded "
752 f"or carved out with a reason ({len(CARVE_OUTS)} carve-out(s)); "
753 f"{len(programming)} physical programmer(s) all image-guarded."
754 )
755 return EXIT_OK
756
757
758if __name__ == "__main__":
759 raise SystemExit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298