ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_dynamic_alloc.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"""Enforce bounded first-party memory ownership in production code.
5
6Applies to RA8D2 firmware, first-party ESP32-C6 port code, and production
7host-tool code. Firmware rejects direct and known transitive allocation.
8Production tools reject every direct allocator; known third-party SOUP
9lifecycle calls remain visible as a non-blocking report because their opaque
10implementation is outside first-party ownership.
11
12Flags two classes of violation:
13
14 * **Direct allocator calls** -- C allocators such as malloc, calloc,
15 realloc, aligned_alloc, free, strdup, and asprintf, plus C++ ``new`` and
16 ``delete`` expressions.
17
18 * **Vendored helpers that allocate transitively** under the hood, so a
19 plain grep for malloc/free won't catch them:
20
21 - stb_truetype: stbtt_GetCodepointBitmap[Subpixel],
22 stbtt_GetGlyphBitmap[Subpixel],
23 stbtt_GetCodepointSDF, stbtt_GetGlyphSDF,
24 stbtt_GetCodepointShape, stbtt_GetGlyphShape,
25 stbtt_FreeBitmap, stbtt_FreeSDF, stbtt_FreeShape.
26 The no-alloc paths are the Make*/Box variants.
27
28 - stb_image: stbi_load, stbi_load_from_memory,
29 stbi_load_from_callbacks, stbi_loadf,
30 stbi_loadf_from_memory, stbi_image_free.
31
32 - miniz: mz_zip_reader_extract_to_heap,
33 mz_zip_reader_extract_file_to_heap.
34 Use the *_to_mem variants with a caller buffer.
35
36 - ESP-IDF HTTP: esp_http_client_init, esp_http_client_set_url,
37 esp_http_client_open, esp_http_client_close,
38 esp_http_client_cleanup. These allocate or release
39 transitive HTTP/TLS state and therefore require an exact,
40 reasoned exception until a fixed-memory backend replaces
41 the adapter.
42
43 - Host-tool SOUP boundaries: miniz writer lifecycle, libcurl easy
44 lifecycle/request calls, and POSIX spawn setup/execution. These APIs
45 may allocate transitively. They are reported for review, but do not
46 excuse a first-party ``malloc``/``free`` wrapper around them.
47
48Scope:
49
50 Firmware code under libs/ra8_*/, src/, port/ (every subdirectory except host-only
51 port/posix, which is scoped under tool policy), and examples/<app>/
52 where <app> has src/main.c + a root CMakeLists.txt is blocking. First-party production
53 C-family code under tools/ is blocking for direct allocation. Build outputs,
54 vendored code, and host-side tests/ are exempt.
55
56 Full sweeps enforce measured per-domain and aggregate file-count floors so a
57 collapsed firmware or tool walk cannot report a vacuous pass.
58
59Inline suppression:
60
61 Append `alloc-allow: <reason>` on the same line. The reason is
62 mandatory; bare `alloc-allow` with no reason is itself rejected.
63
64Example:
65 void* p = malloc(64); /* alloc-allow: bringup scratch, removed in v0.2 */
66
67Usage:
68
69 # explicit file list (used by pre-commit):
70 python3 scripts/checks/check_no_dynamic_alloc.py FILE [FILE ...]
71
72 # full-repo sweep (CI):
73 python3 scripts/checks/check_no_dynamic_alloc.py --all
74
75Exit code:
76 0 no violations
77 1 violations found
78 2 CLI usage error
79"""
80
81from __future__ import annotations
82
83import pathlib
84import re
85import sys
86import tempfile
87
88from doxy_lex import blank_noncode
89from lint_targets import firmware_app_dirs, is_build_output_path
90from suppression_catalog import ALLOC_ALLOW_RE
91from suppression_comment_lex import extract_comments
92
93REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
94SOURCE_SUFFIXES = {
95 ".c",
96 ".h",
97 ".cc",
98 ".cpp",
99 ".cxx",
100 ".hh",
101 ".hpp",
102 ".hxx",
103 ".inc",
104 ".m",
105 ".mm",
106}
107
108DIRECT_ALLOCATORS = (
109 "malloc",
110 "calloc",
111 "realloc",
112 "reallocarray",
113 "aligned_alloc",
114 "posix_memalign",
115 "valloc",
116 "memalign",
117 "free",
118 "strdup",
119 "strndup",
120 "asprintf",
121 "vasprintf",
122)
123
124FIRMWARE_TRANSITIVE_ALLOCATORS = (
125 "stbtt_GetCodepointBitmap",
126 "stbtt_GetCodepointBitmapSubpixel",
127 "stbtt_GetGlyphBitmap",
128 "stbtt_GetGlyphBitmapSubpixel",
129 "stbtt_GetCodepointSDF",
130 "stbtt_GetGlyphSDF",
131 "stbtt_GetCodepointShape",
132 "stbtt_GetGlyphShape",
133 "stbtt_FreeBitmap",
134 "stbtt_FreeSDF",
135 "stbtt_FreeShape",
136 "stbi_load",
137 "stbi_load_from_memory",
138 "stbi_load_from_callbacks",
139 "stbi_loadf",
140 "stbi_loadf_from_memory",
141 "stbi_image_free",
142 "mz_zip_reader_extract_to_heap",
143 "mz_zip_reader_extract_file_to_heap",
144 "esp_http_client_init",
145 "esp_http_client_set_url",
146 "esp_http_client_open",
147 "esp_http_client_close",
148 "esp_http_client_cleanup",
149)
150
151HOST_SOUP_ALLOCATORS = (
152 "mz_zip_writer_init_file",
153 "mz_zip_writer_add_file",
154 "mz_zip_writer_add_mem",
155 "mz_zip_writer_finalize_archive",
156 "mz_zip_writer_end",
157 "curl_global_init",
158 "curl_easy_init",
159 "curl_easy_setopt",
160 "curl_easy_perform",
161 "curl_easy_cleanup",
162 "curl_slist_append",
163 "curl_slist_free_all",
164 "posix_spawn_file_actions_init",
165 "posix_spawnattr_init",
166 "posix_spawn",
167 "posix_spawnp",
168)
169
170TRANSITIVE_ALLOCATORS = FIRMWARE_TRANSITIVE_ALLOCATORS + HOST_SOUP_ALLOCATORS
171ALL_NAMES = DIRECT_ALLOCATORS + TRANSITIVE_ALLOCATORS
172
173# Match "<name>(" at a word boundary so e.g. mz_free(...) does not match
174# free, and stbtt_GetCodepointBitmapBox does not match
175# stbtt_GetCodepointBitmap.
176SYM_RE = re.compile(r"\b(" + "|".join(re.escape(n) for n in ALL_NAMES) + r")\b\s*\‍(")
177CPP_DIRECT_RE = re.compile(r"\b(new|delete)\b")
178
179# Inline exemption hints are valid only inside lexical C-family comments.
180ALLOC_ALLOW_HINT_RE = re.compile(r"\balloc-allow\b", re.IGNORECASE)
181
182# Minimum number of argv entries needed for a file/flag argument to be present.
183MIN_ARGC_WITH_ARG = 2
184
185SCOPE_FILE_FLOORS = {"firmware": 850, "tool": 200}
186TOTAL_FILE_FLOOR = 1100
187
188
189def is_executable_occurrence(code: str, start: int, brace_depth: int) -> bool:
190 """Distinguish allocator uses from top-level function declarations."""
191 prefix = code[:start]
192 local_depth = brace_depth + prefix.count("{") - prefix.count("}")
193 if local_depth > 0:
194 return True
195 stripped = code.lstrip()
196 if stripped.startswith("#"):
197 return stripped.startswith("#define")
198 return any(marker in prefix for marker in ("=", "{", ":"))
199
200
201def allocation_symbols(code: str, brace_depth: int) -> list[str]:
202 """Return executable allocator symbols exactly as the checker governs them."""
203 symbols = [
204 match.group(1)
205 for match in SYM_RE.finditer(code)
206 if is_executable_occurrence(code, match.start(), brace_depth)
207 ]
208 if code.lstrip().startswith("#"):
209 return symbols
210 symbols.extend(
211 f"C++ {match.group(1)}"
212 for match in CPP_DIRECT_RE.finditer(code)
213 if is_executable_occurrence(code, match.start(), brace_depth)
214 )
215 return symbols
216
217
218def _firmware_scan_dirs() -> list[pathlib.Path]:
219 """The firmware directories this rule governs.
220
221 Deliberately narrower than the whole tree: host tools and tests may
222 allocate freely, and Rule 3 is a statement about what runs on the target.
223
224 ``apps/`` is the exception that has to be named. It is the products tier
225 and ``_tool_scan_dirs()`` claims all of it, but a firmware PRODUCT is not a
226 host program: the e-reader image runs on the chip, where Rule 3 says zero
227 dynamic allocation after init, and letting the products root swallow it
228 would quietly downgrade it to the report-only tool scope. The firmware
229 products are derived, not listed -- see ``lint_targets.firmware_app_dirs``
230 -- and ``_scope()`` tests the firmware roots first, so the narrower claim
231 wins.
232 """
233 out: list[pathlib.Path] = []
234 libs = REPO_ROOT / "libs"
235 if libs.is_dir():
236 out.extend(
237 entry
238 for entry in sorted(libs.iterdir())
239 if entry.is_dir() and entry.name.startswith("ra8_")
240 )
241 port = REPO_ROOT / "port"
242 if port.is_dir():
243 out.extend(
244 entry for entry in sorted(port.iterdir()) if entry.is_dir() and entry.name != "posix"
245 )
246 out.extend(REPO_ROOT / rel for rel in firmware_app_dirs())
247 examples = REPO_ROOT / "examples"
248 if examples.is_dir():
249 for path in sorted(examples.glob("**/src/main.c")):
250 app = path.parent.parent
251 if (app / "CMakeLists.txt").is_file():
252 out.append(app)
253 return out
254
255
256def _tool_scan_dirs() -> list[pathlib.Path]:
257 """First-party production host trees governed by explicit ownership.
258
259 Both roots, because both hold shipped host code: ``tools/`` the developer
260 utilities, ``apps/`` the products. They were one root until mdl moved
261 out of ``tools/``, at which point the file floor below caught the loss --
262 199 files against a floor of 200 -- rather than letting a whole product
263 stop being checked for direct allocator calls.
264 """
265 dirs = [
266 directory for directory in (REPO_ROOT / "tools", REPO_ROOT / "apps") if directory.is_dir()
267 ]
268 posix_port = REPO_ROOT / "port" / "posix"
269 if posix_port.is_dir():
270 dirs.append(posix_port)
271 return dirs
272
273
274FIRMWARE_SCAN_DIRS = _firmware_scan_dirs()
275TOOL_SCAN_DIRS = _tool_scan_dirs()
276# Vendor, test, and build trees are outside the production allocation ban.
277EXCLUDED_PARTS = frozenset({"third_party", "tests", "build", "_deps"})
278FIRMWARE_SCAN_RELS = [directory.relative_to(REPO_ROOT) for directory in FIRMWARE_SCAN_DIRS]
279TOOL_SCAN_RELS = [directory.relative_to(REPO_ROOT) for directory in TOOL_SCAN_DIRS]
280
281
282def _scope(path: pathlib.Path) -> str | None:
283 """Return ``firmware`` or ``tool`` for an in-scope production source."""
284 if path.suffix not in SOURCE_SUFFIXES:
285 return None
286 try:
287 rel = path.resolve().relative_to(REPO_ROOT)
288 except ValueError:
289 return None
290 rel_str = str(rel)
291 if any(part in EXCLUDED_PARTS for part in rel.parts) or is_build_output_path(rel.as_posix()):
292 return None
293 if any(
294 rel_str == str(directory) or rel_str.startswith(str(directory) + "/")
295 for directory in FIRMWARE_SCAN_RELS
296 ):
297 return "firmware"
298 if any(
299 rel_str == str(directory) or rel_str.startswith(str(directory) + "/")
300 for directory in TOOL_SCAN_RELS
301 ):
302 return "tool"
303 return None
304
305
306def _is_in_scope(path: pathlib.Path) -> bool:
307 """Return whether ``path`` belongs to a blocking or report-only scope."""
308 return _scope(path) is not None
309
310
311def _allocation_controls(path: pathlib.Path, text: str) -> tuple[set[int], list[tuple[int, str]]]:
312 """Return reasoned control lines and malformed allocation controls."""
313 control_lines: set[int] = set()
314 malformed: list[tuple[int, str]] = []
315 comments, _ = extract_comments(path.as_posix(), text)
316 for comment in comments:
317 if ALLOC_ALLOW_HINT_RE.search(comment.text) is None:
318 continue
319 if ALLOC_ALLOW_RE.search(comment.text.strip()) is not None:
320 control_lines.add(comment.line)
321 else:
322 malformed.append((comment.line, comment.text.strip()))
323 return control_lines, malformed
324
325
326def _allocation_occurrences(stripped_lines: list[str]) -> list[tuple[int, str]]:
327 """Return executable allocator uses while excluding declarations."""
328 occurrences: list[tuple[int, str]] = []
329 brace_depth = 0
330 for line_no, stripped in enumerate(stripped_lines, start=1):
331 preprocessor = stripped.lstrip().startswith("#")
332 for symbol in allocation_symbols(stripped, brace_depth):
333 direct = symbol.startswith("C++ ") or symbol in DIRECT_ALLOCATORS
334 suffix = "" if symbol.startswith("C++ ") else "()"
335 kind = "direct" if direct else "transitive"
336 occurrences.append((line_no, f"{kind} dynamic allocation: {symbol}{suffix}"))
337 if not preprocessor:
338 brace_depth += stripped.count("{") - stripped.count("}")
339 brace_depth = max(0, brace_depth)
340 return occurrences
341
342
343def check(path: pathlib.Path) -> list[str]:
344 """Report every dynamic-allocation call in one production source file.
345
346 An unreadable file yields an empty list rather than raising, so one bad
347 file cannot abort the sweep.
348 """
349 try:
350 text = path.read_text(encoding="utf-8")
351 except (OSError, UnicodeDecodeError):
352 return []
353
354 original_lines = text.splitlines()
355 stripped_lines = blank_noncode(text)[0].splitlines()
356 control_lines, malformed_controls = _allocation_controls(path, text)
357 occurrences = _allocation_occurrences(stripped_lines)
358 allocator_lines = {line_no for line_no, _ in occurrences}
359 allowed_lines = control_lines & allocator_lines
360 problems = [
361 f"{path}:{line_no}: {detail} -- {original_lines[line_no - 1].strip()}"
362 for line_no, detail in occurrences
363 if line_no not in allowed_lines
364 ]
365 problems.extend(
366 f"{path}:{line_no}: alloc-allow without reason -- {control}"
367 for line_no, control in malformed_controls
368 )
369 problems.extend(
370 f"{path}:{line_no}: alloc-allow without governed allocation"
371 for line_no in sorted(control_lines - allocator_lines)
372 )
373 return problems
374
375
376def selftest() -> int:
377 """Prove executable allocators fire and declarations/reasoned controls do not."""
378 with tempfile.TemporaryDirectory(prefix="dynamic-alloc-selftest-") as raw:
379 root = pathlib.Path(raw)
380 bad = root / "bad.c"
381 good = root / "good.c"
382 bad.write_text(
383 "void f(void) { void *p = malloc(4); stbi_load(0, 0, 0, 0, 0); }\n",
384 encoding="ascii",
385 )
386 good.write_text(
387 "void *malloc(size_t size);\n"
388 "void f(void) { void *p = malloc(4); /* alloc-allow: fixed startup owner */ }\n"
389 "// free(p) is prose\n",
390 encoding="ascii",
391 )
392 bad_findings = check(bad)
393 good_findings = check(good)
394 expected_bad_findings = 2
395 target_port_c = REPO_ROOT / "port" / "threadx" / "src" / "dummy.c"
396 posix_port_c = REPO_ROOT / "port" / "posix" / "src" / "ra8_io_stream_posix.c"
397 scope_target_port_ok = _scope(target_port_c) == "firmware"
398 scope_posix_port_ok = _scope(posix_port_c) == "tool"
399
400 cases = (
401 (
402 len(bad_findings) == expected_bad_findings
403 and any("direct dynamic allocation" in item for item in bad_findings)
404 and any("transitive dynamic allocation" in item for item in bad_findings),
405 "direct and known transitive executable allocators fire",
406 ),
407 (not good_findings, "declarations, prose, and reasoned controls stay quiet"),
408 (scope_target_port_ok, "target port files are scoped under firmware"),
409 (scope_posix_port_ok, "host port/posix files are scoped under tool policy"),
410 )
411 failed = [label for passed, label in cases if not passed]
412 for passed, label in cases:
413 print(f" [{'ok' if passed else 'FAIL'}] {label}")
414 if failed:
415 print(f"check_no_dynamic_alloc.py --selftest: {len(failed)} failure(s)", file=sys.stderr)
416 return 1
417 print("check_no_dynamic_alloc.py --selftest: all cases pass (both directions).")
418 return 0
419
420
421def collect_repo_paths() -> list[pathlib.Path]:
422 """Every source file in the blocking and report-only production scopes."""
423 directories = FIRMWARE_SCAN_DIRS + TOOL_SCAN_DIRS
424 return [
425 path for directory in directories for path in directory.rglob("*") if _is_in_scope(path)
426 ]
427
428
429def _scope_floor_errors(counts: dict[str, int]) -> list[str]:
430 """Describe every collapsed full-sweep domain or aggregate."""
431 errors = [
432 f"{scope} enumerated {counts.get(scope, 0)} source file(s); floor is {floor}"
433 for scope, floor in SCOPE_FILE_FLOORS.items()
434 if counts.get(scope, 0) < floor
435 ]
436 total = sum(counts.values())
437 if total < TOTAL_FILE_FLOOR:
438 errors.append(f"total scope enumerated {total} source file(s); floor is {TOTAL_FILE_FLOOR}")
439 return errors
440
441
442def _collect_all_validated() -> list[pathlib.Path] | None:
443 """Return the full nonvacuous scope, or report a collapsed enumeration."""
444 paths = collect_repo_paths()
445 counts = {scope: sum(_scope(path) == scope for path in paths) for scope in SCOPE_FILE_FLOORS}
446 floor_errors = _scope_floor_errors(counts)
447 if not floor_errors:
448 return paths
449 print(
450 "check_no_dynamic_alloc.py: FATAL -- " + "; ".join(floor_errors),
451 file=sys.stderr,
452 )
453 return None
454
455
456def _requested_paths() -> tuple[list[pathlib.Path] | None, int]:
457 """Resolve CLI scope, returning a nonzero status when it is unusable."""
458 if len(sys.argv) >= MIN_ARGC_WITH_ARG and sys.argv[1] == "--all":
459 paths = _collect_all_validated()
460 return paths, 0 if paths is not None else 2
461 if len(sys.argv) >= MIN_ARGC_WITH_ARG:
462 return [pathlib.Path(path).resolve() for path in sys.argv[1:]], 0
463 print(
464 "usage: check_no_dynamic_alloc.py FILE [FILE ...] | --all",
465 file=sys.stderr,
466 )
467 return None, 2
468
469
470def main() -> int:
471 """Fail when firmware or first-party production tools allocate directly.
472
473 Firmware also rejects known transitive allocators. Production tools report
474 third-party SOUP lifecycle calls while rejecting direct first-party
475 ownership. Tests remain outside this production policy.
476
477 Returns 1 listing each blocking call site, 0 when both production domains
478 are clean, and 2 for invalid usage or a collapsed full sweep.
479 """
480 args = sys.argv[1:]
481 if args == ["--selftest"]:
482 return selftest()
483 if any(arg.startswith("-") and arg != "--all" for arg in args) or (
484 "--all" in args and args != ["--all"]
485 ):
486 print(
487 "usage: check_no_dynamic_alloc.py FILE [FILE ...] | --all | --selftest",
488 file=sys.stderr,
489 )
490 return 2
491 paths, error = _requested_paths()
492 if paths is None:
493 return error
494
495 failures: list[str] = []
496 reports: list[str] = []
497 for path in paths:
498 if not path.is_file():
499 continue
500 if not _is_in_scope(path):
501 continue
502 problems = check(path)
503 scope = _scope(path)
504 if scope == "firmware":
505 failures.extend(problems)
506 else:
507 failures.extend(
508 problem for problem in problems if ": direct dynamic allocation:" in problem
509 )
510 reports.extend(
511 problem for problem in problems if ": transitive dynamic allocation:" in problem
512 )
513
514 if reports:
515 print(
516 "check_no_dynamic_alloc.py: host-tool SOUP allocation report "
517 "(non-blocking third-party boundary)."
518 )
519 for line in reports:
520 print(line)
521 print(f"\n{len(reports)} host SOUP call site(s) reported; these rows are non-blocking.")
522
523 if failures:
524 print(
525 "check_no_dynamic_alloc.py: dynamic allocation in firmware or first-party tool code.",
526 file=sys.stderr,
527 )
528 for line in failures:
529 print(line, file=sys.stderr)
530 print(f"\n{len(failures)} violation(s) found.", file=sys.stderr)
531 print(
532 "If a call site is genuinely OK (host-only test path, "
533 "vendored glue you cannot move), append "
534 "`alloc-allow: <reason>` on the same line.",
535 file=sys.stderr,
536 )
537 return 1
538 return 0
539
540
541if __name__ == "__main__":
542 raise SystemExit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298