3"""One function per annotation rule, dispatched by rule key.
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
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.
21from __future__
import annotations
26from collections.abc
import Callable
27from dataclasses
import dataclass
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
37RECURSION_GUARD_LIMIT = 1000
40_NSC_RANGE_CHECK_RE = re.compile(
r"RA8_NSC_CHECK_NS_RANGE_(?:R|RW)\b|cmse_check_address_range\b")
43_ALLOCATORS = frozenset({
"malloc",
"free",
"calloc",
"realloc",
"aligned_alloc"})
46_NSC_SRC_DIR =
"/libs/ra8_nsc/src/"
51 """Everything a rule needs beyond the symbol it is judging.
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.
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]
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]] = {}
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)
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},
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, [])
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, [])
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():
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():
100 return int(m.group(1))
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)
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)
117def _rule_test_helper(sym: AnnotatedSymbol, _arg: str, ctx: RuleCtx) -> list[Violation]:
118 """RA8_TEST_HELPER: every caller must live under tests/."""
123 f
"function '{sym.name}' tagged RA8_TEST_HELPER called from non-test context",
125 for cs
in ctx.callers_of(sym)
126 if not is_test_path(cs.caller_file)
130def _rule_internal(sym: AnnotatedSymbol, _arg: str, _ctx: RuleCtx) -> list[Violation]:
131 """RA8_INTERNAL: the definition must actually be static."""
138 f
"function '{sym.name}' tagged RA8_INTERNAL is not declared static",
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:
149 for cs
in ctx.callers_of(sym):
155 if is_test_path(cs.caller_file):
157 caller_mod = module_of(cs.caller_file)
158 if caller_mod != callee_mod:
163 f
"function '{sym.name}' tagged RA8_PRIV ({callee_mod}) called "
164 f
"from outside its module (caller={caller_mod or 'unknown'})",
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:
181 f
"function '{sym.name}' tagged RA8_DI_SLOT called directly; "
182 f
"must be invoked via function pointer (DIP)",
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:
197 f
"NSC veneer '{sym.name}' must live under libs/ra8_nsc/src/ (found {where})",
202def _veneer_range_check(sym: AnnotatedSymbol) -> list[Violation]:
203 """A veneer must range-check every address it accepts from Non-Secure.
205 Veneers that take no pointer parameter cross no address and need no check.
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.
215 if not sym.has_pointer_param
or _NSC_RANGE_CHECK_RE.search(definition_text(sym)):
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)",
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:
232 if "cmse_nonsecure_entry" in " ".join(sym.annotations):
238 f
"NSC veneer '{sym.name}' must live in .gnu.sgstubs section or be cmse_nonsecure_entry",
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)]
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."""
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:
259 "ra8_hw_register_access",
260 f
"MMIO accessor '{sym.name}' must return volatile* (got '{sym.return_type}')",
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):
273 "ra8_mcdc_deactivated",
274 f
"ra8_mcdc_deactivated reason on '{sym.name}' contains file:line "
275 f
"citation -- use function name",
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."""
289 f
"ra8_max_stack:'{arg}' on '{sym.name}' is not an integer byte count",
292 measured = find_su_file(sym)
293 if measured
is None or measured <= annotated:
299 f
"function '{sym.name}' frame {measured} B exceeds ra8_max_stack:{annotated}",
304def _rule_expects_lock(sym: AnnotatedSymbol, arg: str, ctx: RuleCtx) -> list[Violation]:
305 """RA8_EXPECTS_LOCK: every caller must already hold the named lock.
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
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.
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):
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}"))',
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)
348 a.startswith((
"ra8_hw_register_access",
"RA8_HW_REGISTER_ACCESS"))
349 for a
in callee.annotations
355 f
"host-friendly '{sym.name}' calls MMIO accessor "
356 f
"'{cs.callee_name}' (would break off-target)",
362def _rule_no_recursion(sym: AnnotatedSymbol, _arg: str, ctx: RuleCtx) -> list[Violation]:
363 """RA8_NO_RECURSION: the transitive call closure must not include self."""
367 while stack
and guard < RECURSION_GUARD_LIMIT:
369 for cs
in ctx.calls_by_caller.get(stack.pop(), []):
372 if cs.callee_usr == sym.usr:
377 f
"function '{sym.name}' tagged RA8_NO_RECURSION appears "
378 f
"in its own transitive call closure",
381 if cs.callee_usr
not in seen:
382 seen.add(cs.callee_usr)
383 stack.append(cs.callee_usr)
387def _function_body_text(sym: AnnotatedSymbol) -> str:
388 """Return ``sym``'s brace-balanced body, or '' when it cannot be sliced.
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.
394 src = pathlib.Path(sym.file).read_text(errors=
"ignore")
397 m = re.search(re.escape(sym.name) +
r"\s*\([^;]*\)\s*\{", src)
402 for i
in range(start, len(src)):
412def _loop_headers(body: str) -> list[str]:
413 """Return the full parenthesised header of every for/while in ``body``.
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.
424 for m
in re.finditer(
r"\b(?:for|while)\s*\(", body):
426 for i
in range(m.end() - 1, len(body)):
432 out.append(body[m.end() : i])
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)
446 f
"loop in '{sym.name}' missing bound symbol '{arg}' in condition '{header.strip()}'",
448 for header
in _loop_headers(body)
453def _rule_validates(sym: AnnotatedSymbol, arg: str, ctx: RuleCtx) -> list[Violation]:
454 """RA8_VALIDATES: at least N RA8_CHECK_* calls in the body."""
459 count = sum(1
for c
in ctx.body_calls(sym)
if c.callee_name.startswith(
"RA8_CHECK_"))
466 f
"function '{sym.name}' has {count} RA8_CHECK_* calls; "
467 f
"ra8_validates:{need} requires at least {need}",
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:
483 f
"function '{sym.name}' acquires '{arg}' but no matching {released} call found",
488def _rule_latency_budget(sym: AnnotatedSymbol, arg: str, _ctx: RuleCtx) -> list[Violation]:
489 """RA8_LATENCY_BUDGET_NS: recorded until a WCET pass exists."""
493 "ra8_latency_budget_ns",
494 f
"ra8_latency_budget_ns:{arg} on '{sym.name}' recorded; no WCET pass yet",
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}'")]
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}'")]
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,
537def sweep_dynamic_allocation(
538 symbols: dict[str, AnnotatedSymbol], calls: list[CallSite]
540 """NASA P10 Rule 3: firmware allocates only inside a tagged function.
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.
548 for sym
in symbols.values()
549 if any(a.startswith(
"ra8_nasa_rule_3_ok")
for a
in sym.annotations)
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",
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
572 whole_tree: bool =
True,
573 naming_contract: bool =
False,
575 """Apply every annotation rule and return the findings."""
576 ctx = RuleCtx.build(state.symbols, state.calls)
577 out: list[Violation] = []
587 state.vector_entries,
589 naming_contract=naming_contract,
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)
599 found =
check(sym, arg, ctx)
600 if rule
in INFORMATIONAL_RULES:
605 out.extend(sweep_dynamic_allocation(state.symbols, state.calls))