ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_antirecovery.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: no first-party file may introduce a permanent anti-recovery brick.
5
6Owner policy (2026-07-23): this is a personal, open-source project. It must
7NEVER contain code that permanently disables device recovery on the RA8D2.
8Nothing sets any of this today -- the point of this gate is to keep it that
9way forever, by failing CI the moment a change would.
10
11What is FORBIDDEN (a *violation* -- the gate fails)
12---------------------------------------------------
13Only *actions* that move an RA8D2 into a state its own recovery scripts cannot
14undo. Three shapes, all as executed commands / programmatic writes, never as
15prose:
16
17 * Setting the ``ce`` Security Flag ("Disable Initialize Command") -- via
18 ``rfp-cli`` (``-set-security-flag ce``), an option-byte / OSIS / security
19 register write, or any ``set ce`` / ``ce = true`` / ``--disable-initialize``
20 programmatic path. That flag permanently disables the boot-firmware
21 Initialize (erase-chip) recovery that ``scripts/hil/dlm_reset.sh`` relies on.
22 * Transitioning the DLM to a terminal/unrecoverable lock -- ``LCK_BOOT`` (or
23 any lock that renders the SWD-DP permanently unresponsive), e.g.
24 ``rfp-cli -dlm LCK_BOOT`` or a programmatic ``dlm_program(k_dlm_lck_boot)``.
25 * Any ``rfp-cli -dlm <state>`` / security-flag write that moves the device to
26 a state the recovery scripts (``scripts/hil/dlm_reset.sh`` /
27 ``recover.sh``) cannot undo.
28
29What is ALLOWED (must NOT be flagged -- these are pro-recovery / defensive)
30--------------------------------------------------------------------------
31 * The recovery scripts themselves (``scripts/hil/dlm_reset.sh``,
32 ``dlm_reset_local.sh``, ``recover.sh``, ``reflash.sh``). They READ / CHECK
33 that ``ce`` is unset and WARN if recovery failed -- the exact opposite of
34 the forbidden action. They are excluded by path AND the patterns below do
35 not fire on their content (the ``--selftest`` proves both).
36 * Reading / checking DLM state (``rfp-cli -rfo``), and *recoverable* DLM
37 transitions (``OEM_PL0`` / ``OEM_PL1`` debug-lock, which the Initialize
38 command or an authenticated regression can undo).
39 * Any comment, warning string, or negated phrasing that merely *discusses*
40 the danger: "verify ``ce`` is unset", "``ce`` may be set", "must NOT have
41 been set", "do NOT run ``rfp-cli -dlm LCK_BOOT``".
42
43How the ACTION / not-an-action distinction is drawn
44---------------------------------------------------
45Comments are blanked before matching, so a comment that shows the forbidden
46command as a warning cannot trip the gate. The patterns split into two tiers:
47
48 * HARD -- unambiguous executed commands / writes (``rfp-cli -dlm LCK_BOOT``,
49 ``set ce``, ``ce = true``, ``-set-security-flag ce``, ``--disable-initialize``).
50 These fire on any non-comment occurrence; a comment nearby does not excuse
51 an action.
52 * SOFT -- occurrences of a terminal-state NAME near a set-verb (spaced
53 "disable initialize", "... regress ... LCK_BOOT"). A NAME is a violation
54 only when nothing on the same code line marks it as a check / negation
55 (``DEFENSIVE_RE``). The defensive cues are word-boundaried, so a snake_case
56 or camelCase identifier that merely contains "never" / "verify" does NOT
57 earn the exemption -- a function that really does the transition is still a
58 violation regardless of what it is named.
59
60Documentation files (``.md`` / ``.txt``) are prose, not executed, so every
61match in them is treated as SOFT: a doc that WARNS against the brick stays
62clean, while a doc that advocates running it does not.
63
64Run::
65
66 check_no_antirecovery.py # scan the whole tracked tree
67 check_no_antirecovery.py FILE ... # scan listed files
68 check_no_antirecovery.py --selftest # prove the detector fires on brick
69 # actions and stays silent on the real
70 # recovery scripts + defensive checks
71
72Exit 0 when no anti-recovery action is found, 1 otherwise (or on a failing
73selftest, or on an empty/unreadable scan set).
74"""
75
76from __future__ import annotations
77
78import argparse
79import re
80import subprocess
81import sys
82import tempfile
83from pathlib import Path
84
85REPO_ROOT = Path(__file__).resolve().parents[2]
86
87# First-party scope: everything git tracks, minus vendored / generated / binary
88# reference trees and the recovery scripts, which are pro-recovery by design.
89EXCLUDED_PREFIXES: tuple[str, ...] = (
90 "libs/third_party/",
91 "apps/shared_libs/third_party/",
92 "libs/ra8_fonts/",
93 "docs/reference/",
94)
95
96# The recovery scripts READ/CHECK ce and WARN on failure -- the opposite of the
97# forbidden action. Excluded by path; --selftest additionally proves the
98# patterns do not fire on their content.
99RECOVERY_SCRIPTS: frozenset[str] = frozenset(
100 {
101 "scripts/hil/dlm_reset.sh",
102 "scripts/hil/dlm_reset_local.sh",
103 "scripts/hil/recover.sh",
104 "scripts/hil/reflash.sh",
105 }
106)
107
108# This checker must spell every forbidden pattern (in the regexes and in the
109# selftest fixtures) to describe them, so it exempts itself -- exactly as
110# check_no_wave_references.py exempts itself and the policy doc.
111SELF_PATH = "scripts/checks/check_no_antirecovery.py"
112
113EXCLUDED_FILES: frozenset[str] = RECOVERY_SCRIPTS | {SELF_PATH}
114
115# Comment style per language. "hash": # to EOL. "c": /* */ blocks and // to EOL.
116# "xml": <!-- --> blocks. "prose": no comment stripping, and every match is
117# treated as SOFT (docs are described, not executed). "none": no comment style.
118HASH_EXTS: frozenset[str] = frozenset(
119 {
120 ".sh",
121 ".bash",
122 ".py",
123 ".yml",
124 ".yaml",
125 ".cmake",
126 ".toml",
127 ".cfg",
128 ".conf",
129 ".ini",
130 ".mk",
131 ".just",
132 }
133)
134C_EXTS: frozenset[str] = frozenset({".c", ".h", ".cpp", ".hpp", ".cc", ".hh", ".cxx", ".ld"})
135XML_EXTS: frozenset[str] = frozenset({".xml", ".html", ".svg", ".rpj"})
136PROSE_EXTS: frozenset[str] = frozenset({".md", ".markdown", ".txt", ".rst"})
137NONE_EXTS: frozenset[str] = frozenset({".json"})
138
139HASH_BASENAMES: frozenset[str] = frozenset({"justfile", "Justfile", "CMakeLists.txt", "Dockerfile"})
140
141# Extensionless basenames that are still shell/text worth scanning (the git
142# hooks: scripts/git/pre-commit, pre-push).
143HASH_STEM_HINTS: frozenset[str] = frozenset({"pre-commit", "pre-push"})
144
145# Extension -> comment/prose class, assembled once.
146_EXT_LANG: dict[str, str] = {
147 **dict.fromkeys(HASH_EXTS, "hash"),
148 **dict.fromkeys(C_EXTS, "c"),
149 **dict.fromkeys(XML_EXTS, "xml"),
150 **dict.fromkeys(PROSE_EXTS, "prose"),
151 **dict.fromkeys(NONE_EXTS, "none"),
152}
153
154# --- Forbidden ACTION patterns -----------------------------------------------
155#
156# Each entry: (rule, compiled regex, hard). `hard` patterns fire on any
157# non-comment occurrence. Non-`hard` (SOFT) patterns are exempted when the same
158# code line carries a DEFENSIVE_RE cue.
159
160# A terminal DLM lock. Only LCK_BOOT is known to render the SWD-DP permanently
161# unresponsive on the RA8D2; the recoverable OEM_PL0/PL1 debug-locks are
162# deliberately NOT here. Keep this a named token so a future terminal state is a
163# one-line addition.
164_TERMINAL = r"lck[_-]?boot"
165
166# A single `=` assignment operator, excluding the comparison operators (==, !=,
167# <=, >=). Used so `x = LCK_BOOT` (an action) fires while `x == LCK_BOOT` (a
168# check) does not.
169_ASSIGN = r"(?<![=!<>])=(?!=)"
170
171# The truthy right-hand sides that set a flag.
172_TRUE = r"(?:1\b|true\b|on\b|yes\b|set\b|enabled?\b)"
173
174# A set/transition verb. The lookarounds treat `_`, whitespace and punctuation
175# as boundaries but NOT letters, so the verb matches as an underscore-delimited
176# identifier component too -- `dlm_program(...)` and `never_regress_to_...` both
177# expose their verb -- while `reset`, `offset`, `clock`, `remove` do not.
178_SET_VERB = r"set|enable|program|transition|move|write|lock|enter|switch|regress|activate|go\s*to"
179_VERB = rf"(?<![A-Za-z])(?:{_SET_VERB})(?![A-Za-z])"
180
181RULES: list[tuple[str, re.Pattern[str], bool]] = [
182 # -- HARD: executed / programmatic ce ("Disable Initialize") set ----------
183 ("ce-set", re.compile(r"\bset[_ -]ce\b", re.IGNORECASE), True),
184 ("ce-set", re.compile(rf"\bce\b\s*(?:{_ASSIGN}|:)\s*{_TRUE}", re.IGNORECASE), True),
185 (
186 "ce-set",
187 re.compile(
188 r"(?:set[_ -]security[_ -]flags?|--?security[_ -]flags?[= ])[^\n]{0,24}\bce\b",
189 re.IGNORECASE,
190 ),
191 True,
192 ),
193 ("ce-set", re.compile(r"--disable[_-]initialize\b", re.IGNORECASE), True),
194 (
195 "ce-set",
196 re.compile(rf"\bdisable[_-]initialize\b\s*(?:{_ASSIGN}|:)\s*{_TRUE}", re.IGNORECASE),
197 True,
198 ),
199 # -- HARD: executed terminal DLM transition (rfp-cli -dlm LCK_BOOT) --------
200 ("dlm-terminal", re.compile(rf"--?dlm[= ]+['\"]?\s*{_TERMINAL}\b", re.IGNORECASE), True),
201 # -- SOFT: terminal-state / flag NAME governed by a set-verb (guarded) -----
202 (
203 "ce-set",
204 re.compile(rf"{_VERB}[^\n]{{0,24}}\bdisable[ _-]?initialize\b", re.IGNORECASE),
205 False,
206 ),
207 (
208 "dlm-terminal",
209 re.compile(rf"{_VERB}[^\n]{{0,24}}{_TERMINAL}\b", re.IGNORECASE),
210 False,
211 ),
212 # -- SOFT: assignment target is the terminal state (`dlm = LCK_BOOT`) ------
213 (
214 "dlm-terminal",
215 re.compile(rf"{_ASSIGN}\s*[^\n;{{}}]{{0,24}}?{_TERMINAL}\b", re.IGNORECASE),
216 False,
217 ),
218]
219
220# A code line that CHECKS / NEGATES / WARNS / COMPARES rather than acts. The
221# word cues are word-boundaried so that an identifier merely containing one of
222# them (e.g. `never_regress_to_lck_boot`) does NOT earn the exemption -- a
223# function that does the transition is a violation whatever it is named. The
224# comparison operators keep a state check (`if (dlm == LCK_BOOT)`) from reading
225# as an action. Guards SOFT rules only (and, for prose files, HARD rules too).
226DEFENSIVE_RE = re.compile(
227 r"==|!=|"
228 r"\b(?:unset|must\s+not|must\s+never|may\s+be\s+set|may\s+have\s+been\s+set|"
229 r"not\s+have\s+been\s+set|not\s+be\s+set|do\s+not|don't|never|should\s+not|"
230 r"shall\s+not|verify|verifies|ensure|is\s+set|was\s+set|been\s+set)\b",
231 re.IGNORECASE,
232)
233
234MAX_FINDINGS_SHOWN = 50
235SNIPPET_MAX_LEN = 120
236
237
238def lang_of(rel: str) -> str:
239 """Classify a tracked path's comment style / prose-ness for scanning.
240
241 Returns one of "hash", "c", "xml", "prose", "none", or "skip" (binary /
242 out-of-scope). Extension wins; a few known extensionless basenames (the git
243 hooks) map to "hash".
244 """
245 p = Path(rel)
246 lang = _EXT_LANG.get(p.suffix.lower())
247 if lang is not None:
248 return lang
249 if p.name in HASH_BASENAMES:
250 return "hash"
251 if p.suffix == "" and p.name in HASH_STEM_HINTS:
252 return "hash"
253 return "skip"
254
255
256def _blank_hash_line(line: str) -> str:
257 """Blank a shell/python/yaml ``#`` comment, respecting quotes.
258
259 A ``#`` starts a comment only outside quotes and at a word boundary (line
260 start after whitespace, or preceded by whitespace) -- so ``${x#y}`` and
261 ``http://a#b`` are left intact while ``foo # note`` is trimmed.
262 """
263 in_s = in_d = False
264 i, n = 0, len(line)
265 while i < n:
266 c = line[i]
267 if in_s:
268 if c == "'":
269 in_s = False
270 elif in_d:
271 if c == "\\":
272 i += 2
273 continue
274 if c == '"':
275 in_d = False
276 elif c == "'":
277 in_s = True
278 elif c == '"':
279 in_d = True
280 elif c == "#" and (i == 0 or line[i - 1].isspace()):
281 return line[:i] + " " * (n - i)
282 i += 1
283 return line
284
285
286def _blank_c_line(line: str) -> str:
287 """Blank a C ``//`` line comment, respecting quotes."""
288 in_s = in_d = False
289 i, n = 0, len(line)
290 while i < n:
291 c = line[i]
292 if in_s:
293 if c == "\\":
294 i += 2
295 continue
296 if c == "'":
297 in_s = False
298 elif in_d:
299 if c == "\\":
300 i += 2
301 continue
302 if c == '"':
303 in_d = False
304 elif c == "'":
305 in_s = True
306 elif c == '"':
307 in_d = True
308 elif c == "/" and i + 1 < n and line[i + 1] == "/":
309 return line[:i] + " " * (n - i)
310 i += 1
311 return line
312
313
314def _blank_blocks(text: str, open_tok: str, close_tok: str) -> str:
315 """Blank ``open_tok ... close_tok`` spans, preserving newlines and offsets."""
316 out: list[str] = []
317 i, n = 0, len(text)
318 while i < n:
319 start = text.find(open_tok, i)
320 if start < 0:
321 out.append(text[i:])
322 break
323 out.append(text[i:start])
324 end = text.find(close_tok, start + len(open_tok))
325 end = n if end < 0 else end + len(close_tok)
326 out.append(re.sub(r"[^\n]", " ", text[start:end]))
327 i = end
328 return "".join(out)
329
330
331def blank_comments(text: str, lang: str) -> str:
332 """Return `text` with comments blanked (strings preserved), for `lang`.
333
334 Strings are deliberately kept so a quoted CLI argument like
335 ``-dlm "LCK_BOOT"`` is still seen. Prose files are returned unchanged --
336 every match in them is treated as SOFT by the caller.
337 """
338 if lang == "c":
339 text = _blank_blocks(text, "/*", "*/")
340 return "\n".join(_blank_c_line(ln) for ln in text.split("\n"))
341 if lang == "hash":
342 return "\n".join(_blank_hash_line(ln) for ln in text.split("\n"))
343 if lang == "xml":
344 return _blank_blocks(text, "<!--", "-->")
345 return text
346
347
348def scan_text(raw: str, rel: str, lang: str) -> list[dict]:
349 """Find forbidden anti-recovery ACTIONS in one file's text.
350
351 `lang == "prose"` forces every rule to be treated as SOFT (documentation is
352 described, not executed), so a doc that warns against the brick stays clean.
353 """
354 code = blank_comments(raw, lang)
355 prose = lang == "prose"
356 found: list[dict] = []
357 for lineno, line in enumerate(code.split("\n"), start=1):
358 defended = DEFENSIVE_RE.search(line) is not None
359 for rule, pattern, hard in RULES:
360 m = pattern.search(line)
361 if not m:
362 continue
363 if (not hard or prose) and defended:
364 continue
365 found.append(
366 {
367 "path": rel,
368 "line": lineno,
369 "rule": rule,
370 "match": m.group(0).strip(),
371 "text": line.strip(),
372 }
373 )
374 break
375 return found
376
377
378def tracked_files(explicit: list[str]) -> list[Path]:
379 """Enumerate the tracked first-party files in scope.
380
381 Tracked via ``git ls-files``, not globbed: a glob also sweeps build output
382 and changes the verdict depending on whether the caller has built. Excluded
383 prefixes, the recovery scripts, and this checker are dropped here.
384 """
385 if explicit:
386 return [Path(p) for p in explicit]
387 try:
388 listed = subprocess.run(
389 ["git", "ls-files", "-z"], # noqa: S607 # trusted: fixed git argv
390 capture_output=True,
391 text=True,
392 check=True,
393 cwd=REPO_ROOT,
394 ).stdout
395 except (OSError, subprocess.CalledProcessError) as exc:
396 sys.exit(
397 f"check_no_antirecovery.py: FATAL -- cannot list tracked files: {exc}\n"
398 " This gate enumerates via git and must not fall back to a glob."
399 )
400 out: list[Path] = []
401 for name in listed.split("\0"):
402 if not name:
403 continue
404 if name in EXCLUDED_FILES:
405 continue
406 if any(name.startswith(pfx) for pfx in EXCLUDED_PREFIXES):
407 continue
408 out.append(REPO_ROOT / name)
409 return out
410
411
412def analyse(files: list[Path]) -> list[dict]:
413 """Return every forbidden-action finding across the given files."""
414 findings: list[dict] = []
415 for path in files:
416 rel = str(path.relative_to(REPO_ROOT)) if path.is_absolute() else str(path)
417 lang = lang_of(rel)
418 if lang == "skip":
419 continue
420 try:
421 raw = path.read_text(encoding="utf-8")
422 except (OSError, UnicodeDecodeError):
423 continue
424 findings.extend(scan_text(raw, rel, lang))
425 return findings
426
427
428# --- selftest ----------------------------------------------------------------
429# (fixture, filename, should_fire). Filenames carry a real suffix so lang_of
430# classifies them the way the corresponding production file would be.
431SELFTEST_CASES: list[tuple[str, str, bool]] = [
432 # ---- must FIRE: brick actions -------------------------------------------
433 (
434 "rfp-cli -d ra -t jlink:$SN -if swd -s 1000000 -dlm LCK_BOOT\n",
435 "brick_dlm.sh",
436 True,
437 ),
438 (
439 "# the ce flag disables Initialize -- do the deed below\nset ce\n",
440 "brick_set_ce.sh",
441 True,
442 ),
443 (
444 'rfp-cli -d ra -t "jlink:$SN" -if swd -set-security-flag ce\n',
445 "brick_secflag.sh",
446 True,
447 ),
448 (
449 "disable_initialize = true\n",
450 "brick_config.toml",
451 True,
452 ),
453 (
454 "rfp-cli -d ra --disable-initialize\n",
455 "brick_cli_flag.sh",
456 True,
457 ),
458 (
459 " dlm_program(k_dlm_lck_boot);\n",
460 "brick_program.c",
461 True,
462 ),
463 (
464 "ra8_security.ce = true;\n",
465 "brick_field.c",
466 True,
467 ),
468 (
469 'rfp-cli -d ra -t jlink -dlm "LCK_BOOT"\n',
470 "brick_dlm_quoted.sh",
471 True,
472 ),
473 (
474 "static void never_regress_to_lck_boot(void) { program_dlm(k_dlm_lck_boot); }\n",
475 "brick_misnamed.c",
476 True,
477 ),
478 # ---- must STAY SILENT: defensive / recoverable / prose ------------------
479 (
480 "# verify the ce security flag is unset before any OEM_PLx transition\n"
481 'warn "ce flag may be set -- recovery would then fail"\n'
482 "# the ce flag must NOT have been set\n"
483 "# do NOT run: rfp-cli -dlm LCK_BOOT (that permanently bricks the board)\n"
484 'echo "policy: never regress the DLM to LCK_BOOT on this project"\n'
485 'if ! rfp-cli -rfo | grep -q "DLM State: OEM_PL2"; then echo bad; fi\n',
486 "defensive_check.sh",
487 False,
488 ),
489 (
490 "rfp-cli -d ra -t jlink -dlm OEM_PL1\n",
491 "recoverable_dlm.sh",
492 False,
493 ),
494 (
495 "This project must NEVER run `rfp-cli -dlm LCK_BOOT`; recovery relies on\n"
496 "the ce flag staying unset so Initialize can regress OEM_PL0 -> OEM_PL2.\n",
497 "policy_doc.md",
498 False,
499 ),
500 (
501 'ok "Boundary cleared; DLM regressed OEM_PL0 -> OEM_PL2"\n',
502 "recovered_ok.sh",
503 False,
504 ),
505]
506
507
508def selftest(tmp: Path) -> int:
509 """Assert the detector fires on brick actions and stays silent otherwise.
510
511 Runs BOTH directions -- synthetic brick files must be detected, and the
512 REAL recovery scripts plus a defensive-check fixture must produce nothing.
513 The recovery scripts are scanned by CONTENT here (bypassing the path
514 exclusion) so the assertion proves the PATTERNS keep them silent, not
515 merely that they are excluded.
516 """
517 failures: list[str] = []
518
519 for idx, (body, fname, should_fire) in enumerate(SELFTEST_CASES):
520 p = tmp / f"{idx:02d}_{fname}"
521 p.write_text(body, encoding="utf-8")
522 fired = bool(scan_text(body, fname, lang_of(fname)))
523 if fired != should_fire:
524 verb = "did not fire" if should_fire else "fired"
525 failures.append(f" synthetic [{fname}]: gate {verb} (unexpected)")
526
527 # The real recovery scripts, by content, must be silent on the PATTERNS.
528 for rel in sorted(RECOVERY_SCRIPTS):
529 f = REPO_ROOT / rel
530 if not f.is_file():
531 failures.append(f" recovery script missing: {rel}")
532 continue
533 hits = scan_text(f.read_text(encoding="utf-8"), rel, lang_of(rel))
534 failures.extend(f" recovery script {rel}:{h['line']} matched {h['match']!r}" for h in hits)
535
536 if failures:
537 print("check_no_antirecovery.py --selftest: FAILED\n", file=sys.stderr)
538 print("\n".join(failures), file=sys.stderr)
539 return 1
540
541 fires = sum(1 for c in SELFTEST_CASES if c[2])
542 total = len(SELFTEST_CASES)
543 print(
544 f"check_no_antirecovery.py --selftest: PASS "
545 f"({total} synthetic cases: {fires} must fire, {total - fires} must stay silent; "
546 f"{len(RECOVERY_SCRIPTS)} real recovery scripts silent by pattern)"
547 )
548 return 0
549
550
551def _report(findings: list[dict]) -> None:
552 """List every forbidden action found, then explain the policy."""
553 print(
554 f"check_no_antirecovery.py: {len(findings)} anti-recovery action(s) found:\n",
555 file=sys.stderr,
556 )
557 for f in sorted(findings, key=lambda v: (v["path"], v["line"]))[:MAX_FINDINGS_SHOWN]:
558 snippet = f["text"]
559 if len(snippet) > SNIPPET_MAX_LEN:
560 snippet = snippet[: SNIPPET_MAX_LEN - 3] + "..."
561 print(f" [{f['rule']}] {f['path']}:{f['line']} {snippet}", file=sys.stderr)
562 if len(findings) > MAX_FINDINGS_SHOWN:
563 print(f" ... {len(findings) - MAX_FINDINGS_SHOWN} more (truncated)", file=sys.stderr)
564 print(
565 "\nOwner policy (2026-07-23): this project must NEVER permanently disable\n"
566 "device recovery. Forbidden actions:\n"
567 " [ce-set] setting the ce Security Flag / Disable Initialize Command\n"
568 " (rfp-cli -set-security-flag ce, set ce, ce = true,\n"
569 " --disable-initialize, an OSIS / option-byte write).\n"
570 " [dlm-terminal] transitioning the DLM to LCK_BOOT (or any lock that\n"
571 " renders the SWD-DP permanently unresponsive).\n"
572 "\nRecoverable OEM_PL0/PL1 debug-lock -- which Initialize or an\n"
573 "authenticated regression can undo -- is allowed, as is reading/checking\n"
574 "state (rfp-cli -rfo) and warning about the danger. If you are writing a\n"
575 "recovery/check path, phrase it as a check (it is then not an action), or\n"
576 "put it in scripts/hil/{dlm_reset,dlm_reset_local,recover,reflash}.sh.",
577 file=sys.stderr,
578 )
579
580
581def main(argv: list[str]) -> int:
582 """Scan first-party files for anti-recovery brick actions, or run the selftest.
583
584 CI runs ``--selftest`` before the scan: a detector whose patterns stopped
585 matching would report a clean tree, and only an assertion in both directions
586 tells that apart from a genuinely clean tree.
587
588 Returns 0 when no forbidden action is found, 1 on a finding, on an empty
589 scan set, or on a failing selftest.
590 """
591 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
592 ap.add_argument("files", nargs="*", help="specific files to scan")
593 ap.add_argument(
594 "--selftest",
595 action="store_true",
596 help="prove the detector fires on brick actions and not on recovery/defensive code",
597 )
598 args = ap.parse_args(argv[1:])
599
600 if args.selftest:
601 with tempfile.TemporaryDirectory() as td:
602 return selftest(Path(td))
603
604 files = tracked_files(args.files)
605 if not files:
606 print(
607 "check_no_antirecovery.py: FATAL -- no tracked files in scope.\n"
608 " Run this from the repository root.",
609 file=sys.stderr,
610 )
611 return 1
612
613 findings = analyse(files)
614 if not findings:
615 print(
616 f"check_no_antirecovery.py: OK ({len(files)} files scanned, no anti-recovery actions)"
617 )
618 return 0
619
620 _report(findings)
621 return 1
622
623
624if __name__ == "__main__":
625 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298