ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_errexit_masking.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 shell function is called with its errexit masked.
5
6The defect class
7----------------
8Under ``set -e``, calling a function on the left of ``||`` puts it into bash's
9inherited "ignoring errors" state::
10
11 run_suite "$fast" || rc=$?
12
13Two things then go wrong. A failure part-way through the function body no
14longer aborts it, so every remaining statement still runs and the caller sees
15only the *last* command's status. Worse, that state propagates into nested
16subshells where a plain ``set -e`` cannot clear it -- ``$-`` reports ``e`` set
17while a failing command still does not abort. This tree shipped exactly that:
18the gate suite silently degraded to "did each gate's LAST command succeed", so
19a gate failing part-way -- including ``require_cmd`` reporting an absent tool
20-- reported PASS.
21
22The remedy, already documented at the ``run_suite`` call site in
23``scripts/ci.sh``, is to disable errexit around the CALL only::
24
25 set +e
26 run_suite "$fast"
27 rc=$?
28 set -e
29
30The callee then runs in a normal errexit context, so its own ``( set -e; ... )``
31subshells re-arm and a mid-body failure is not swallowed.
32
33Why this check and not ShellCheck's SC2310
34------------------------------------------
35``check-set-e-suppressed`` covers this defect but is unsatisfiable here: it
36fires on any function in a condition, including a bare one-line predicate and
37the rewrite its own help text recommends. Measured on this tree it produces 90
38findings, and the only two source forms it accepts are worse than what it
39rejects -- ``set +e; fn; rc=$?; set -e`` (which it passes) and a bare subshell
40(which aborts the parent). Adopting it would mean ~90 inline disables rather
41than ~90 fixes. See #363 for the full form-by-form evidence.
42
43This check keeps the signal and drops the noise: it fires only where a
44first-party function whose body has more than one command is invoked with its
45status masked. A one-command predicate is exempt by construction -- there is
46no statement after the failure for errexit to have protected.
47
48Run::
49
50 check_errexit_masking.py # gate (fail on any finding)
51 check_errexit_masking.py --selftest # prove it fires AND stays quiet
52
53Exit 0 if clean, exit 1 on findings, exit 2 on an internal error.
54"""
55
56from __future__ import annotations
57
58import re
59import sys
60import tempfile
61from pathlib import Path
62
63REPO_ROOT = Path(__file__).resolve().parents[2]
64sys.path.insert(0, str(Path(__file__).resolve().parent))
65
66# A function body with a single command cannot hide a mid-body failure: the
67# status the caller captures IS that command's status. Two or more is where
68# the defect becomes possible, so that is where the check starts firing.
69MIN_BODY_COMMANDS = 2
70
71# The bad fixture masks exactly three calls to the multi-command `work`.
72# Pinned as a constant so the selftest asserts a number rather than a
73# literal that could be edited to match whatever the checker happens to do.
74SELFTEST_EXPECTED_HITS = 3
75
76_DEF_RE = re.compile(r"^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\‍(\‍)\s*[({]")
77
78# `fn ... || true`, `|| :`, `|| rc=$?` -- the forms that swallow the status.
79# The callee must sit at the start of a command position: line start, or after
80# a separator, a `case` pattern's `)`, or a `then`/`do`/`else` keyword.
81_MASK_RE = re.compile(
82 r"(?:^|[;&|()]|\bthen\b|\bdo\b|\belse\b)\s*"
83 r"([A-Za-z_][A-Za-z0-9_]*)"
84 r"(?:\s+[^|;&]*?)?"
85 r"\|\|\s*(?:true\b|:\s|:$|[A-Za-z_][A-Za-z0-9_]*=\$\?)"
86)
87
88
89def _strip(line: str) -> str:
90 """Drop comments and neutralise quoted spans, preserving column count."""
91 out: list[str] = []
92 quote: str | None = None
93 for index, char in enumerate(line):
94 if quote is not None:
95 out.append(char)
96 if char == quote:
97 quote = None
98 elif char in "\"'":
99 quote = char
100 out.append(char)
101 elif char == "#" and (index == 0 or line[index - 1].isspace()):
102 break
103 else:
104 out.append(char)
105 return "".join(out)
106
107
108# Control keywords. A compound block counts as its WORST branch, never the sum,
109# and a condition counts zero: a non-zero status in a condition is control flow,
110# not a failure errexit was ever going to catch.
111_BLOCK_OPEN = frozenset({"if", "while", "until", "for", "case"})
112_BODY_START = frozenset({"then", "do", "in"})
113_BLOCK_CLOSE = frozenset({"fi", "done", "esac"})
114_KEYWORD_RE = re.compile(r"^(if|elif|while|until|for|case|then|do|else|fi|done|esac|\{|\})\b\s*")
115
116
117def _tokens(body: str) -> list[str]:
118 r"""Split a function body into statements at separator positions.
119
120 Newlines and `;` only separate at paren depth zero: a multi-line array
121 literal (`local -a args=(\n --hex ...\n)`) is ONE assignment, not one
122 command per element.
123 """
124 joined = re.sub(r"\\\n", " ", body)
125 depth = 0
126 flat: list[str] = []
127 for char in joined:
128 if char == "(":
129 depth += 1
130 elif char == ")":
131 depth = max(0, depth - 1)
132 if char in "\n;" and depth > 0:
133 flat.append(" ")
134 else:
135 flat.append(char)
136 out: list[str] = []
137 for raw in re.split(r"[\n;]", "".join(flat)):
138 piece = raw.strip()
139 while piece:
140 match = _KEYWORD_RE.match(piece)
141 if match is None:
142 break
143 out.append(match.group(1))
144 piece = piece[match.end() :].strip()
145 if piece:
146 out.append(piece)
147 return out
148
149
150# An assignment with no command substitution in it cannot fail, so it is not a
151# step errexit was ever protecting. Counting `local app="$1"` as a command is
152# what made an early revision of this check fire on functions whose only
153# failable statement was the last one -- the same false-positive problem that
154# makes SC2310 unusable.
155_ASSIGN_RE = re.compile(
156 r"^(?:(?:local|declare|readonly|export|typeset)\s+(?:-\w+\s+)*)?"
157 r"[A-Za-z_][A-Za-z0-9_]*(?:\‍[[^\‍]]*\‍])?\+?=|"
158 r"^(?:local|declare|readonly|export|typeset)\s+-?\w*\s*[A-Za-z_]"
159)
160_SUBST_RE = re.compile(r"\$\‍(|`")
161
162
163def _is_failable(token: str) -> bool:
164 """False for assignments that cannot fail -- no substitution, no exit status."""
165 if _SUBST_RE.search(token):
166 return True
167 return _ASSIGN_RE.match(token) is None
168
169
170def _max_sequential(body: str) -> int:
171 """Max commands executed sequentially on any ONE path through `body`.
172
173 This is the number that decides whether errexit had anything to protect:
174 with two or more commands in a row, a failure in the first still lets the
175 rest run and the caller sees only the last one's status.
176 """
177 stack: list[list[int]] = []
178 run = 0
179 skipping = False
180 for word in _tokens(body):
181 if word in _BLOCK_OPEN:
182 stack.append([0, run])
183 run = 0
184 skipping = True
185 elif word in _BODY_START:
186 skipping = False
187 elif word in ("elif", "else"):
188 if stack:
189 stack[-1][0] = max(stack[-1][0], run)
190 run = 0
191 skipping = word == "elif"
192 elif word in _BLOCK_CLOSE:
193 if stack:
194 best, saved = stack.pop()
195 run = saved + max(best, run)
196 skipping = False
197 elif word not in ("{", "}") and not skipping and _is_failable(word):
198 run += 1
199 while stack:
200 best, saved = stack.pop()
201 run = saved + max(best, run)
202 return run
203
204
205def _function_sizes(lines: list[str]) -> dict[str, int]:
206 """Map every function defined in `lines` to its max sequential command count."""
207 sizes: dict[str, int] = {}
208 index = 0
209 while index < len(lines):
210 match = _DEF_RE.match(lines[index])
211 if match is None:
212 index += 1
213 continue
214 end, body = _body_span(lines, index)
215 sizes[match.group(1)] = _max_sequential(body)
216 index = end + 1
217 return sizes
218
219
220def _body_span(lines: list[str], start: int) -> tuple[int, str]:
221 """Return (last line index, body text) for the function opening at `start`."""
222 depth = 0
223 opened = False
224 collected: list[str] = []
225 index = start
226 while index < len(lines):
227 stripped = _strip(lines[index])
228 for char in stripped:
229 if char in "{(":
230 depth += 1
231 opened = True
232 elif char in "})":
233 depth -= 1
234 collected.append(stripped.split("{", 1)[-1] if index == start else stripped)
235 if opened and depth <= 0:
236 return index, "\n".join(collected)
237 index += 1
238 return index - 1, "\n".join(collected)
239
240
241def _scan_file(rel: str) -> list[str]:
242 """Return one message per errexit-masked first-party call in `rel`."""
243 text = (REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace")
244 lines = text.splitlines()
245 sizes = _function_sizes(lines)
246 findings: list[str] = []
247 for number, line in enumerate(lines, 1):
248 for match in _MASK_RE.finditer(_strip(line)):
249 name = match.group(1)
250 size = sizes.get(name)
251 if size is None or size < MIN_BODY_COMMANDS:
252 continue
253 findings.append(
254 f"{rel}:{number}: `{name}` (~{size} commands) is invoked with its "
255 f"exit status masked; a failure part-way through its body is "
256 f"silently swallowed.\n"
257 f" {line.strip()}\n"
258 f" Use: set +e; {name} ...; rc=$?; set -e"
259 )
260 return findings
261
262
263def _targets() -> list[str]:
264 """First-party shell scripts -- the same scope `check_shell.py` gates.
265
266 Imported rather than re-derived: a second copy of the scope list is how a
267 file ends up covered by one shell gate and invisible to the other.
268 """
269 import check_shell # noqa: PLC0415 -- needs the sys.path insert above
270
271 return check_shell.first_party_scripts()
272
273
274_SELFTEST_BAD = """#!/usr/bin/env bash
275set -euo pipefail
276work() {
277 cp /a /b
278 rm /c
279}
280predicate() { [ -e /tmp ]; }
281rc=0
282work || rc=$?
283case "$1" in
284 go) work "$@" || rc=$? ;;
285esac
286out="$(work || true)"
287predicate || rc=$?
288if predicate; then echo yes; fi
289grep -q x /etc/hosts || rc=$?
290"""
291
292_SELFTEST_GOOD = """#!/usr/bin/env bash
293set -euo pipefail
294work() {
295 cp /a /b
296 rm /c
297}
298predicate() { [ -e /tmp ]; }
299set +e
300work
301rc=$?
302set -e
303if predicate; then echo yes; fi
304grep -q x /etc/hosts || rc=$?
305external_tool --flag || true
306"""
307
308
309def _selftest() -> int:
310 """Assert the check fires on the defect AND stays silent on the remedy."""
311 failures: list[str] = []
312 with tempfile.TemporaryDirectory(dir=REPO_ROOT) as tmp:
313 holder = Path(tmp)
314 bad = holder / "bad.sh"
315 good = holder / "good.sh"
316 bad.write_text(_SELFTEST_BAD, encoding="utf-8")
317 good.write_text(_SELFTEST_GOOD, encoding="utf-8")
318 bad_hits = _scan_file(str(bad.relative_to(REPO_ROOT)))
319 good_hits = _scan_file(str(good.relative_to(REPO_ROOT)))
320
321 # MUST-FIRE: the three masked calls on the multi-command `work`.
322 if len(bad_hits) != SELFTEST_EXPECTED_HITS:
323 failures.append(
324 f"must-fire: expected {SELFTEST_EXPECTED_HITS} findings on the bad "
325 f"fixture, got {len(bad_hits)}"
326 )
327 if any("predicate" in hit for hit in bad_hits):
328 failures.append("must-fire: flagged the one-command predicate, which is exempt")
329 if any("grep" in hit for hit in bad_hits):
330 failures.append("must-fire: flagged an external command, which is out of scope")
331
332 # MUST-BE-SILENT: the documented `set +e` remedy and the exempt forms.
333 if good_hits:
334 failures.append(f"must-be-silent: {len(good_hits)} finding(s) on the good fixture")
335
336 for line in failures:
337 sys.stderr.write(f"check_errexit_masking.py: SELFTEST FAIL -- {line}\n")
338 if failures:
339 return 1
340 sys.stdout.write(
341 f"check_errexit_masking.py: selftest OK "
342 f"(fires on {len(bad_hits)} masked calls, silent on the remedy)\n"
343 )
344 return 0
345
346
347def main() -> int:
348 """Entry point: `--selftest` proves non-vacuity, otherwise gate the tree."""
349 if "--selftest" in sys.argv[1:]:
350 return _selftest()
351 findings: list[str] = []
352 for rel in _targets():
353 findings.extend(_scan_file(rel))
354 if findings:
355 sys.stderr.write("check_errexit_masking.py: errexit-masked first-party call(s):\n\n")
356 for finding in findings:
357 sys.stderr.write(f" {finding}\n\n")
358 sys.stderr.write(
359 f"{len(findings)} finding(s). Disabling errexit around the CALL keeps the\n"
360 "callee in a normal errexit context; `||` does not, and the state it sets\n"
361 "propagates into nested subshells that `set -e` cannot rescue.\n"
362 )
363 return 1
364 return 0
365
366
367if __name__ == "__main__":
368 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298