ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
annot_rules.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""One function per annotation rule, dispatched by rule key.
4
5This was a single 450-line ``enforce_rules()`` carrying an ``elif`` chain of
6nineteen rules and a ``noqa`` asserting that splitting it "reduces clarity".
7It did the opposite: the chain shared one mutable ``out`` list, so a rule's
8effect was only readable by tracking every append across the whole body -- and
9the informational-rule marker at the bottom reached back into ``out[-1]``,
10which silently belongs to whichever rule appended last rather than to the rule
11being processed.
12
13Each rule is now a function of ``(symbol, argument, context)`` returning its
14own findings, and :data:`RULE_CHECKS` maps the key to the function. Adding a
15rule is adding an entry; a rule's whole behaviour is one screen. The keys are
16cross-checked against what ``ra8_attributes.h`` emits by
17:func:`annot_rulekeys.check_rule_keys`, so an entry keyed on a spelling no
18macro produces is a failure rather than a rule that quietly never matches.
19"""
20
21from __future__ import annotations
22
23import contextlib
24import pathlib
25import re
26from collections.abc import Callable
27from dataclasses import dataclass
28
29from annot_linkage import enforce_linkage
30from annot_model import AnnotatedSymbol, CallSite, Violation, WalkState
31from annot_rulekeys import INFORMATIONAL_RULES, parse_annotation
32from annot_scope import is_host_only_path, is_test_path, module_of, repo_root
33from annot_source import definition_text
34
35#: Upper bound on the call-graph BFS used for ra8_no_recursion checking.
36#: Prevents infinite loops on pathological call graphs during static analysis.
37RECURSION_GUARD_LIMIT = 1000
38
39#: The two ways a veneer legitimately validates an NS address range.
40_NSC_RANGE_CHECK_RE = re.compile(r"RA8_NSC_CHECK_NS_RANGE_(?:R|RW)\b|cmse_check_address_range\b")
41
42#: Allocation entry points NASA P10 Rule 3 forbids in firmware.
43_ALLOCATORS = frozenset({"malloc", "free", "calloc", "realloc", "aligned_alloc"})
44
45#: Where an NSC veneer must be defined.
46_NSC_SRC_DIR = "/libs/ra8_nsc/src/"
47
48
49@dataclass
50class RuleCtx:
51 """Everything a rule needs beyond the symbol it is judging.
52
53 Both call indexes are keyed by USR. A name key would merge every module's
54 namesake ``static`` helper into one bucket and enforce one module's
55 annotation against another module's callers -- the defect the symbol table
56 itself was rekeyed to end.
57 """
58
59 symbols: dict[str, AnnotatedSymbol]
60 calls_by_callee: dict[str, list[CallSite]]
61 calls_by_caller: dict[str, list[CallSite]]
62 address_taken: set[str]
63
64 @classmethod
65 def build(cls, symbols: dict[str, AnnotatedSymbol], calls: list[CallSite]) -> RuleCtx:
66 """Index ``calls`` by callee and by caller for constant-time lookup."""
67 by_callee: dict[str, list[CallSite]] = {}
68 by_caller: dict[str, list[CallSite]] = {}
69 for cs in calls:
70 if not cs.in_address_of:
71 by_callee.setdefault(cs.callee_usr, []).append(cs)
72 by_caller.setdefault(cs.caller_usr, []).append(cs)
73 return cls(
74 symbols=symbols,
75 calls_by_callee=by_callee,
76 calls_by_caller=by_caller,
77 address_taken={cs.callee_usr for cs in calls if cs.in_address_of},
78 )
79
80 def callers_of(self, sym: AnnotatedSymbol) -> list[CallSite]:
81 """Every direct call site targeting ``sym``."""
82 return self.calls_by_callee.get(sym.usr, [])
83
84 def body_calls(self, sym: AnnotatedSymbol) -> list[CallSite]:
85 """Every call ``sym``'s own body makes."""
86 return self.calls_by_caller.get(sym.usr, [])
87
88
89def find_su_file(symbol: AnnotatedSymbol) -> int | None:
90 """Look for a matching .su entry under any examples/**/build*/ tree."""
91 examples = repo_root() / "examples"
92 if not examples.is_dir():
93 return None
94 pat = re.compile(r"\b" + re.escape(symbol.name) + r"\b\s+(\d+)\s+\w+")
95 for su in examples.rglob("*.su"):
96 with contextlib.suppress(OSError):
97 for line in su.read_text(errors="ignore").splitlines():
98 m = pat.search(line)
99 if m:
100 return int(m.group(1))
101 return None
102
103
104def _at(sym: AnnotatedSymbol, rule: str, message: str) -> Violation:
105 """A finding located at ``sym``'s own definition."""
106 return Violation(rule, sym.file, sym.line, message)
107
108
109def _at_call(cs: CallSite, rule: str, message: str) -> Violation:
110 """A finding located at a call site rather than at the callee."""
111 return Violation(rule, cs.caller_file, cs.caller_line, message)
112
113
114# --------------------------------------------------------------------------
115# Rule implementations. Signature: (symbol, annotation argument, context).
116# --------------------------------------------------------------------------
117def _rule_test_helper(sym: AnnotatedSymbol, _arg: str, ctx: RuleCtx) -> list[Violation]:
118 """RA8_TEST_HELPER: every caller must live under tests/."""
119 return [
120 _at_call(
121 cs,
122 "ra8_test_helper",
123 f"function '{sym.name}' tagged RA8_TEST_HELPER called from non-test context",
124 )
125 for cs in ctx.callers_of(sym)
126 if not is_test_path(cs.caller_file)
127 ]
128
129
130def _rule_internal(sym: AnnotatedSymbol, _arg: str, _ctx: RuleCtx) -> list[Violation]:
131 """RA8_INTERNAL: the definition must actually be static."""
132 if sym.is_static:
133 return []
134 return [
135 _at(
136 sym,
137 "ra8_internal",
138 f"function '{sym.name}' tagged RA8_INTERNAL is not declared static",
139 )
140 ]
141
142
143def _rule_priv(sym: AnnotatedSymbol, _arg: str, ctx: RuleCtx) -> list[Violation]:
144 """RA8_PRIV: callers must share the callee's libs/<module> or tools/<tool>."""
145 callee_mod = module_of(sym.file)
146 out: list[Violation] = []
147 if callee_mod is None:
148 return out
149 for cs in ctx.callers_of(sym):
150 # Host unit tests are a sanctioned consumer of a module's promoted
151 # internals: CLAUDE.md ("Test access to internal symbols") allows a
152 # helper to drop `static` and be declared in the module's _internal.h
153 # precisely so tests can reach the validation paths for MC/DC.
154 # Production callers in *other* modules remain violations.
155 if is_test_path(cs.caller_file):
156 continue
157 caller_mod = module_of(cs.caller_file)
158 if caller_mod != callee_mod:
159 out.append(
160 _at_call(
161 cs,
162 "ra8_priv",
163 f"function '{sym.name}' tagged RA8_PRIV ({callee_mod}) called "
164 f"from outside its module (caller={caller_mod or 'unknown'})",
165 )
166 )
167 return out
168
169
170def _rule_di_slot(sym: AnnotatedSymbol, _arg: str, ctx: RuleCtx) -> list[Violation]:
171 """RA8_DI_SLOT: must be reached through a function pointer, not called."""
172 direct = ctx.callers_of(sym)
173 if not direct or sym.usr in ctx.address_taken:
174 return []
175 cs = direct[0]
176 return [
177 Violation(
178 "ra8_di_slot",
179 cs.caller_file,
180 cs.caller_line,
181 f"function '{sym.name}' tagged RA8_DI_SLOT called directly; "
182 f"must be invoked via function pointer (DIP)",
183 warn_only=False,
184 )
185 ]
186
187
188def _veneer_location(sym: AnnotatedSymbol) -> list[Violation]:
189 """A veneer must be defined under libs/ra8_nsc/src/."""
190 where = sym.file.replace("\\", "/")
191 if _NSC_SRC_DIR in where:
192 return []
193 return [
194 _at(
195 sym,
196 "ra8_nsc_veneer",
197 f"NSC veneer '{sym.name}' must live under libs/ra8_nsc/src/ (found {where})",
198 )
199 ]
200
201
202def _veneer_range_check(sym: AnnotatedSymbol) -> list[Violation]:
203 """A veneer must range-check every address it accepts from Non-Secure.
204
205 Veneers that take no pointer parameter cross no address and need no check.
206
207 The check is a textual scan, not a call-graph lookup: the idiom is the
208 RA8_NSC_CHECK_NS_RANGE_R/_RW macro, which expands to
209 cmse_check_address_range() only under -mcmse. This script parses without
210 -mcmse, so the macro expands to a ((void)(p),(void)(n)) no-op and leaves
211 no CallExpr for libclang to find. The previous call-graph form looked for
212 a callee named "ra8_nsc_check_*" -- a spelling no veneer has ever used --
213 so it could only ever produce false positives.
214 """
215 if not sym.has_pointer_param or _NSC_RANGE_CHECK_RE.search(definition_text(sym)):
216 return []
217 return [
218 _at(
219 sym,
220 "ra8_nsc_veneer",
221 f"NSC veneer '{sym.name}' takes a pointer from NS but never "
222 f"range-checks it (expected RA8_NSC_CHECK_NS_RANGE_R/_RW or "
223 f"cmse_check_address_range)",
224 )
225 ]
226
227
228def _veneer_section(sym: AnnotatedSymbol) -> list[Violation]:
229 """A veneer must land in .gnu.sgstubs or be a cmse_nonsecure_entry."""
230 if not sym.section or ".gnu.sgstubs" in sym.section:
231 return []
232 if "cmse_nonsecure_entry" in " ".join(sym.annotations):
233 return []
234 return [
235 _at(
236 sym,
237 "ra8_nsc_veneer",
238 f"NSC veneer '{sym.name}' must live in .gnu.sgstubs section or be cmse_nonsecure_entry",
239 )
240 ]
241
242
243def _rule_nsc_veneer(sym: AnnotatedSymbol, _arg: str, _ctx: RuleCtx) -> list[Violation]:
244 """RA8_NSC_VENEER: location, NS range-checking, and section placement."""
245 return [*_veneer_location(sym), *_veneer_range_check(sym), *_veneer_section(sym)]
246
247
248def _rule_hw_register_access(sym: AnnotatedSymbol, _arg: str, _ctx: RuleCtx) -> list[Violation]:
249 """RA8_HW_REGISTER_ACCESS: an inline accessor returning a volatile pointer."""
250 # Caller-side RA8_PROTECTED_WRITE / CITES-OK checking is left to a future
251 # textual scan; libclang loses macro context that early.
252 out: list[Violation] = []
253 if not sym.has_inline:
254 out.append(_at(sym, "ra8_hw_register_access", f"MMIO accessor '{sym.name}' must be inline"))
255 if "volatile" not in sym.return_type:
256 out.append(
257 _at(
258 sym,
259 "ra8_hw_register_access",
260 f"MMIO accessor '{sym.name}' must return volatile* (got '{sym.return_type}')",
261 )
262 )
263 return out
264
265
266def _rule_mcdc_deactivated(sym: AnnotatedSymbol, arg: str, _ctx: RuleCtx) -> list[Violation]:
267 """RA8_MCDC_DEACTIVATED: the reason may not carry a file:line citation."""
268 if not re.search(r"\.[ch]:\d+", arg):
269 return []
270 return [
271 _at(
272 sym,
273 "ra8_mcdc_deactivated",
274 f"ra8_mcdc_deactivated reason on '{sym.name}' contains file:line "
275 f"citation -- use function name",
276 )
277 ]
278
279
280def _rule_max_stack(sym: AnnotatedSymbol, arg: str, _ctx: RuleCtx) -> list[Violation]:
281 """RA8_MAX_STACK: the measured .su frame must fit the declared budget."""
282 try:
283 annotated = int(arg)
284 except ValueError:
285 return [
286 _at(
287 sym,
288 "ra8_max_stack",
289 f"ra8_max_stack:'{arg}' on '{sym.name}' is not an integer byte count",
290 )
291 ]
292 measured = find_su_file(sym)
293 if measured is None or measured <= annotated:
294 return []
295 return [
296 _at(
297 sym,
298 "ra8_max_stack",
299 f"function '{sym.name}' frame {measured} B exceeds ra8_max_stack:{annotated}",
300 )
301 ]
302
303
304def _rule_expects_lock(sym: AnnotatedSymbol, arg: str, ctx: RuleCtx) -> list[Violation]:
305 """RA8_EXPECTS_LOCK: every caller must already hold the named lock.
306
307 There are exactly two ways to hold it and the tree has vocabulary for
308 both. A caller that ACQUIRES the lock for the length of its own body says
309 so with ``RA8_OWNS_RESOURCE(name)`` -- which :func:`_rule_owns_resource`
310 separately requires to reach a matching ``RA8_RELEASES_RESOURCE(name)``,
311 so the pair is what makes "held across this call" checkable rather than
312 asserted. A caller that was itself entered under the lock propagates the
313 contract upward by carrying ``RA8_EXPECTS_LOCK(name)`` too, exactly as the
314 macro documents.
315
316 This used to look for a call to ``RA8_TAKE_LOCK`` preceding the call site.
317 Nothing named that has ever existed in this tree -- not a function, not a
318 macro -- and callee names are resolved AFTER macro expansion, so no
319 spelling of it could have satisfied the rule. The annotation was
320 therefore unusable, and it had zero uses tree-wide: an unsatisfiable rule
321 is not a strict rule, it is a rule nobody can adopt.
322 """
323 holds = {f"ra8_owns_resource:{arg}", f"ra8_expects_lock:{arg}"}
324 out: list[Violation] = []
325 for cs in ctx.callers_of(sym):
326 caller = ctx.symbols.get(cs.caller_usr)
327 if caller is not None and holds.intersection(caller.annotations):
328 continue
329 out.append(
330 _at_call(
331 cs,
332 "ra8_expects_lock",
333 f"call to '{sym.name}' (expects lock '{arg}') from "
334 f"'{cs.caller_name}', which neither takes it "
335 f'(RA8_OWNS_RESOURCE("{arg}")) nor declares it already held '
336 f'(RA8_EXPECTS_LOCK("{arg}"))',
337 )
338 )
339 return out
340
341
342def _rule_host_friendly(sym: AnnotatedSymbol, _arg: str, ctx: RuleCtx) -> list[Violation]:
343 """RA8_HOST_FRIENDLY: must not reach an MMIO accessor."""
344 out: list[Violation] = []
345 for cs in ctx.body_calls(sym):
346 callee = ctx.symbols.get(cs.callee_usr)
347 if callee and any(
348 a.startswith(("ra8_hw_register_access", "RA8_HW_REGISTER_ACCESS"))
349 for a in callee.annotations
350 ):
351 out.append(
352 _at_call(
353 cs,
354 "ra8_host_friendly",
355 f"host-friendly '{sym.name}' calls MMIO accessor "
356 f"'{cs.callee_name}' (would break off-target)",
357 )
358 )
359 return out
360
361
362def _rule_no_recursion(sym: AnnotatedSymbol, _arg: str, ctx: RuleCtx) -> list[Violation]:
363 """RA8_NO_RECURSION: the transitive call closure must not include self."""
364 seen = {sym.usr}
365 stack = [sym.usr]
366 guard = 0
367 while stack and guard < RECURSION_GUARD_LIMIT:
368 guard += 1
369 for cs in ctx.calls_by_caller.get(stack.pop(), []):
370 if cs.in_address_of:
371 continue
372 if cs.callee_usr == sym.usr:
373 return [
374 _at(
375 sym,
376 "ra8_no_recursion",
377 f"function '{sym.name}' tagged RA8_NO_RECURSION appears "
378 f"in its own transitive call closure",
379 )
380 ]
381 if cs.callee_usr not in seen:
382 seen.add(cs.callee_usr)
383 stack.append(cs.callee_usr)
384 return []
385
386
387def _function_body_text(sym: AnnotatedSymbol) -> str:
388 """Return ``sym``'s brace-balanced body, or '' when it cannot be sliced.
389
390 A crude textual slice on purpose: libclang loses for/while bounds through
391 the macros this rule is about, so the check runs on source text.
392 """
393 try:
394 src = pathlib.Path(sym.file).read_text(errors="ignore")
395 except OSError:
396 return ""
397 m = re.search(re.escape(sym.name) + r"\s*\‍([^;]*\‍)\s*\{", src)
398 if not m:
399 return ""
400 start = m.end() - 1
401 depth = 0
402 for i in range(start, len(src)):
403 if src[i] == "{":
404 depth += 1
405 elif src[i] == "}":
406 depth -= 1
407 if depth == 0:
408 return src[start:i]
409 return ""
410
411
412def _loop_headers(body: str) -> list[str]:
413 """Return the full parenthesised header of every for/while in ``body``.
414
415 The paren run is matched by counting depth rather than by a
416 ``[^)]*`` regex. A condition that contains parentheses of its own --
417 ``(i < n) && (i < k_max)``, or a cast such as ``(uint32_t)k_max`` --
418 is extremely common, and a paren-blind slice truncates it at the first
419 inner ``)``. That made the bound symbol invisible whenever it sat after
420 one, so the rule reported a violation against a loop that was in fact
421 bounded exactly as the annotation claimed.
422 """
423 out: list[str] = []
424 for m in re.finditer(r"\b(?:for|while)\s*\‍(", body):
425 depth = 0
426 for i in range(m.end() - 1, len(body)):
427 if body[i] == "(":
428 depth += 1
429 elif body[i] == ")":
430 depth -= 1
431 if depth == 0:
432 out.append(body[m.end() : i])
433 break
434 return out
435
436
437def _rule_bounded_loop(sym: AnnotatedSymbol, arg: str, _ctx: RuleCtx) -> list[Violation]:
438 """RA8_BOUNDED_LOOP: every loop condition must name the bounding symbol."""
439 body = _function_body_text(sym)
440 if not body:
441 return []
442 return [
443 _at(
444 sym,
445 "ra8_bounded_loop",
446 f"loop in '{sym.name}' missing bound symbol '{arg}' in condition '{header.strip()}'",
447 )
448 for header in _loop_headers(body)
449 if arg not in header
450 ]
451
452
453def _rule_validates(sym: AnnotatedSymbol, arg: str, ctx: RuleCtx) -> list[Violation]:
454 """RA8_VALIDATES: at least N RA8_CHECK_* calls in the body."""
455 try:
456 need = int(arg)
457 except ValueError:
458 return []
459 count = sum(1 for c in ctx.body_calls(sym) if c.callee_name.startswith("RA8_CHECK_"))
460 if count >= need:
461 return []
462 return [
463 _at(
464 sym,
465 "ra8_validates",
466 f"function '{sym.name}' has {count} RA8_CHECK_* calls; "
467 f"ra8_validates:{need} requires at least {need}",
468 )
469 ]
470
471
472def _rule_owns_resource(sym: AnnotatedSymbol, arg: str, ctx: RuleCtx) -> list[Violation]:
473 """RA8_OWNS_RESOURCE: the body must reach a matching release."""
474 released = f"ra8_releases_resource:{arg}"
475 for c in ctx.body_calls(sym):
476 callee = ctx.symbols.get(c.callee_usr)
477 if callee and released in callee.annotations:
478 return []
479 return [
480 _at(
481 sym,
482 "ra8_owns_resource",
483 f"function '{sym.name}' acquires '{arg}' but no matching {released} call found",
484 )
485 ]
486
487
488def _rule_latency_budget(sym: AnnotatedSymbol, arg: str, _ctx: RuleCtx) -> list[Violation]:
489 """RA8_LATENCY_BUDGET_NS: recorded until a WCET pass exists."""
490 return [
491 _at(
492 sym,
493 "ra8_latency_budget_ns",
494 f"ra8_latency_budget_ns:{arg} on '{sym.name}' recorded; no WCET pass yet",
495 )
496 ]
497
498
499def _rule_reviewed_by(sym: AnnotatedSymbol, arg: str, _ctx: RuleCtx) -> list[Violation]:
500 """RA8_REVIEWED_BY: informational rollup into the safety report."""
501 return [_at(sym, "ra8_reviewed_by", f"reviewed-by '{arg}' recorded for '{sym.name}'")]
502
503
504def _rule_register_bank(sym: AnnotatedSymbol, arg: str, _ctx: RuleCtx) -> list[Violation]:
505 """RA8_REGISTER_BANK: informational grouping of MMIO accessors."""
506 return [_at(sym, "ra8_register_bank", f"register-bank '{arg}' recorded for '{sym.name}'")]
507
508
509#: Rule key -> the function that judges one symbol carrying it.
510#:
511#: Three keys are deliberately absent, and their absence is the statement:
512#: ``ra8_isr_safe`` and ``ra8_releases_resource`` are read by other rules
513#: rather than checked on their own, and ``ra8_nasa_rule_3_ok`` is a waiver
514#: consumed by the tree-wide allocation sweep below. Every key here and in
515#: ANNOTATION_PREFIXES is cross-checked against ra8_attributes.h on every run.
516RULE_CHECKS: dict[str, Callable[[AnnotatedSymbol, str, RuleCtx], list[Violation]]] = {
517 "ra8_test_helper": _rule_test_helper,
518 "ra8_internal": _rule_internal,
519 "ra8_priv": _rule_priv,
520 "ra8_di_slot": _rule_di_slot,
521 "ra8_nsc_veneer": _rule_nsc_veneer,
522 "ra8_hw_register_access": _rule_hw_register_access,
523 "ra8_mcdc_deactivated": _rule_mcdc_deactivated,
524 "ra8_max_stack": _rule_max_stack,
525 "ra8_expects_lock": _rule_expects_lock,
526 "ra8_host_friendly": _rule_host_friendly,
527 "ra8_no_recursion": _rule_no_recursion,
528 "ra8_bounded_loop": _rule_bounded_loop,
529 "ra8_validates": _rule_validates,
530 "ra8_owns_resource": _rule_owns_resource,
531 "ra8_latency_budget_ns": _rule_latency_budget,
532 "ra8_reviewed_by": _rule_reviewed_by,
533 "ra8_register_bank": _rule_register_bank,
534}
535
536
537def sweep_dynamic_allocation(
538 symbols: dict[str, AnnotatedSymbol], calls: list[CallSite]
539) -> list[Violation]:
540 """NASA P10 Rule 3: firmware allocates only inside a tagged function.
541
542 A tree-wide sweep rather than a per-symbol rule, because the property is
543 about call sites that carry no annotation at all -- the set a rule keyed
544 on an annotation cannot see.
545 """
546 exempt = {
547 sym.usr
548 for sym in symbols.values()
549 if any(a.startswith("ra8_nasa_rule_3_ok") for a in sym.annotations)
550 }
551 return [
552 _at_call(
553 cs,
554 "ra8_nasa_rule_3_ok",
555 f"call to '{cs.callee_name}' from '{cs.caller_name}' which is "
556 f"not tagged RA8_NASA_RULE_3_OK",
557 )
558 for cs in calls
559 # Host-only code is outside NASA P10 Rule 3 by construction: the
560 # firmware image never contains these TUs. See the path predicates below.
561 if not cs.in_address_of
562 and not is_host_only_path(cs.caller_file)
563 and not is_test_path(cs.caller_file)
564 and cs.callee_name in _ALLOCATORS
565 and cs.caller_usr not in exempt
566 ]
567
568
569def enforce_rules(
570 state: WalkState,
571 *,
572 whole_tree: bool = True,
573 naming_contract: bool = False,
574) -> list[Violation]:
575 """Apply every annotation rule and return the findings."""
576 ctx = RuleCtx.build(state.symbols, state.calls)
577 out: list[Violation] = []
578
579 # The linkage rule is defined over the whole tree: a handler tabled in
580 # one TU, or an API declared in a header no parsed TU includes, cannot
581 # be judged from a subset. Running it over an explicit file list would
582 # invent violations rather than find them.
583 if whole_tree:
584 out.extend(
585 enforce_linkage(
586 state.symbols,
587 state.vector_entries,
588 state.data_symbols,
589 naming_contract=naming_contract,
590 )
591 )
592
593 for sym in state.symbols.values():
594 for ann in sym.annotations:
595 rule, arg = parse_annotation(ann)
596 check = RULE_CHECKS.get(rule)
597 if check is None:
598 continue
599 found = check(sym, arg, ctx)
600 if rule in INFORMATIONAL_RULES:
601 for v in found:
602 v.warn_only = True
603 out.extend(found)
604
605 out.extend(sweep_dynamic_allocation(state.symbols, state.calls))
606 return out
-copyright