ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
freestanding_runtime_selftest.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"""Both-direction selftests for the freestanding runtime ratchet.
5
6Exercises map/nm parsing, linker-script checks, baseline ratchet evaluation
7for unknown, indebted, and zero-debt apps, archive allowlist policy, source
8assert scanning, and RA8_FREESTANDING configuration. Imported lazily by
9check_freestanding_runtime.selftest() so the detector module stays small.
10"""
11
12from __future__ import annotations
13
14import json
15import pathlib
16import tempfile
17from typing import Any
18
19import check_freestanding_runtime as core
20
21
22def _selftest_nm_and_map() -> list[str]:
23 """Test nm parsing and map file parsing."""
24 failures: list[str] = []
25 mock_nm = """
260200109e T memset
270200e50c T malloc
280200e51c T free
290200e4d0 T __assert_func
3002001234 T _sbrk
31220801c8 B end
3202003000 T __aeabi_uldivmod
33"""
34 syms = core.parse_nm_symbols(mock_nm)
35 if "memset" not in syms or "malloc" not in syms or "__aeabi_uldivmod" not in syms:
36 failures.append("nm symbol parser missed symbols")
37
38 mock_map = """
39Archive member included to satisfy reference by file (symbol)
40
41.../libg_nano.a(libc_a-memcmp.o)
42.../libg_nano.a(libc_a-memset.o)
43.../libnosys.a(close.o)
44
45Discarded input sections
46
47 .text 0x00000000 0x60 .../libg_nano.a(libc_a-memcmp.o)
48 .text._sbrk 0x00000000 0x14 CMakeFiles/blink.elf.dir/.../ra8_sbrk_trap.c.obj
49
50Memory Map
51
52LOAD .../libnosys.a
53
54.text 0x02000000 0x1000
55 .text.memset 0x0200109e 0x10 .../libg_nano.a(libc_a-memset.o)
56 0x0200109e memset
57 .text._sbrk 0x02001234 0x14 CMakeFiles/app.elf.dir/.../ra8_sbrk_trap.c.obj
58 0x02001234 _sbrk
59"""
60 map_res = core.parse_map_file(mock_map)
61 if "libc_a-memcmp.o" in map_res["live_archive_members"].get("libg_nano.a", set()):
62 failures.append("discarded archive member classified as live")
63 if "libc_a-memset.o" not in map_res["live_archive_members"].get("libg_nano.a", set()):
64 failures.append("live archive member not detected")
65 if "close.o" in map_res["live_archive_members"].get("libnosys.a", set()):
66 failures.append("LOAD-only archive member classified as live")
67 if "ra8_sbrk_trap" not in map_res["symbol_providers"].get("_sbrk", ""):
68 failures.append(f"_sbrk provider incorrect: {map_res['symbol_providers'].get('_sbrk')}")
69
70 mock_bad_map = """
71Memory Map
72.text 0x02000000 0x1000
73 .text._sbrk 0x02001234 0x20 .../libnosys.a(sbrk.o)
74 0x02001234 _sbrk
75"""
76 bad_map_res = core.parse_map_file(mock_bad_map)
77 if "sbrk.o" not in bad_map_res["symbol_providers"].get("_sbrk", ""):
78 failures.append("libnosys sbrk provider was not flagged")
79
80 return failures
81
82
83def _selftest_linker_scripts() -> list[str]:
84 """Test linker script verification."""
85 failures: list[str] = []
86 clean_ld = "SECTIONS { .text : { *(.text*) } > SRAM }"
87 bad_ld_end = "SECTIONS { PROVIDE(end = .); }"
88 bad_ld_heap = "SECTIONS { .heap : { *(._heap*) } > SRAM }"
89
90 if core.check_linker_script(clean_ld, "clean.ld"):
91 failures.append("clean linker script flagged")
92 if not core.check_linker_script(bad_ld_end, "bad_end.ld"):
93 failures.append("linker script defining 'end' not flagged")
94 if not core.check_linker_script(bad_ld_heap, "bad_heap.ld"):
95 failures.append("linker script defining '.heap' not flagged")
96 return failures
97
98
99def _selftest_new_app_policy(
100 mock_baseline: dict[str, Any], clean_analysis: dict[str, Any]
101) -> list[str]:
102 """Test strict symbol and forbidden-archive policy for unknown apps."""
103 failures: list[str] = []
104 if core.evaluate_against_baseline("new_app", clean_analysis, mock_baseline):
105 failures.append("clean app with libgcc helpers flagged")
106
107 bad_sym = dict(clean_analysis, live_forbidden_symbols=["malloc"])
108 if not core.evaluate_against_baseline("new_app", bad_sym, mock_baseline):
109 failures.append("new forbidden malloc not flagged")
110
111 bad_prim_src = dict(
112 clean_analysis,
113 runtime_primitive_providers={"memset": "libg_nano.a(libc_a-memset.o)"},
114 )
115 if not core.evaluate_against_baseline("new_app", bad_prim_src, mock_baseline):
116 failures.append("forbidden runtime primitive provider not flagged")
117
118 bad_nosys = dict(clean_analysis, live_archive_members={"libnosys.a": ["close.o"]})
119 if not core.evaluate_against_baseline("new_app", bad_nosys, mock_baseline):
120 failures.append("live libnosys member not flagged")
121
122 bad_nano = dict(clean_analysis, live_archive_members={"libg_nano.a": ["libc_a-memset.o"]})
123 if not core.evaluate_against_baseline("new_app", bad_nano, mock_baseline):
124 failures.append("live newlib-nano member not flagged")
125
126 bad_posix_sym = dict(clean_analysis, posix_symbols=["ra8_io_stream_posix_bind"])
127 if not core.evaluate_against_baseline("new_app", bad_posix_sym, mock_baseline):
128 failures.append("forbidden host-only POSIX symbol not flagged")
129
130 bad_posix_obj = dict(clean_analysis, posix_providers={"io": "ra8_io_stream_posix.c.obj"})
131 if not core.evaluate_against_baseline("new_app", bad_posix_obj, mock_baseline):
132 failures.append("forbidden host-only POSIX provider not flagged")
133 failures.extend(_selftest_new_app_archives(mock_baseline, clean_analysis))
134 return failures
135
136
137def _selftest_new_app_archives(
138 mock_baseline: dict[str, Any], clean_analysis: dict[str, Any]
139) -> list[str]:
140 """Test allowlist policy for compiler and unknown archives."""
141 failures: list[str] = []
142 libm_ok = dict(
143 clean_analysis,
144 live_archive_members={"libgcc.a": ["_aeabi_uldivmod.o"], "libm.a": ["libm_a-wf_acos.o"]},
145 system_archives=["libgcc.a", "libm.a"],
146 )
147 if core.evaluate_against_baseline("new_app", libm_ok, mock_baseline):
148 failures.append("approved libm member unexpectedly flagged")
149
150 libm_unapproved = dict(
151 clean_analysis,
152 live_archive_members={"libm.a": ["libm_a-unapproved.o"]},
153 system_archives=["libm.a"],
154 )
155 if not core.evaluate_against_baseline("new_app", libm_unapproved, mock_baseline):
156 failures.append("unapproved live libm member not flagged")
157
158 arbitrary_arch = dict(
159 clean_analysis, live_archive_members={"libfoo.a": ["foo.o"]}, system_archives=["libfoo.a"]
160 )
161 if not core.evaluate_against_baseline("new_app", arbitrary_arch, mock_baseline):
162 failures.append("arbitrary unknown live archive not flagged")
163
164 libm_discarded = dict(
165 clean_analysis, discarded_archive_members={"libm.a": ["libm_a-unapproved.o"]}
166 )
167 if core.evaluate_against_baseline("new_app", libm_discarded, mock_baseline):
168 failures.append("discarded libm member unexpectedly flagged")
169
170 libm_forbidden_sym = dict(libm_ok, live_forbidden_symbols=["malloc"])
171 if not core.evaluate_against_baseline("new_app", libm_forbidden_sym, mock_baseline):
172 failures.append("approved libm member that introduces forbidden symbol not flagged")
173 return failures
174
175
176def _selftest_live_archive_fail_closed(
177 mock_baseline: dict[str, Any], clean_analysis: dict[str, Any]
178) -> list[str]:
179 """Unknown live archives fail closed with or without system classification."""
180 failures: list[str] = []
181 unknown_live = dict(clean_analysis, live_archive_members={"libevil.a": ["evil.o"]})
182 if not core.evaluate_against_baseline("new_app", unknown_live, mock_baseline):
183 failures.append("unknown live archive without system tag not flagged")
184
185 unknown_sys = dict(unknown_live, system_archives=["libevil.a"])
186 if not core.evaluate_against_baseline("new_app", unknown_sys, mock_baseline):
187 failures.append("unknown live archive with system tag not flagged")
188
189 project_live = dict(clean_analysis, live_archive_members={"libthreadx.a": ["tx_x.o"]})
190 if core.evaluate_against_baseline("new_app", project_live, mock_baseline):
191 failures.append("approved project archive unexpectedly flagged")
192
193 project_prim = dict(
194 clean_analysis,
195 runtime_primitive_providers={"memset": "libra8_shared_ek_ra8d2.a(m.o)"},
196 )
197 if core.evaluate_against_baseline("new_app", project_prim, mock_baseline):
198 failures.append("primitive from approved project archive unexpectedly flagged")
199
200 unknown_dead = dict(clean_analysis, discarded_archive_members={"libevil.a": ["evil.o"]})
201 if core.evaluate_against_baseline("new_app", unknown_dead, mock_baseline):
202 failures.append("discarded unknown archive unexpectedly flagged")
203
204 heavy_unknown = dict(
205 clean_analysis,
206 live_archive_members={"libevil.a": ["evil.o"]},
207 sbrk_present=True,
208 sbrk_provider="ra8_sbrk_trap.c.obj",
209 )
210 if not core.evaluate_against_baseline("heavy_app", heavy_unknown, mock_baseline):
211 failures.append("debt-baselined app with unknown live archive not flagged")
212 return failures
213
214
215def _selftest_debt_ratchet(mock_baseline: dict[str, Any]) -> list[str]:
216 """Test that recorded debt ratchets without authorizing new archives."""
217 failures: list[str] = []
218 heavy_ok = {
219 "live_forbidden_symbols": ["malloc", "free"],
220 "live_archive_members": {"libnosys.a": ["close.o"]},
221 "sbrk_present": True,
222 "sbrk_provider": "ra8_sbrk_trap.c.obj",
223 "end_symbol_present": False,
224 "has_heap_section": False,
225 }
226 if core.evaluate_against_baseline("heavy_app", heavy_ok, mock_baseline):
227 failures.append("heavy app within baseline flagged")
228
229 heavy_realloc = dict(heavy_ok, live_forbidden_symbols=["malloc", "free", "realloc"])
230 if not core.evaluate_against_baseline("heavy_app", heavy_realloc, mock_baseline):
231 failures.append("debt expansion not flagged")
232
233 # Recorded debt never authorizes an unapproved archive: the allowlist is
234 # absolute on the ratchet path too.
235 heavy_libm = dict(
236 heavy_ok,
237 live_archive_members={"libnosys.a": ["close.o"], "libm.a": ["libm_a-unapproved.o"]},
238 system_archives=["libm.a"],
239 )
240 if not core.evaluate_against_baseline("heavy_app", heavy_libm, mock_baseline):
241 failures.append("debt-baselined app with unapproved libm member not flagged")
242
243 heavy_unknown = dict(
244 heavy_ok,
245 live_archive_members={"libfoo.a": ["foo.o"]},
246 system_archives=["libfoo.a"],
247 )
248 if not core.evaluate_against_baseline("heavy_app", heavy_unknown, mock_baseline):
249 failures.append("debt-baselined app with unknown live archive not flagged")
250 return failures
251
252
253def _selftest_zero_debt(clean_analysis: dict[str, Any]) -> list[str]:
254 """Test that zero-debt baselines enforce strict invariants."""
255 failures: list[str] = []
256 zero_debt_baseline = {
257 "apps": {
258 "clean_app": {
259 "forbidden_symbols": [],
260 "forbidden_archives": {},
261 "sbrk_provider": "none",
262 "end_symbol": False,
263 }
264 }
265 }
266
267 if core.evaluate_against_baseline("clean_app", clean_analysis, zero_debt_baseline):
268 failures.append("clean zero-debt baselined app unexpectedly flagged")
269
270 bad_zero_prim = dict(
271 clean_analysis,
272 runtime_primitive_providers={"memset": "libg_nano.a(libc_a-memset.o)"},
273 )
274 bad_zero_sym = dict(clean_analysis, live_forbidden_symbols=["malloc"])
275 if not core.evaluate_against_baseline("clean_app", bad_zero_sym, zero_debt_baseline):
276 failures.append("zero-debt baselined app with forbidden symbol not flagged")
277
278 bad_zero_arch = dict(clean_analysis, live_archive_members={"libnosys.a": ["close.o"]})
279 if not core.evaluate_against_baseline("clean_app", bad_zero_arch, zero_debt_baseline):
280 failures.append("zero-debt baselined app with forbidden archive member not flagged")
281
282 bad_zero_end = dict(clean_analysis, end_symbol_present=True)
283 if not core.evaluate_against_baseline("clean_app", bad_zero_end, zero_debt_baseline):
284 failures.append("zero-debt baselined app with end symbol not flagged")
285
286 bad_zero_heap = dict(clean_analysis, has_heap_section=True)
287 if not core.evaluate_against_baseline("clean_app", bad_zero_heap, zero_debt_baseline):
288 failures.append("zero-debt baselined app with .heap section not flagged")
289
290 if not core.evaluate_against_baseline("clean_app", bad_zero_prim, zero_debt_baseline):
291 failures.append(
292 "zero-debt baselined app with runtime primitive from libg_nano.a not flagged"
293 )
294
295 bad_zero_sbrk = dict(
296 clean_analysis,
297 sbrk_present=True,
298 sbrk_provider="ra8_sbrk_trap.c.obj",
299 )
300 if not core.evaluate_against_baseline("clean_app", bad_zero_sbrk, zero_debt_baseline):
301 failures.append("zero-debt baselined app with newly-live _sbrk not flagged")
302
303 bad_zero_posix = dict(clean_analysis, posix_symbols=["ra8_io_stream_posix_bind"])
304 if not core.evaluate_against_baseline("clean_app", bad_zero_posix, zero_debt_baseline):
305 failures.append("zero-debt baselined app with POSIX symbol not flagged")
306
307 bad_zero_posix_obj = dict(clean_analysis, posix_providers={"io": "ra8_io_stream_posix.c.obj"})
308 if not core.evaluate_against_baseline("clean_app", bad_zero_posix_obj, zero_debt_baseline):
309 failures.append("zero-debt baselined app with POSIX provider not flagged")
310
311 return failures
312
313
314def _selftest_ratchet_eval() -> list[str]:
315 """Test baseline ratchet evaluation."""
316 mock_baseline = {
317 "apps": {
318 "heavy_app": {
319 "forbidden_symbols": ["malloc", "free"],
320 "forbidden_archives": {"libnosys.a": ["close.o"]},
321 "sbrk_provider": "ra8_sbrk_trap.c.obj",
322 "end_symbol": False,
323 }
324 }
325 }
326
327 clean_analysis = {
328 "live_forbidden_symbols": [],
329 "live_archive_members": {"libgcc.a": ["_aeabi_uldivmod.o"]},
330 "system_archives": ["libgcc.a"],
331 "runtime_primitive_providers": {"memset": "ra8_freestanding_mem.c.obj"},
332 "sbrk_present": False,
333 "sbrk_provider": "none",
334 "end_symbol_present": False,
335 "has_heap_section": False,
336 }
337 return (
338 _selftest_new_app_policy(mock_baseline, clean_analysis)
339 + _selftest_live_archive_fail_closed(mock_baseline, clean_analysis)
340 + _selftest_debt_ratchet(mock_baseline)
341 + _selftest_zero_debt(clean_analysis)
342 )
343
344
345def _selftest_scripts_and_ratchet() -> list[str]:
346 """Test linker script verification and baseline ratchet evaluation."""
347 return _selftest_linker_scripts() + _selftest_ratchet_eval()
348
349
350def _selftest_source_asserts(repo_root: pathlib.Path) -> list[str]:
351 """Test source assert scanner."""
352 failures: list[str] = []
353 clean_src = 'void f(void) { static_assert(1, "ok"); RA8_ASSERT(x > 0, "msg"); }'
354 bad_inc = "#include <assert.h>\nvoid f(void) {}"
355 bad_call = "void f(int x) { assert(x > 0); }"
356
357 if core.check_source_asserts(clean_src, "clean.c"):
358 failures.append("clean source with static_assert/RA8_ASSERT flagged")
359 if not core.check_source_asserts(bad_inc, "bad_inc.c"):
360 failures.append("<assert.h> include not flagged")
361 if not core.check_source_asserts(bad_call, "bad_call.c"):
362 failures.append("runtime assert() call not flagged")
363
364 # Target esp-hosted assert must fail
365 esp_hosted_bad = "void f(void) { assert(bus != NULL); }"
366 if not core.check_source_asserts(esp_hosted_bad, "port/esp-hosted/src/ra8_esp_hosted_spi.c"):
367 failures.append("target esp-hosted assert() not flagged")
368
369 macro_redirect = (
370 '#include "ra8_check.h"\n'
371 "#ifndef assert\n"
372 '#define assert(expr) RA8_ASSERT(expr, "msg")\n'
373 "#endif\n"
374 )
375 if core.check_source_asserts(macro_redirect, "port/esp-hosted/inc/port_esp_hosted_host_os.h"):
376 failures.append("macro redirect #define assert(...) incorrectly flagged")
377
378 # POSIX fail-closed guard verification (repo-rooted so the check cannot
379 # silently skip when run from another working directory).
380 posix_header = repo_root / "port" / "posix" / "inc" / "ra8_io_stream_posix.h"
381 if not posix_header.is_file():
382 failures.append("port/posix/inc/ra8_io_stream_posix.h not found for guard check")
383 else:
384 text = posix_header.read_text(encoding="utf-8")
385 if "#ifndef RA8_OFF_TARGET" not in text or "#error" not in text:
386 failures.append("port/posix/inc/ra8_io_stream_posix.h missing fail-closed guard")
387
388 return failures
389
390
391def _selftest_provider_policy(
392 mock_baseline: dict[str, Any], clean_analysis: dict[str, Any]
393) -> list[str]:
394 """Test primitive-provider and project-archive policy for unknown apps."""
395 failures: list[str] = []
396 project_arch = dict(
397 clean_analysis,
398 live_archive_members={"libthreadx.a": ["tx_thread_create.c.obj"]},
399 system_archives=[],
400 )
401 if core.evaluate_against_baseline("new_app", project_arch, mock_baseline):
402 failures.append("project-built archive unexpectedly flagged")
403
404 prim_unknown = dict(
405 clean_analysis,
406 runtime_primitive_providers={"memcpy": "libfoo.a(foo.o)"},
407 )
408 if not core.evaluate_against_baseline("new_app", prim_unknown, mock_baseline):
409 failures.append("primitive resolved to unknown archive not flagged")
410
411 prim_libm_bad = dict(
412 clean_analysis,
413 live_archive_members={"libm.a": ["libm_a-unapproved.o"]},
414 system_archives=["libm.a"],
415 runtime_primitive_providers={"strlen": "libm.a(libm_a-unapproved.o)"},
416 )
417 if not core.evaluate_against_baseline("new_app", prim_libm_bad, mock_baseline):
418 failures.append("primitive resolved to unapproved libm member not flagged")
419
420 prim_libm_ok = dict(
421 clean_analysis,
422 live_archive_members={"libm.a": ["libm_a-wf_acos.o"]},
423 system_archives=["libm.a"],
424 runtime_primitive_providers={"strlen": "ra8_freestanding_str.c.obj"},
425 )
426 if core.evaluate_against_baseline("new_app", prim_libm_ok, mock_baseline):
427 failures.append("project-resolved primitive with live libm unexpectedly flagged")
428
429 prim_pathed = dict(
430 clean_analysis,
431 runtime_primitive_providers={"memset": ".../libgcc.a(_arm_muldf3.o)"},
432 )
433 if core.evaluate_against_baseline("new_app", prim_pathed, mock_baseline):
434 failures.append("path-form allowed archive provider unexpectedly flagged")
435 return failures
436
437
438def _selftest_db_flags() -> list[str]:
439 """Test RA8_FREESTANDING compile-database enforcement both directions."""
440 failures: list[str] = []
441 flagged = [{"file": "a.c", "command": "cc -DRA8_FREESTANDING -c a.c"}]
442 if core.check_db_entries(flagged, must_have=True, exempt=None, label="Target"):
443 failures.append("fully flagged target database unexpectedly flagged")
444 if not core.check_target_db_flags(pathlib.Path("nonexistent-db.json")):
445 failures.append("missing target database path not flagged")
446 missing_flag = [*flagged, {"file": "b.c", "command": "cc -O2 -c b.c"}]
447 if not core.check_db_entries(missing_flag, must_have=True, exempt=None, label="Target"):
448 failures.append("target database entry missing the flag not flagged")
449
450 stray_flag = [{"file": "h.c", "command": "cc -DRA8_FREESTANDING -c h.c"}]
451 if not core.check_db_entries(
452 stray_flag, must_have=False, exempt="-DRA8_TEST_FREESTANDING", label="Host"
453 ):
454 failures.append("host database entry setting the flag not flagged")
455
456 exempt_flag = [
457 {"file": "t.c", "command": "cc -DRA8_FREESTANDING -DRA8_TEST_FREESTANDING -c t.c"}
458 ]
459 if core.check_db_entries(
460 exempt_flag, must_have=False, exempt="-DRA8_TEST_FREESTANDING", label="Host"
461 ):
462 failures.append("exempt freestanding-test host entry unexpectedly flagged")
463
464 with tempfile.TemporaryDirectory() as tmp:
465 root = pathlib.Path(tmp)
466 db_dir = root / "examples" / "demo" / "build"
467 db_dir.mkdir(parents=True)
468 db_path = db_dir / "compile_commands.json"
469 db_path.write_text(json.dumps(flagged), encoding="utf-8")
470 if core.discover_target_dbs(root) != [db_path]:
471 failures.append("per-app target database not discovered")
472 if core.check_discovered_target_dbs(root):
473 failures.append("flagged per-app target database unexpectedly flagged")
474 db_path.write_text(json.dumps(missing_flag), encoding="utf-8")
475 if not core.check_discovered_target_dbs(root):
476 failures.append("unflagged per-app target database not flagged")
477 db_path.write_text("[]", encoding="utf-8")
478 if not core.check_discovered_target_dbs(root):
479 failures.append("empty per-app target database not flagged")
480 if not core.check_target_db_flags(root / "examples" / "demo" / "none.json"):
481 failures.append("missing target database not flagged")
482 return failures
483
484
485def run_selftests(repo_root: pathlib.Path) -> list[str]:
486 """Run every freestanding-runtime selftest; return failure strings."""
487 failures = _selftest_nm_and_map() + _selftest_scripts_and_ratchet()
488 failures.extend(_selftest_source_asserts(repo_root))
489 failures.extend(_selftest_db_flags())
490 mock_baseline: dict[str, Any] = {"apps": {}}
491 clean_analysis: dict[str, Any] = {
492 "live_forbidden_symbols": [],
493 "live_archive_members": {"libgcc.a": ["_aeabi_uldivmod.o"]},
494 "system_archives": ["libgcc.a"],
495 "runtime_primitive_providers": {"memset": "ra8_freestanding_mem.c.obj"},
496 "sbrk_present": False,
497 "sbrk_provider": "none",
498 "end_symbol_present": False,
499 "has_heap_section": False,
500 }
501 failures.extend(_selftest_provider_policy(mock_baseline, clean_analysis))
502 return failures