ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_stdio_streams.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"""Reject allocator-backed C/POSIX streams in first-party C-family code.
5
6Reusable firmware code uses ``fw_fs_file_t`` and injected ``ra8_io``/logging
7facades. Hosted adapters may use raw descriptors and bounded caller-owned
8state, but they must not expose or depend on C-runtime ``FILE``/``DIR`` state.
9Those opaque stream objects can allocate internally and do not make storage or
10stack bounds part of the caller-visible contract.
11
12This gate rejects the stream types, standard streams, file/console stream
13functions, and allocator-backed directory walkers in code positions. Matching
14bare identifiers, rather than calls alone, also catches typedefs, macro aliases,
15function-pointer assignments, and wrappers that merely rename a forbidden API.
16Comments and string/character literals are blanked before matching.
17
18Memory-only conversion APIs such as ``snprintf``, ``vsnprintf``, and ``sscanf``
19are deliberately outside this rule: they do not create a stream. Separate
20bounds, allocation, and format-string gates still govern their safe use.
21
22Scope is version-controlled or newly added C-family source under ``libs/``,
23``port/``, ``examples/``, ``src/``, ``coprocessor/``, ``tools/``, and
24``tests/``. Vendored SOUP, generated font tables, and exact sources registered
25as generated are excluded. Host tools and tests use raw descriptor adapters
26and injected streams at their composition edge; being hosted or test-only does
27not make opaque allocator-backed streams acceptable.
28
29The full sweep has a zero baseline: one finding fails. Per-root and total file
30floors make a collapsed or accidentally narrowed enumeration fatal.
31
32Usage::
33
34 check_no_stdio_streams.py FILE [FILE ...]
35 check_no_stdio_streams.py --all
36 check_no_stdio_streams.py --selftest
37
38Returns 0 when clean, 1 on policy findings, and 2 on usage/scope failure.
39"""
40
41from __future__ import annotations
42
43import argparse
44import pathlib
45import re
46import subprocess
47import sys
48from collections.abc import Iterable
49
50from doxy_lex import blank_noncode
51from lint_coverage_rules import PATH_CLASS
52from lint_targets import is_build_output_path
53from selftest_assert import expect, report
54
55REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
56
57SCOPE_ROOTS = (
58 "libs/",
59 "port/",
60 "examples/",
61 "coprocessor/",
62 "tools/",
63 "apps/",
64 "tests/",
65)
66SOURCE_SUFFIXES = (".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".inc", ".m", ".mm")
67EXCLUDED_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/", "libs/ra8_fonts/")
68GENERATED_SOURCE_PATHS = frozenset(
69 path for path, classification in PATH_CLASS.items() if classification == "generated-source"
70)
71
72# The floors are below current counts, but high enough that dropping a major
73# first-party source or test subtree cannot report a vacuous pass.
74ROOT_FILE_FLOORS = {
75 "libs/": 750,
76 "port/": 80,
77 "examples/": 400,
78 "tools/": 180,
79 "apps/": 110,
80 "tests/": 600,
81}
82TOTAL_FILE_FLOOR = 2300
83
84# Every token is rejected wherever it appears in code. This catches direct
85# calls and declarations as well as aliases such as ``#define OPEN fopen``.
86BANNED_TOKENS = (
87 "DIR",
88 "FILE",
89 "_IO_FILE",
90 "__sFILE",
91 "alphasort",
92 "alphasort64",
93 "clearerr",
94 "clearerr_unlocked",
95 "closedir",
96 "dirfd",
97 "dprintf",
98 "fclose",
99 "fcloseall",
100 "fdopen",
101 "fdopendir",
102 "feof",
103 "feof_unlocked",
104 "ferror",
105 "ferror_unlocked",
106 "fflush",
107 "fflush_unlocked",
108 "fgetc",
109 "fgetc_unlocked",
110 "fgetpos",
111 "fgetpos64",
112 "fgets",
113 "fgetwc",
114 "fgetws",
115 "fileno",
116 "fileno_unlocked",
117 "flockfile",
118 "fmemopen",
119 "fopen",
120 "fopen64",
121 "fopen_s",
122 "fopencookie",
123 "fpos_t",
124 "fprintf",
125 "fputc",
126 "fputc_unlocked",
127 "fputs",
128 "fputwc",
129 "fputws",
130 "fread",
131 "fread_unlocked",
132 "freopen",
133 "freopen64",
134 "freopen_s",
135 "fscanf",
136 "fseek",
137 "fseeko",
138 "fseeko64",
139 "fsetpos",
140 "fsetpos64",
141 "ftell",
142 "ftello",
143 "ftello64",
144 "ftrylockfile",
145 "ftw",
146 "ftw64",
147 "funlockfile",
148 "funopen",
149 "funopen2",
150 "fwprintf",
151 "fwide",
152 "fwrite",
153 "fwrite_unlocked",
154 "getc",
155 "getchar",
156 "getchar_unlocked",
157 "getdelim",
158 "getline",
159 "gets",
160 "gets_s",
161 "getw",
162 "getwc",
163 "getwchar",
164 "nftw",
165 "nftw64",
166 "open_memstream",
167 "open_wmemstream",
168 "opendir",
169 "pclose",
170 "perror",
171 "popen",
172 "printf",
173 "putc",
174 "putc_unlocked",
175 "putchar",
176 "putchar_unlocked",
177 "puts",
178 "putw",
179 "putwc",
180 "putwchar",
181 "readdir",
182 "readdir64",
183 "readdir64_r",
184 "readdir_r",
185 "rewind",
186 "rewinddir",
187 "scandir",
188 "scandir64",
189 "scandirat",
190 "scandirat64",
191 "scanf",
192 "seekdir",
193 "setbuf",
194 "setbuffer",
195 "setlinebuf",
196 "setvbuf",
197 "stderr",
198 "stdin",
199 "stdout",
200 "telldir",
201 "tempnam",
202 "tmpfile",
203 "tmpfile64",
204 "tmpfile_s",
205 "tmpnam",
206 "tmpnam_r",
207 "ungetc",
208 "ungetwc",
209 "vdprintf",
210 "vfprintf",
211 "vfscanf",
212 "vfwprintf",
213 "vfwscanf",
214 "vprintf",
215 "vscanf",
216 "vwprintf",
217 "vwscanf",
218 "versionsort",
219 "versionsort64",
220 "wprintf",
221 "wscanf",
222)
223
224TOKEN_RE = re.compile(r"\b(" + "|".join(re.escape(token) for token in BANNED_TOKENS) + r")\b")
225FORMAT_ATTRIBUTE_PREFIX_RE = re.compile(
226 r"(?:\‍[\‍[\s*(?:gnu::)?format|__attribute__\s*\‍(\‍(\s*format)\s*\‍(\s*$"
227)
228
229Finding = tuple[int, int, str, str]
230
231
232def _in_scope(rel: str) -> bool:
233 """Return whether ``rel`` is first-party C-family source."""
234 normalized = rel.replace("\\", "/").lstrip("./")
235 if not normalized.startswith(SCOPE_ROOTS):
236 return False
237 if not normalized.lower().endswith(SOURCE_SUFFIXES):
238 return False
239 if normalized.startswith(EXCLUDED_PREFIXES):
240 return False
241 if normalized in GENERATED_SOURCE_PATHS:
242 return False
243 return not is_build_output_path(normalized)
244
245
246def _is_format_attribute(code: str, offset: int, token: str) -> bool:
247 """Allow ``printf``/``scanf`` only as a compiler format dialect name."""
248 if token not in {"printf", "scanf"}:
249 return False
250 line_start = code.rfind("\n", 0, offset) + 1
251 return FORMAT_ATTRIBUTE_PREFIX_RE.search(code[line_start:offset]) is not None
252
253
254def scan_text(text: str) -> list[Finding]:
255 """Return forbidden code-position tokens in ``text`` with source locations."""
256 code, _comments = blank_noncode(text)
257 raw_lines = text.splitlines()
258 findings: list[Finding] = []
259 for match in TOKEN_RE.finditer(code):
260 token = match.group(1)
261 if _is_format_attribute(code, match.start(), token):
262 continue
263 line_no = code.count("\n", 0, match.start()) + 1
264 line_start = code.rfind("\n", 0, match.start()) + 1
265 column = match.start() - line_start + 1
266 source = raw_lines[line_no - 1].strip() if line_no <= len(raw_lines) else ""
267 findings.append((line_no, column, token, source))
268 return findings
269
270
271def _scope_floor_errors(counts: dict[str, int]) -> list[str]:
272 """Describe every per-root or total enumeration floor violation."""
273 errors: list[str] = []
274 total = sum(counts.values())
275 for root, floor in ROOT_FILE_FLOORS.items():
276 actual = counts.get(root, 0)
277 if actual < floor:
278 errors.append(f"{root} enumerated {actual} source file(s); floor is {floor}")
279 if total < TOTAL_FILE_FLOOR:
280 errors.append(f"total scope enumerated {total} source file(s); floor is {TOTAL_FILE_FLOOR}")
281 return errors
282
283
284def _working_scope() -> tuple[list[pathlib.Path], dict[str, int]]:
285 """Enumerate present tracked/new in-scope files and enforce coverage floors."""
286 proc = subprocess.run(
287 ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], # noqa: S607 -- fixed repository Git census
288 cwd=REPO_ROOT,
289 capture_output=True,
290 text=True,
291 check=False,
292 )
293 if proc.returncode != 0:
294 sys.stderr.write(proc.stderr)
295 sys.stderr.write(
296 "check_no_stdio_streams.py: FATAL -- git working-tree enumeration failed\n"
297 )
298 raise SystemExit(2)
299 rels = sorted(
300 rel
301 for rel in proc.stdout.split("\0")
302 if rel and _in_scope(rel) and (REPO_ROOT / rel).is_file()
303 )
304 counts = dict.fromkeys(SCOPE_ROOTS, 0)
305 for rel in rels:
306 root = next(root for root in SCOPE_ROOTS if rel.startswith(root))
307 counts[root] += 1
308 floor_errors = _scope_floor_errors(counts)
309 if floor_errors:
310 sys.stderr.write("check_no_stdio_streams.py: FATAL -- " + "; ".join(floor_errors) + "\n")
311 raise SystemExit(2)
312 return [REPO_ROOT / rel for rel in rels], counts
313
314
315def _explicit_scope(raw_paths: Iterable[str]) -> list[pathlib.Path]:
316 """Filter caller-named files through the same first-party scope policy."""
317 paths: list[pathlib.Path] = []
318 for raw in raw_paths:
319 path = pathlib.Path(raw)
320 absolute = path if path.is_absolute() else REPO_ROOT / path
321 try:
322 rel = absolute.resolve().relative_to(REPO_ROOT).as_posix()
323 except ValueError:
324 continue
325 if _in_scope(rel) and absolute.is_file():
326 paths.append(absolute)
327 return sorted(set(paths))
328
329
330def _scan_files(paths: Iterable[pathlib.Path]) -> tuple[int, list[tuple[str, Finding]]]:
331 """Read and scan ``paths``, raising when a source file is unreadable."""
332 findings: list[tuple[str, Finding]] = []
333 scanned = 0
334 for path in paths:
335 text = path.read_text(encoding="utf-8", errors="replace")
336 rel = path.resolve().relative_to(REPO_ROOT).as_posix()
337 scanned += 1
338 findings.extend((rel, finding) for finding in scan_text(text))
339 return scanned, findings
340
341
342def _selftest_tokens(failures: list[str]) -> None:
343 """Prove every forbidden identifier fires and legal lookalikes stay quiet."""
344 bad = "\n".join(
345 f"void *alias_{index} = (void *)&{token};" for index, token in enumerate(BANNED_TOKENS)
346 )
347 observed = {finding[2] for finding in scan_text(bad)}
348 expect(observed == set(BANNED_TOKENS), "every forbidden token fires", failures)
349 expect(
350 bool(scan_text("#define HOST_OPEN fopen\n")),
351 "a macro alias to a forbidden API fires",
352 failures,
353 )
354 allowed = """
355 char buffer[32];
356 int n = snprintf(buffer, sizeof(buffer), "%u", 7U);
357 int parsed = sscanf(buffer, "%d", &n);
358 fw_fs_file_t file = {};
359 ra8_io_stream_puts(&stream, "FILE stdout opendir printf");
360 unsigned stdout_count = 0U;
361 [[gnu::format(printf, 3, 4)]] void bounded_log(int, int, const char*, ...);
362 /* FILE *ignored = fopen("x", "r"); DIR *dir = opendir("."); */
363 """
364 expect(not scan_text(allowed), "memory formatting/comments/lookalikes stay quiet", failures)
365
366
367def _selftest_scope(failures: list[str]) -> None:
368 """Prove every requested first-party root and only exact exclusions apply."""
369 for root in SCOPE_ROOTS:
370 expect(_in_scope(f"{root}future_portable.c"), f"{root} is in scope", failures)
371 expect(
372 _in_scope("apps/host/mdl/src/main.c"),
373 "production host tools are in scope",
374 failures,
375 )
376 expect(
377 _in_scope("apps/host/mdl/tests/src/test_main.c"),
378 "tool test fixtures are in scope",
379 failures,
380 )
381 expect(_in_scope("tests/test_fs.c"), "unit tests are in scope", failures)
382 expect(
383 not _in_scope("apps/shared_libs/third_party/miniz/miniz.c"),
384 "vendored SOUP is excluded",
385 failures,
386 )
387 generated = "libs/ra8_c6link/src/ra8_media_download.pb-c.c"
388 expect(not _in_scope(generated), "registered generated protobuf source is excluded", failures)
389 expect(
390 _in_scope("libs/future/src/future.pb-c.c"),
391 "a generated-looking future file is not automatically exempt",
392 failures,
393 )
394
395
396def _selftest_floors(failures: list[str]) -> None:
397 """Prove both per-root and aggregate non-vacuity floors bite."""
398 good = {
399 "libs/": 910,
400 "port/": 90,
401 "examples/": 430,
402 "coprocessor/": 0,
403 "tools/": 200,
404 "apps/": 120,
405 "tests/": 700,
406 }
407 expect(not _scope_floor_errors(good), "current-shaped scope clears every floor", failures)
408 root_short = dict(good)
409 root_short["port/"] = 79
410 expect(bool(_scope_floor_errors(root_short)), "a narrowed production root fails", failures)
411 tests_short = dict(good)
412 tests_short["tests/"] = 599
413 expect(bool(_scope_floor_errors(tests_short)), "a narrowed test root fails", failures)
414 # Every root exactly ON its floor, so only the aggregate can object. It has
415 # to be recomputed whenever a root is added, or the new root's floor lifts
416 # the sum back over TOTAL_FILE_FLOOR and this case stops testing anything.
417 total_short = {
418 "libs/": 750,
419 "port/": 80,
420 "examples/": 400,
421 "coprocessor/": 0,
422 "tools/": 180,
423 "apps/": 110,
424 "tests/": 600,
425 }
426 expect(bool(_scope_floor_errors(total_short)), "the aggregate floor fails", failures)
427
428
429def selftest() -> int:
430 """Run both-direction token, scope, generated-source, and floor proofs."""
431 print("check_no_stdio_streams.py --selftest")
432 failures: list[str] = []
433 expect(
434 len(BANNED_TOKENS) == len(set(BANNED_TOKENS)),
435 "token registry has no duplicates",
436 failures,
437 )
438 _selftest_tokens(failures)
439 _selftest_scope(failures)
440 _selftest_floors(failures)
441 return report(failures)
442
443
444def _report_findings(scanned: int, findings: list[tuple[str, Finding]]) -> int:
445 """Print the zero-baseline result and return its policy exit status."""
446 if not findings:
447 print(f"check_no_stdio_streams.py: {scanned} first-party source file(s), 0 findings.")
448 return 0
449 sys.stderr.write(
450 "check_no_stdio_streams.py: C/POSIX stream API violation(s); use fw_fs_file_t, "
451 "ra8_io/logging, or a raw bounded host adapter:\n"
452 )
453 for rel, finding in findings:
454 line, column, token, source = finding
455 sys.stderr.write(f" {rel}:{line}:{column}: {token}: {source}\n")
456 sys.stderr.write(f"\n{len(findings)} finding(s); baseline is zero.\n")
457 return 1
458
459
460def main(argv: list[str]) -> int:
461 """Dispatch the selftest, full tracked sweep, or explicit-file scan."""
462 parser = argparse.ArgumentParser(description=__doc__)
463 parser.add_argument("--all", action="store_true", help="scan all tracked first-party source")
464 parser.add_argument(
465 "--selftest", action="store_true", help="prove the checker in both directions"
466 )
467 parser.add_argument("files", nargs="*", help="explicit source files")
468 args = parser.parse_args(argv[1:])
469 if args.selftest:
470 if args.all or args.files:
471 parser.error("--selftest accepts no other arguments")
472 return selftest()
473 if args.all and args.files:
474 parser.error("--all accepts no explicit files")
475 if not args.all and not args.files:
476 parser.error("provide --all or at least one source file")
477 try:
478 paths, _counts = _working_scope() if args.all else (_explicit_scope(args.files), {})
479 scanned, findings = _scan_files(paths)
480 except OSError as exc:
481 sys.stderr.write(f"check_no_stdio_streams.py: FATAL -- {exc}\n")
482 return 2
483 return _report_findings(scanned, findings)
484
485
486if __name__ == "__main__":
487 raise SystemExit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298