ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_freestanding_runtime.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"""Freestanding target runtime binary dependency and linker script ratchet.
5
6Enforces that target firmware images contain no implicit libc/newlib dependencies,
7no general-purpose heap, no unapproved dynamic allocation, and that linker
8scripts do not define heap anchors ('end', '_end') or '.heap' sections.
9
10Differentiates between:
11 - live symbols vs discarded symbols
12 - live archive members vs discarded sections vs LOAD-only archives
13 - project sbrk trap vs libnosys bump allocator
14 - allowed libgcc compiler helpers vs forbidden libc symbols
15"""
16
17from __future__ import annotations
18
19import argparse
20import json
21import os
22import pathlib
23import re
24import subprocess
25import sys
26from typing import Any
27
28import freestanding_runtime_selftest as fr_selftest
29
30_nm_min_fields = 2
31_map_input_section_fields = 4
32_map_symbol_fields = 2
33
34
35def _repo_root() -> pathlib.Path:
36 """Return repository root path."""
37 return pathlib.Path(__file__).resolve().parents[2]
38
39
40def _forbidden_symbols() -> set[str]:
41 """Return the set of forbidden libc/allocator/stdio runtime symbols."""
42 return {
43 "malloc",
44 "calloc",
45 "realloc",
46 "free",
47 "aligned_alloc",
48 "valloc",
49 "pvalloc",
50 "posix_memalign",
51 "reallocarray",
52 "strdup",
53 "strndup",
54 "asprintf",
55 "vasprintf",
56 "_malloc_r",
57 "_calloc_r",
58 "_realloc_r",
59 "_free_r",
60 "_sbrk_r",
61 "_malloc_usable_size_r",
62 "__assert_func",
63 "printf",
64 "fprintf",
65 "fiprintf",
66 "vfprintf",
67 "_vfprintf_r",
68 "_vfiprintf_r",
69 "_printf_i",
70 "_printf_common",
71 "puts",
72 "fputs",
73 }
74
75
76def _forbidden_archives() -> set[str]:
77 """Return the set of forbidden standard library archives."""
78 return {"libnosys.a", "libg_nano.a", "libc_nano.a", "libc.a"}
79
80
81def _allowed_compiler_archives() -> set[str]:
82 """Return compiler support archives that are explicitly allowed."""
83 return {"libgcc.a", "libm.a"}
84
85
86def _allowed_project_archives() -> set[str]:
87 """First-party build-product archives allowed to contribute live members."""
88 return {
89 "libthreadx.a",
90 "libthreadx_ns.a",
91 "libra8_shared_ek_ra8d2.a",
92 "libtfpsa_arm.a",
93 "libtfpsa_dfu.a",
94 "libtfpsa_rot.a",
95 "libtfpsa_sb.a",
96 "libtfpsa_sbns.a",
97 }
98
99
100def _allowed_libm_members() -> set[str]:
101 """Return explicitly approved libm.a members (transcendental functions without malloc/stdio)."""
102 return {
103 "libm_a-wf_acos.o",
104 "libm_a-wf_atan2.o",
105 "libm_a-wf_fmod.o",
106 "libm_a-wf_pow.o",
107 "libm_a-wf_sqrt.o",
108 "libm_a-sf_finite.o",
109 "libm_a-sf_cos.o",
110 "libm_a-sf_fabs.o",
111 "libm_a-sf_nan.o",
112 "libm_a-sf_sin.o",
113 "libm_a-sf_tan.o",
114 "libm_a-kf_cos.o",
115 "libm_a-kf_sin.o",
116 "libm_a-kf_tan.o",
117 "libm_a-ef_acos.o",
118 "libm_a-ef_atan2.o",
119 "libm_a-ef_sqrt.o",
120 "libm_a-ef_fmod.o",
121 "libm_a-ef_pow.o",
122 "libm_a-sf_ceil.o",
123 "libm_a-sf_floor.o",
124 "libm_a-ef_rem_pio2.o",
125 "libm_a-math_errf.o",
126 "libm_a-sf_scalbn.o",
127 "libm_a-sf_atan.o",
128 "libm_a-kf_rem_pio2.o",
129 }
130
131
132def _reviewed_runtime_primitives() -> set[str]:
133 """Explicit, reviewed allowlist for project-owned freestanding ABI primitives."""
134 return {
135 "memset",
136 "memcpy",
137 "memmove",
138 "memcmp",
139 "memchr",
140 "strlen",
141 "strnlen",
142 "strcmp",
143 "strncmp",
144 "strchr",
145 "strrchr",
146 "strstr",
147 "strcpy",
148 "strncpy",
149 "abs",
150 }
151
152
153def find_tool(tool_name: str) -> str | None:
154 """Locate tool binary in RA8_ARM_TOOLCHAIN_BIN, PATH, or standard toolchain paths."""
155 env_bin = os.environ.get("RA8_ARM_TOOLCHAIN_BIN")
156 if env_bin:
157 p = pathlib.Path(env_bin) / tool_name
158 if p.is_file() and os.access(p, os.X_OK):
159 return str(p)
160
161 path_dirs = os.environ.get("PATH", "").split(os.pathsep)
162 for d in path_dirs:
163 if d:
164 p = pathlib.Path(d) / tool_name
165 if p.is_file() and os.access(p, os.X_OK):
166 return str(p)
167
168 home_opt = str(pathlib.Path.home() / "opt" / "arm-gnu-toolchain-13.3" / "bin")
169 known_paths = [
170 home_opt,
171 "/opt/arm-gnu-toolchain/bin",
172 "/opt/toolchains/arm-gnu-toolchain-13.3/bin",
173 "/usr/local/bin",
174 "/usr/bin",
175 ]
176 for d in known_paths:
177 p = pathlib.Path(d) / tool_name
178 if p.is_file() and os.access(p, os.X_OK):
179 return str(p)
180
181 return None
182
183
184def parse_nm_symbols(nm_output: str) -> dict[str, str]:
185 """Parse nm output into {symbol_name: symbol_type}."""
186 symbols: dict[str, str] = {}
187 for line in nm_output.splitlines():
188 parts = line.split()
189 if len(parts) > _nm_min_fields:
190 sym_type, sym_name = parts[1], parts[2]
191 symbols[sym_name] = sym_type
192 elif len(parts) == _nm_min_fields:
193 sym_type, sym_name = parts[0], parts[1]
194 symbols[sym_name] = sym_type
195 return symbols
196
197
198def _parse_map_archive_member(
199 obj_path: str,
200 arch_member_re: re.Pattern[str],
201) -> tuple[str, str, bool] | None:
202 """Extract archive name, member name, and whether it's a system archive."""
203 m = arch_member_re.search(obj_path)
204 if not m:
205 return None
206 arch_path, member = m.group(1), m.group(2)
207 arch_path_fwd = arch_path.replace("\\", "/")
208 arch_name = arch_path_fwd.split("/")[-1]
209 # Heuristic for system toolchain archives: absolute paths containing 'arm-none-eabi' or 'gcc'
210 is_system = "/" in arch_path_fwd and (
211 "arm-none-eabi" in arch_path_fwd or "gcc" in arch_path_fwd
212 )
213 return arch_name, member, is_system
214
215
216def _parse_map_discarded(
217 discarded_text: str,
218 arch_member_re: re.Pattern[str],
219) -> dict[str, set[str]]:
220 """Collect archive members that appear only under Discarded input sections."""
221 discarded: dict[str, set[str]] = {}
222 for line in discarded_text.splitlines():
223 res = _parse_map_archive_member(line, arch_member_re)
224 if res:
225 arch_name, member, _ = res
226 discarded.setdefault(arch_name, set()).add(member)
227 return discarded
228
229
230def _parse_map_memory_map(
231 memory_map_text: str,
232 arch_member_re: re.Pattern[str],
233) -> tuple[dict[str, set[str]], dict[str, str], set[str]]:
234 """Parse the memory map for live members, providers, and system archives."""
235 live_members: dict[str, set[str]] = {}
236 providers: dict[str, str] = {}
237 system_archives: set[str] = set()
238 lines = memory_map_text.splitlines()
239
240 for i, line in enumerate(lines):
241 parts = line.split()
242 if (
243 len(parts) >= _map_input_section_fields
244 and parts[0].startswith(".")
245 and parts[1].startswith("0x")
246 and parts[2].startswith("0x")
247 ):
248 try:
249 size = int(parts[2], 16)
250 except ValueError:
251 size = 0
252 if size > 0:
253 res = _parse_map_archive_member(parts[3], arch_member_re)
254 if res:
255 arch_name, member, is_system = res
256 live_members.setdefault(arch_name, set()).add(member)
257 if is_system:
258 system_archives.add(arch_name)
259
260 if (
261 len(parts) == _map_symbol_fields
262 and parts[0].startswith("0x")
263 and not parts[1].startswith("0x")
264 ):
265 sym = parts[1]
266 if i > 0:
267 prev = lines[i - 1]
268 res = _parse_map_archive_member(prev, arch_member_re)
269 if res:
270 providers[sym] = f"{res[0]}({res[1]})"
271 elif ".o" in prev:
272 obj_m = re.search(r"([^\s()]+\.o(?:bj)?)", prev)
273 if obj_m:
274 providers[sym] = obj_m.group(1).replace("\\", "/").split("/")[-1]
275
276 return live_members, providers, system_archives
277
278
279def parse_map_file(map_content: str) -> dict[str, Any]:
280 """Parse GNU ld map file to extract live archive members and symbol providers."""
281 result: dict[str, Any] = {
282 "live_archive_members": {},
283 "discarded_archive_members": {},
284 "symbol_providers": {},
285 "system_archives": set(),
286 "has_heap_section": False,
287 }
288
289 mm_pos = map_content.find("Linker script and memory map")
290 if mm_pos == -1:
291 mm_pos = map_content.find("Memory Map")
292
293 memory_map_text = map_content[mm_pos:] if mm_pos != -1 else ""
294 discarded_text = map_content[:mm_pos] if mm_pos != -1 else map_content
295
296 heap_sec_re = re.compile(r"^\s*\.heap\s+0x[0-9a-fA-F]+\s+0x[1-9a-fA-F]", re.MULTILINE)
297 if heap_sec_re.search(memory_map_text):
298 result["has_heap_section"] = True
299
300 arch_member_re = re.compile(r"([^\s()]+\.a)\‍(([^)]+\.o(?:bj)?)\‍)")
301 result["discarded_archive_members"] = _parse_map_discarded(discarded_text, arch_member_re)
302
303 live_members, providers, system_archives = _parse_map_memory_map(
304 memory_map_text, arch_member_re
305 )
306 result["live_archive_members"] = live_members
307 result["symbol_providers"] = providers
308 result["system_archives"] = system_archives
309 return result
310
311
312def check_linker_script(content: str, path: str) -> list[str]:
313 """Check a linker script for prohibited 'end'/'_end' definitions and '.heap' sections."""
314 comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
315 stripped = comment_re.sub(" ", content)
316
317 violations = []
318 end_sym_re = re.compile(r"\b(PROVIDE\s*\‍(\s*)?(_?end)\s*=", re.MULTILINE)
319 violations.extend(
320 f"{path}: defines forbidden heap anchor '{m.group(2)}'"
321 for m in end_sym_re.finditer(stripped)
322 )
323
324 heap_sec_re = re.compile(r"(?<![a-zA-Z0-9_])\.heap\b")
325 if heap_sec_re.search(stripped):
326 violations.append(f"{path}: defines forbidden '.heap' output section")
327
328 return violations
329
330
331def check_all_linker_scripts(repo_root: pathlib.Path, baseline: dict[str, Any]) -> list[str]:
332 """Check all target linker scripts in the repository."""
333 violations: list[str] = []
334 allowed_script_exceptions = set(baseline.get("linker_script_exceptions", []))
335
336 roots = [
337 repo_root / "libs",
338 repo_root / "examples",
339 repo_root / "apps",
340 ]
341
342 for root in roots:
343 for ld_path in root.glob("**/*.ld"):
344 rel_path = str(ld_path.relative_to(repo_root)).replace("\\", "/")
345 if "third_party" in rel_path:
346 continue
347
348 content = ld_path.read_text(encoding="utf-8", errors="replace")
349 script_violations = check_linker_script(content, rel_path)
350 for v in script_violations:
351 if rel_path in allowed_script_exceptions:
352 continue
353 violations.append(v)
354
355 return violations
356
357
358def check_source_asserts(content: str, path: str) -> list[str]:
359 """Check a source file for forbidden <assert.h> and runtime assert()."""
360 comment_re = re.compile(r"/\*.*?\*/|//[^\n]*", re.DOTALL)
361 stripped = comment_re.sub(" ", content)
362 str_re = re.compile(r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'')
363 stripped = str_re.sub(" ", stripped)
364 # Ignore preprocessor macro definitions redirecting assert, e.g. #define assert(...)
365 macro_def_re = re.compile(r"#\s*define\s+assert\b[^\n]*")
366 stripped = macro_def_re.sub(" ", stripped)
367
368 violations = []
369 if re.search(r"#\s*include\s+<assert\.h>", content):
370 violations.append(f"{path}: includes forbidden libc <assert.h>")
371
372 assert_call_re = re.compile(r"(?<![a-zA-Z0-9_])assert\s*\‍(")
373 for m in assert_call_re.finditer(stripped):
374 line_num = content[: m.start()].count("\n") + 1
375 violations.append(
376 f"{path}:{line_num}: uses forbidden standard assert() "
377 "(use RA8_ASSERT for runtime invariants or static_assert for compile-time)"
378 )
379 return violations
380
381
382def check_all_source_asserts(repo_root: pathlib.Path) -> list[str]:
383 """Check all target-linkable first-party source files for forbidden asserts."""
384 violations: list[str] = []
385 roots = [
386 repo_root / "libs",
387 repo_root / "examples",
388 repo_root / "apps",
389 repo_root / "port",
390 ]
391 # port/posix is host-only and legitimately uses host facilities.
392 # port/esp-hosted is target-linkable and audited (no blanket exemption).
393 exempt_fragments = ("third_party", "tests", "apps/host", "port/posix", ".pb-c.")
394
395 for root in roots:
396 for p in root.glob("**/*"):
397 if not p.is_file() or p.suffix not in (".c", ".h"):
398 continue
399 rel_path = str(p.relative_to(repo_root)).replace("\\", "/")
400 if any(frag in rel_path for frag in exempt_fragments):
401 continue
402 content = p.read_text(encoding="utf-8", errors="replace")
403 violations.extend(check_source_asserts(content, rel_path))
404
405 return violations
406
407
408def _run_readelf_heap_check(readelf_bin: str | None, elf_path: pathlib.Path) -> bool:
409 """Return True if readelf detects a .heap section."""
410 if not readelf_bin:
411 return False
412 r_proc = subprocess.run( # noqa: S603 # trusted tool binary
413 [readelf_bin, "-S", str(elf_path)],
414 capture_output=True,
415 text=True,
416 check=False,
417 )
418 if r_proc.returncode != 0:
419 return False
420 return any(re.search(r"\‍[\s*\d+\‍]\s+\.heap\b", line) for line in r_proc.stdout.splitlines())
421
422
423def _read_symbols_and_heap(
424 elf_path: pathlib.Path, nm_bin: str, readelf_bin: str | None
425) -> tuple[dict[str, str], bool]:
426 """Run nm and readelf to extract defined symbols and check for .heap section."""
427 proc = subprocess.run( # noqa: S603 # trusted tool binary
428 [nm_bin, "-n", str(elf_path)],
429 capture_output=True,
430 text=True,
431 check=True,
432 )
433 symbols = parse_nm_symbols(proc.stdout)
434 has_heap = _run_readelf_heap_check(readelf_bin, elf_path)
435 return symbols, has_heap
436
437
438def _load_map_info(map_path: pathlib.Path | None, has_heap: bool) -> tuple[dict[str, Any], bool]:
439 """Parse map file if present, updating heap flag."""
440 map_info: dict[str, Any] = {
441 "live_archive_members": {},
442 "discarded_archive_members": {},
443 "symbol_providers": {},
444 "has_heap_section": False,
445 }
446 if map_path and map_path.is_file():
447 map_content = map_path.read_text(encoding="utf-8", errors="replace")
448 map_info = parse_map_file(map_content)
449 if map_info["has_heap_section"]:
450 has_heap = True
451 return map_info, has_heap
452
453
454def analyze_image(
455 elf_path: pathlib.Path,
456 map_path: pathlib.Path | None = None,
457 nm_binary: str | None = None,
458 readelf_binary: str | None = None,
459) -> dict[str, Any]:
460 """Analyze a single target ELF and its map file."""
461 if map_path is None:
462 candidate_map = elf_path.with_suffix(".map")
463 if candidate_map.is_file():
464 map_path = candidate_map
465
466 nm_bin = nm_binary or find_tool("arm-none-eabi-nm")
467 readelf_bin = readelf_binary or find_tool("arm-none-eabi-readelf")
468 if not nm_bin:
469 msg = "arm-none-eabi-nm tool not found"
470 raise RuntimeError(msg)
471
472 symbols, has_heap_section = _read_symbols_and_heap(elf_path, nm_bin, readelf_bin)
473 map_info, has_heap_section = _load_map_info(map_path, has_heap_section)
474 all_forbidden = _forbidden_symbols()
475 live_forbidden_syms = sorted(s for s in symbols if s in all_forbidden)
476
477 sbrk_present = "_sbrk" in symbols
478 sbrk_provider = map_info["symbol_providers"].get("_sbrk", "unknown" if sbrk_present else "none")
479 end_sym_present = ("end" in symbols) or ("_end" in symbols)
480 reviewed_prims = _reviewed_runtime_primitives()
481 prim_providers = {
482 s: map_info["symbol_providers"].get(s, "project") for s in symbols if s in reviewed_prims
483 }
484 posix_prefixes = ("ra8_io_stream_posix", "fw_fs_posix", "fw_if_fs_posix")
485 posix_symbols = sorted(s for s in symbols if any(s.startswith(p) for p in posix_prefixes))
486 posix_providers = {
487 s: prov for s, prov in map_info["symbol_providers"].items() if "posix" in prov.lower()
488 }
489
490 return {
491 "elf": str(elf_path),
492 "map": str(map_path) if map_path else None,
493 "live_forbidden_symbols": live_forbidden_syms,
494 "live_archive_members": {k: sorted(v) for k, v in map_info["live_archive_members"].items()},
495 "system_archives": sorted(map_info.get("system_archives", set())),
496 "discarded_archive_members": {
497 k: sorted(v) for k, v in map_info["discarded_archive_members"].items()
498 },
499 "runtime_primitive_providers": prim_providers,
500 "sbrk_present": sbrk_present,
501 "sbrk_provider": sbrk_provider,
502 "end_symbol_present": end_sym_present,
503 "has_heap_section": has_heap_section,
504 "posix_symbols": posix_symbols,
505 "posix_providers": posix_providers,
506 }
507
508
509def _check_live_archive_policy(app_name: str, analysis: dict[str, Any]) -> list[str]:
510 """Fail closed on live archives outside the explicit allowlists."""
511 violations: list[str] = []
512 allowed = _allowed_compiler_archives()
513 project = _allowed_project_archives()
514 live = analysis.get("live_archive_members", {})
515 for arch in sorted(live):
516 if arch in allowed:
517 if arch == "libm.a":
518 unapproved = [m for m in live[arch] if m not in _allowed_libm_members()]
519 if unapproved:
520 violations.append(
521 f"{app_name}: contains unapproved libm.a members: {sorted(unapproved)}"
522 )
523 elif arch in project or arch in _forbidden_archives():
524 continue
525 else:
526 violations.append(f"{app_name}: contains unapproved live archive '{arch}'")
527 return violations
528
529
530def _split_archive_provider(provider: str) -> tuple[str, str] | None:
531 """Split an 'archive(member)' provider into its (archive, member) pair."""
532 match = re.match(r"^(.+\.a)\‍(([^)]+)\‍)$", provider)
533 if match is None:
534 return None
535 arch = match.group(1).replace("\\", "/").split("/")[-1]
536 return arch, match.group(2)
537
538
539def _check_primitive_providers(app_name: str, analysis: dict[str, Any]) -> list[str]:
540 """Reject runtime primitives resolved to unapproved archives."""
541 violations: list[str] = []
542 allowed = _allowed_compiler_archives() | _allowed_project_archives()
543 for sym, provider in analysis.get("runtime_primitive_providers", {}).items():
544 if any(arch in provider for arch in _forbidden_archives()):
545 violations.append(
546 f"{app_name}: runtime primitive '{sym}' resolved to forbidden '{provider}'"
547 )
548 continue
549 split = _split_archive_provider(provider)
550 if split is None:
551 continue
552 arch, member = split
553 if arch not in allowed or (arch == "libm.a" and member not in _allowed_libm_members()):
554 violations.append(
555 f"{app_name}: runtime primitive '{sym}' resolved to unapproved '{provider}'"
556 )
557 return violations
558
559
560def _check_posix_block(app_name: str, analysis: dict[str, Any]) -> list[str]:
561 """Reject host-only POSIX symbols and objects in target images."""
562 violations: list[str] = []
563 if analysis.get("posix_symbols"):
564 violations.append(
565 f"{app_name}: contains forbidden host-only POSIX symbol(s): {analysis['posix_symbols']}"
566 )
567 if analysis.get("posix_providers"):
568 providers = analysis["posix_providers"]
569 violations.append(f"{app_name}: links host-only POSIX object(s): {providers}")
570 return violations
571
572
573def _check_sbrk_ratchet(
574 app_name: str, analysis: dict[str, Any], app_debt: dict[str, Any]
575) -> list[str]:
576 """Reject unexpected live _sbrk against the recorded provider."""
577 violations: list[str] = []
578 expected_provider = app_debt.get("sbrk_provider", "none")
579 if expected_provider == "none" and analysis["sbrk_present"]:
580 violations.append(
581 f"{app_name}: unexpected live _sbrk symbol (provider: '{analysis['sbrk_provider']}')"
582 )
583 elif (
584 analysis["sbrk_present"]
585 and expected_provider != "none"
586 and expected_provider != analysis["sbrk_provider"]
587 ):
588 violations.append(
589 f"{app_name}: _sbrk provider changed from '{expected_provider}' "
590 f"to '{analysis['sbrk_provider']}'"
591 )
592 return violations
593
594
595def _eval_strict_freestanding(
596 app_name: str,
597 analysis: dict[str, Any],
598) -> list[str]:
599 """Enforce strict freestanding invariants for images with zero allowable debt."""
600 violations: list[str] = []
601 live_syms = set(analysis["live_forbidden_symbols"])
602 if live_syms:
603 violations.append(f"{app_name}: contains forbidden runtime symbols: {sorted(live_syms)}")
604
605 violations.extend(_check_live_archive_policy(app_name, analysis))
606
607 live_archives = analysis["live_archive_members"]
608 for arch in _forbidden_archives():
609 members = live_archives.get(arch, [])
610 if members:
611 violations.append(f"{app_name}: contains forbidden live members from {arch}: {members}")
612
613 violations.extend(_check_primitive_providers(app_name, analysis))
614
615 if analysis["sbrk_present"]:
616 violations.append(
617 f"{app_name}: unexpected live _sbrk symbol (provider: '{analysis['sbrk_provider']}')"
618 )
619
620 if analysis["end_symbol_present"]:
621 violations.append(f"{app_name}: defines heap anchor symbol 'end'/'_end'")
622
623 violations.extend(_check_posix_block(app_name, analysis))
624 return violations
625
626
627def _eval_ratchet_baseline(
628 app_name: str,
629 analysis: dict[str, Any],
630 app_debt: dict[str, Any],
631) -> list[str]:
632 """Enforce that an image does not exceed its recorded debt."""
633 violations: list[str] = []
634 live_syms = set(analysis["live_forbidden_symbols"])
635 baseline_syms = set(app_debt.get("forbidden_symbols", []))
636 new_syms = live_syms - baseline_syms
637 if new_syms:
638 violations.append(
639 f"{app_name}: introduces new forbidden symbols beyond baseline: {sorted(new_syms)}"
640 )
641
642 # The compiler-archive allowlist is absolute, not ratcheted: recorded debt
643 # never authorizes an unapproved system archive or libm member.
644 violations.extend(_check_live_archive_policy(app_name, analysis))
645
646 live_archives = analysis["live_archive_members"]
647 baseline_archives = app_debt.get("forbidden_archives", {})
648 for arch in _forbidden_archives():
649 current_members = set(live_archives.get(arch, []))
650 base_members = set(baseline_archives.get(arch, []))
651 new_members = current_members - base_members
652 if new_members:
653 violations.append(
654 f"{app_name}: introduces new live members from {arch} "
655 f"beyond baseline: {sorted(new_members)}"
656 )
657
658 # The archive allowlist is absolute, not ratcheted: recorded debt never
659 # authorizes an unapproved archive, member, or primitive provider.
660 violations.extend(_check_primitive_providers(app_name, analysis))
661
662 violations.extend(_check_sbrk_ratchet(app_name, analysis, app_debt))
663
664 expected_end = app_debt.get("end_symbol", False)
665 if analysis["end_symbol_present"] and not expected_end:
666 violations.append(f"{app_name}: defines unexpected heap anchor 'end'/'_end'")
667
668 violations.extend(_check_posix_block(app_name, analysis))
669
670 return violations
671
672
673def _is_zero_debt(app_debt: dict[str, Any]) -> bool:
674 """Return True if an app baseline entry specifies zero allowable debt."""
675 forbidden_syms = app_debt.get("forbidden_symbols", [])
676 forbidden_archs = app_debt.get("forbidden_archives", {})
677 has_arch_members = any(bool(members) for members in forbidden_archs.values())
678 sbrk_provider = app_debt.get("sbrk_provider", "none")
679 end_symbol = app_debt.get("end_symbol", False)
680 return (
681 not forbidden_syms
682 and not has_arch_members
683 and sbrk_provider in (None, "none")
684 and not end_symbol
685 )
686
687
688def evaluate_against_baseline(
689 app_name: str,
690 analysis: dict[str, Any],
691 baseline: dict[str, Any],
692) -> list[str]:
693 """Check whether the analyzed image violates the freestanding ratchet."""
694 violations: list[str] = []
695 if analysis.get("has_heap_section"):
696 violations.append(f"{app_name}: contains forbidden '.heap' section")
697
698 app_debt = baseline.get("apps", {}).get(app_name)
699 if app_debt is None:
700 # Unknown app: strictly enforce zero debt
701 violations.extend(_eval_strict_freestanding(app_name, analysis))
702 elif _is_zero_debt(app_debt):
703 # Baselined app with zero recorded debt: strictly enforce zero debt
704 violations.extend(_eval_strict_freestanding(app_name, analysis))
705 else:
706 violations.extend(_eval_ratchet_baseline(app_name, analysis, app_debt))
707
708 return violations
709
710
711def _check_ra8_freestanding_build_files(repo_root: pathlib.Path) -> list[str]:
712 """Verify build-system files discriminate target builds with RA8_FREESTANDING."""
713 failures: list[str] = []
714 # Build-system declarations always exist, so this half of the check can
715 # never pass vacuously: the toolchain must discriminate target builds
716 # with RA8_FREESTANDING even when no compile database was generated.
717 toolchain = repo_root / "cmake" / "toolchain-ra8d2.cmake"
718 if not toolchain.is_file():
719 failures.append("cmake/toolchain-ra8d2.cmake not found")
720 elif "RA8_FREESTANDING" not in toolchain.read_text(encoding="utf-8"):
721 failures.append("cmake/toolchain-ra8d2.cmake does not define RA8_FREESTANDING")
722
723 add_app = repo_root / "cmake" / "ra8_add_app.cmake"
724 if not add_app.is_file():
725 failures.append("cmake/ra8_add_app.cmake not found")
726 elif "RA8_FREESTANDING" not in add_app.read_text(encoding="utf-8"):
727 failures.append("cmake/ra8_add_app.cmake does not define RA8_FREESTANDING")
728 return failures
729
730
731def check_db_entries(
732 entries: list[dict[str, Any]],
733 *,
734 must_have: bool,
735 exempt: str | None,
736 label: str,
737) -> list[str]:
738 """Check one compile database for the RA8_FREESTANDING discriminator."""
739 failures: list[str] = []
740 for entry in entries:
741 cmd = entry.get("command", " ".join(entry.get("arguments", [])))
742 if exempt is not None and exempt in cmd:
743 continue
744 has_flag = "-DRA8_FREESTANDING" in cmd
745 if must_have and not has_flag:
746 rel_file = entry.get("file", "unknown")
747 failures.append(f"{label} missing -DRA8_FREESTANDING: {rel_file}")
748 break
749 if not must_have and has_flag:
750 rel_file = entry.get("file", "unknown")
751 failures.append(f"{label} sets -DRA8_FREESTANDING: {rel_file}")
752 break
753 return failures
754
755
756def _load_db_entries(db_path: pathlib.Path) -> list[dict[str, Any]] | None:
757 """Load compile database entries, or None when unreadable."""
758 try:
759 entries = json.loads(db_path.read_text(encoding="utf-8"))
760 except (OSError, json.JSONDecodeError):
761 return None
762 return entries if isinstance(entries, list) else None
763
764
765def discover_target_dbs(repo_root: pathlib.Path) -> list[pathlib.Path]:
766 """Find per-app target compile databases under gitignored build dirs."""
767 dbs: list[pathlib.Path] = []
768 for top in ("examples", "apps"):
769 base = repo_root / top
770 if base.is_dir():
771 dbs.extend(
772 p for p in sorted(base.glob("**/build/compile_commands.json")) if p.is_file()
773 )
774 return dbs
775
776
777def check_target_db_flags(db_path: pathlib.Path) -> list[str]:
778 """Require every TU in one target database to define RA8_FREESTANDING."""
779 entries = _load_db_entries(db_path) if db_path.is_file() else None
780 if entries is None:
781 return [f"Target compile database unreadable or missing: {db_path}"]
782 if not entries:
783 return [f"Target compile database empty: {db_path}"]
784 return check_db_entries(entries, must_have=True, exempt=None, label="Target compile command")
785
786
787def check_discovered_target_dbs(repo_root: pathlib.Path) -> list[str]:
788 """Enforce the discriminator on every generated per-app target database."""
789 failures: list[str] = []
790 for db_path in discover_target_dbs(repo_root):
791 failures.extend(check_target_db_flags(db_path))
792 return failures
793
794
795def _check_ra8_freestanding_config(repo_root: pathlib.Path) -> list[str]:
796 """Verify RA8_FREESTANDING is defined for target builds and absent for host builds."""
797 failures = _check_ra8_freestanding_build_files(repo_root)
798
799 target_db_path = repo_root / "compile_commands.json"
800 if target_db_path.is_file():
801 entries = _load_db_entries(target_db_path)
802 if entries is not None:
803 failures.extend(
804 check_db_entries(
805 entries, must_have=True, exempt=None, label="Target compile command"
806 )
807 )
808
809 failures.extend(check_discovered_target_dbs(repo_root))
810
811 host_db_path = None
812 for p in (
813 repo_root / "tests" / "build-darwin" / "compile_commands.json",
814 repo_root / "tests" / "build-linux" / "compile_commands.json",
815 ):
816 if p.is_file():
817 host_db_path = p
818 break
819
820 if host_db_path:
821 # Host tests explicitly building freestanding primitives WILL have the
822 # flag (e.g. test_ra8_freestanding). We only check general host code.
823 entries = _load_db_entries(host_db_path)
824 if entries is not None:
825 failures.extend(
826 check_db_entries(
827 entries,
828 must_have=False,
829 exempt="-DRA8_TEST_FREESTANDING",
830 label="Host compile command",
831 )
832 )
833
834 return failures
835
836
837def selftest() -> int:
838 """Run all freestanding-runtime selftests."""
839 failures = fr_selftest.run_selftests(_repo_root())
840 failures.extend(_check_ra8_freestanding_config(_repo_root()))
841 if failures:
842 print(f"check_freestanding_runtime selftest: {len(failures)} failure(s):", file=sys.stderr)
843 for f in failures:
844 print(f" FAIL: {f}", file=sys.stderr)
845 return 1
846 print("check_freestanding_runtime selftest: all cases pass (both directions).")
847 return 0
848
849
850def _load_baseline(baseline_path: pathlib.Path) -> dict[str, Any] | None:
851 """Load baseline dictionary from JSON file."""
852 if not baseline_path.is_file():
853 return {}
854 try:
855 return json.loads(baseline_path.read_text(encoding="utf-8"))
856 except (OSError, json.JSONDecodeError) as e:
857 print(f"Error reading baseline file {baseline_path}: {e}", file=sys.stderr)
858 return None
859
860
861def _process_elf_list(
862 elfs: list[tuple[str, pathlib.Path, pathlib.Path | None]],
863 baseline: dict[str, Any],
864 violations: list[str],
865 *,
866 update: bool,
867) -> int:
868 """Analyze ELFs and evaluate against baseline or update baseline."""
869 for app_name, elf_path, map_file in elfs:
870 try:
871 analysis = analyze_image(elf_path, map_path=map_file)
872 except (OSError, subprocess.SubprocessError, RuntimeError) as e:
873 print(f"Error analyzing {elf_path}: {e}", file=sys.stderr)
874 return 2
875
876 if update:
877 baseline.setdefault("apps", {})[app_name] = {
878 "forbidden_symbols": analysis["live_forbidden_symbols"],
879 "forbidden_archives": {
880 k: v
881 for k, v in analysis["live_archive_members"].items()
882 if k in _forbidden_archives()
883 },
884 "sbrk_provider": analysis["sbrk_provider"],
885 "end_symbol": analysis["end_symbol_present"],
886 }
887 else:
888 violations.extend(evaluate_against_baseline(app_name, analysis, baseline))
889 return 0
890
891
892def _collect_elfs(
893 scan_dir: pathlib.Path | None,
894 elf: pathlib.Path | None,
895 map_path: pathlib.Path | None,
896 app_name: str | None,
897) -> list[tuple[str, pathlib.Path, pathlib.Path | None]]:
898 """Gather ELF targets to inspect."""
899 elfs: list[tuple[str, pathlib.Path, pathlib.Path | None]] = []
900 if scan_dir:
901 elfs.extend((app_name or p.stem, p, None) for p in scan_dir.glob("**/*.elf"))
902 if elf:
903 elfs.append((app_name or elf.stem, elf, map_path))
904 return elfs
905
906
907def _run_static_checks(
908 args: argparse.Namespace, root: pathlib.Path, baseline: dict[str, Any]
909) -> int:
910 """Run static source-assert and linker-script checks."""
911 if args.check_asserts:
912 assert_violations = check_all_source_asserts(root)
913 if assert_violations:
914 print(
915 f"FATAL: Source assert violations ({len(assert_violations)}):",
916 file=sys.stderr,
917 )
918 for v in assert_violations:
919 print(f" {v}", file=sys.stderr)
920 return 1
921 print("check_freestanding_runtime: target source clean (no standard libc assert).")
922
923 if args.check_scripts:
924 script_violations = check_all_linker_scripts(root, baseline)
925 if script_violations:
926 print(
927 f"FATAL: Linker script violations ({len(script_violations)}):",
928 file=sys.stderr,
929 )
930 for v in script_violations:
931 print(f" {v}", file=sys.stderr)
932 return 1
933 print(
934 "check_freestanding_runtime: linker scripts clean (no unauthorized 'end' or '.heap')."
935 )
936 return 0
937
938
939def main() -> int:
940 """CLI entry point."""
941 parser = argparse.ArgumentParser(description="Freestanding runtime dependency ratchet.")
942 parser.add_argument("--selftest", action="store_true", help="Run self-tests.")
943 parser.add_argument("--check-scripts", action="store_true", help="Audit target linker scripts.")
944 parser.add_argument("--check-asserts", action="store_true", help="Audit for libc assert.")
945 parser.add_argument("--elf", type=pathlib.Path, help="Target ELF to inspect.")
946 parser.add_argument("--map", type=pathlib.Path, help="Linker map file to inspect.")
947 parser.add_argument("--app-name", type=str, help="Application name for baseline check.")
948 parser.add_argument("--scan-dir", type=pathlib.Path, help="Scan a directory for .elf files.")
949 parser.add_argument("--baseline", type=pathlib.Path, help="Path to baseline JSON file.")
950 parser.add_argument("--update-baseline", action="store_true", help="Update baseline file.")
951
952 args = parser.parse_args()
953 if args.selftest:
954 return selftest()
955
956 root = _repo_root()
957 baseline_path = args.baseline or (root / ".github" / "freestanding-runtime-baseline.json")
958 baseline = _load_baseline(baseline_path)
959 if baseline is None:
960 return 2
961 baseline.setdefault("linker_script_exceptions", [])
962
963 violations: list[str] = []
964
965 static_rc = _run_static_checks(args, root, baseline)
966 elfs_to_check = _collect_elfs(args.scan_dir, args.elf, args.map, args.app_name)
967 # Gate stages run in order and fail fast on the first nonzero status.
968 rc = static_rc or _process_elf_list(
969 elfs_to_check, baseline, violations, update=args.update_baseline
970 )
971 if rc != 0:
972 return rc
973 if args.scan_dir:
974 db_violations = check_target_db_flags(args.scan_dir / "compile_commands.json")
975 if db_violations:
976 print(
977 f"FATAL: Freestanding target misconfigured ({len(db_violations)}):",
978 file=sys.stderr,
979 )
980 for v in db_violations:
981 print(f" {v}", file=sys.stderr)
982 return 1
983
984 if args.update_baseline:
985 baseline_path.write_text(
986 json.dumps(baseline, indent=2, sort_keys=True) + "\n",
987 encoding="utf-8",
988 )
989 print(f"check_freestanding_runtime: updated baseline at {baseline_path}")
990 elif violations:
991 print(f"FATAL: Freestanding runtime violations ({len(violations)}):", file=sys.stderr)
992 for v in violations:
993 print(f" {v}", file=sys.stderr)
994 return 1
995
996 return 0
997
998
999if __name__ == "__main__":
1000 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298