4"""Enforce bounded first-party memory ownership in production code.
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.
12Flags two classes of violation:
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.
18 * **Vendored helpers that allocate transitively** under the hood, so a
19 plain grep for malloc/free won't catch them:
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.
28 - stb_image: stbi_load, stbi_load_from_memory,
29 stbi_load_from_callbacks, stbi_loadf,
30 stbi_loadf_from_memory, stbi_image_free.
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.
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
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.
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.
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.
61 Append `alloc-allow: <reason>` on the same line. The reason is
62 mandatory; bare `alloc-allow` with no reason is itself rejected.
65 void* p = malloc(64); /* alloc-allow: bringup scratch, removed in v0.2 */
69 # explicit file list (used by pre-commit):
70 python3 scripts/checks/check_no_dynamic_alloc.py FILE [FILE ...]
72 # full-repo sweep (CI):
73 python3 scripts/checks/check_no_dynamic_alloc.py --all
81from __future__
import annotations
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
93REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
124FIRMWARE_TRANSITIVE_ALLOCATORS = (
125 "stbtt_GetCodepointBitmap",
126 "stbtt_GetCodepointBitmapSubpixel",
127 "stbtt_GetGlyphBitmap",
128 "stbtt_GetGlyphBitmapSubpixel",
129 "stbtt_GetCodepointSDF",
131 "stbtt_GetCodepointShape",
132 "stbtt_GetGlyphShape",
137 "stbi_load_from_memory",
138 "stbi_load_from_callbacks",
140 "stbi_loadf_from_memory",
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",
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",
163 "curl_slist_free_all",
164 "posix_spawn_file_actions_init",
165 "posix_spawnattr_init",
170TRANSITIVE_ALLOCATORS = FIRMWARE_TRANSITIVE_ALLOCATORS + HOST_SOUP_ALLOCATORS
171ALL_NAMES = DIRECT_ALLOCATORS + TRANSITIVE_ALLOCATORS
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")
180ALLOC_ALLOW_HINT_RE = re.compile(
r"\balloc-allow\b", re.IGNORECASE)
185SCOPE_FILE_FLOORS = {
"firmware": 850,
"tool": 200}
186TOTAL_FILE_FLOOR = 1100
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(
"}")
195 stripped = code.lstrip()
196 if stripped.startswith(
"#"):
197 return stripped.startswith(
"#define")
198 return any(marker
in prefix
for marker
in (
"=",
"{",
":"))
201def allocation_symbols(code: str, brace_depth: int) -> list[str]:
202 """Return executable allocator symbols exactly as the checker governs them."""
205 for match
in SYM_RE.finditer(code)
206 if is_executable_occurrence(code, match.start(), brace_depth)
208 if code.lstrip().startswith(
"#"):
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)
218def _firmware_scan_dirs() -> list[pathlib.Path]:
219 """The firmware directories this rule governs.
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.
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
233 out: list[pathlib.Path] = []
234 libs = REPO_ROOT /
"libs"
238 for entry
in sorted(libs.iterdir())
239 if entry.is_dir()
and entry.name.startswith(
"ra8_")
241 port = REPO_ROOT /
"port"
244 entry
for entry
in sorted(port.iterdir())
if entry.is_dir()
and entry.name !=
"posix"
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():
256def _tool_scan_dirs() -> list[pathlib.Path]:
257 """First-party production host trees governed by explicit ownership.
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.
266 directory
for directory
in (REPO_ROOT /
"tools", REPO_ROOT /
"apps")
if directory.is_dir()
268 posix_port = REPO_ROOT /
"port" /
"posix"
269 if posix_port.is_dir():
270 dirs.append(posix_port)
274FIRMWARE_SCAN_DIRS = _firmware_scan_dirs()
275TOOL_SCAN_DIRS = _tool_scan_dirs()
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]
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:
287 rel = path.resolve().relative_to(REPO_ROOT)
291 if any(part
in EXCLUDED_PARTS
for part
in rel.parts)
or is_build_output_path(rel.as_posix()):
294 rel_str == str(directory)
or rel_str.startswith(str(directory) +
"/")
295 for directory
in FIRMWARE_SCAN_RELS
299 rel_str == str(directory)
or rel_str.startswith(str(directory) +
"/")
300 for directory
in TOOL_SCAN_RELS
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
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:
319 if ALLOC_ALLOW_RE.search(comment.text.strip())
is not None:
320 control_lines.add(comment.line)
322 malformed.append((comment.line, comment.text.strip()))
323 return control_lines, malformed
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]] = []
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}"))
338 brace_depth += stripped.count(
"{") - stripped.count(
"}")
339 brace_depth = max(0, brace_depth)
343def check(path: pathlib.Path) -> list[str]:
344 """Report every dynamic-allocation call in one production source file.
346 An unreadable file yields an empty list rather than raising, so one bad
347 file cannot abort the sweep.
350 text = path.read_text(encoding=
"utf-8")
351 except (OSError, UnicodeDecodeError):
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
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
366 f
"{path}:{line_no}: alloc-allow without reason -- {control}"
367 for line_no, control
in malformed_controls
370 f
"{path}:{line_no}: alloc-allow without governed allocation"
371 for line_no
in sorted(control_lines - allocator_lines)
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)
381 good = root /
"good.c"
383 "void f(void) { void *p = malloc(4); stbi_load(0, 0, 0, 0, 0); }\n",
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",
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"
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",
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"),
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}")
415 print(f
"check_no_dynamic_alloc.py --selftest: {len(failed)} failure(s)", file=sys.stderr)
417 print(
"check_no_dynamic_alloc.py --selftest: all cases pass (both directions).")
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
425 path
for directory
in directories
for path
in directory.rglob(
"*")
if _is_in_scope(path)
429def _scope_floor_errors(counts: dict[str, int]) -> list[str]:
430 """Describe every collapsed full-sweep domain or aggregate."""
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
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}")
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)
450 "check_no_dynamic_alloc.py: FATAL -- " +
"; ".join(floor_errors),
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
464 "usage: check_no_dynamic_alloc.py FILE [FILE ...] | --all",
471 """Fail when firmware or first-party production tools allocate directly.
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.
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.
481 if args == [
"--selftest"]:
483 if any(arg.startswith(
"-")
and arg !=
"--all" for arg
in args)
or (
484 "--all" in args
and args != [
"--all"]
487 "usage: check_no_dynamic_alloc.py FILE [FILE ...] | --all | --selftest",
491 paths, error = _requested_paths()
495 failures: list[str] = []
496 reports: list[str] = []
498 if not path.is_file():
500 if not _is_in_scope(path):
502 problems =
check(path)
504 if scope ==
"firmware":
505 failures.extend(problems)
508 problem
for problem
in problems
if ": direct dynamic allocation:" in problem
511 problem
for problem
in problems
if ": transitive dynamic allocation:" in problem
516 "check_no_dynamic_alloc.py: host-tool SOUP allocation report "
517 "(non-blocking third-party boundary)."
521 print(f
"\n{len(reports)} host SOUP call site(s) reported; these rows are non-blocking.")
525 "check_no_dynamic_alloc.py: dynamic allocation in firmware or first-party tool code.",
528 for line
in failures:
529 print(line, file=sys.stderr)
530 print(f
"\n{len(failures)} violation(s) found.", file=sys.stderr)
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.",
541if __name__ ==
"__main__":
542 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.