4"""Reject allocator-backed C/POSIX streams in first-party C-family code.
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.
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.
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.
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.
29The full sweep has a zero baseline: one finding fails. Per-root and total file
30floors make a collapsed or accidentally narrowed enumeration fatal.
34 check_no_stdio_streams.py FILE [FILE ...]
35 check_no_stdio_streams.py --all
36 check_no_stdio_streams.py --selftest
38Returns 0 when clean, 1 on policy findings, and 2 on usage/scope failure.
41from __future__
import annotations
48from collections.abc
import Iterable
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
55REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
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"
82TOTAL_FILE_FLOOR = 2300
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*$"
229Finding = tuple[int, int, str, str]
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):
237 if not normalized.lower().endswith(SOURCE_SUFFIXES):
239 if normalized.startswith(EXCLUDED_PREFIXES):
241 if normalized
in GENERATED_SOURCE_PATHS:
243 return not is_build_output_path(normalized)
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"}:
250 line_start = code.rfind(
"\n", 0, offset) + 1
251 return FORMAT_ATTRIBUTE_PREFIX_RE.search(code[line_start:offset])
is not None
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):
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))
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)
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}")
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"],
293 if proc.returncode != 0:
294 sys.stderr.write(proc.stderr)
296 "check_no_stdio_streams.py: FATAL -- git working-tree enumeration failed\n"
301 for rel
in proc.stdout.split(
"\0")
302 if rel
and _in_scope(rel)
and (REPO_ROOT / rel).is_file()
304 counts = dict.fromkeys(SCOPE_ROOTS, 0)
306 root = next(root
for root
in SCOPE_ROOTS
if rel.startswith(root))
308 floor_errors = _scope_floor_errors(counts)
310 sys.stderr.write(
"check_no_stdio_streams.py: FATAL -- " +
"; ".join(floor_errors) +
"\n")
312 return [REPO_ROOT / rel
for rel
in rels], counts
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
322 rel = absolute.resolve().relative_to(REPO_ROOT).as_posix()
325 if _in_scope(rel)
and absolute.is_file():
326 paths.append(absolute)
327 return sorted(set(paths))
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]] = []
335 text = path.read_text(encoding=
"utf-8", errors=
"replace")
336 rel = path.resolve().relative_to(REPO_ROOT).as_posix()
338 findings.extend((rel, finding)
for finding
in scan_text(text))
339 return scanned, findings
342def _selftest_tokens(failures: list[str]) ->
None:
343 """Prove every forbidden identifier fires and legal lookalikes stay quiet."""
345 f
"void *alias_{index} = (void *)&{token};" for index, token
in enumerate(BANNED_TOKENS)
347 observed = {finding[2]
for finding
in scan_text(bad)}
348 expect(observed == set(BANNED_TOKENS),
"every forbidden token fires", failures)
350 bool(scan_text(
"#define HOST_OPEN fopen\n")),
351 "a macro alias to a forbidden API fires",
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("."); */
364 expect(
not scan_text(allowed),
"memory formatting/comments/lookalikes stay quiet", failures)
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)
372 _in_scope(
"apps/host/mdl/src/main.c"),
373 "production host tools are in scope",
377 _in_scope(
"apps/host/mdl/tests/src/test_main.c"),
378 "tool test fixtures are in scope",
381 expect(_in_scope(
"tests/test_fs.c"),
"unit tests are in scope", failures)
383 not _in_scope(
"apps/shared_libs/third_party/miniz/miniz.c"),
384 "vendored SOUP is excluded",
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)
390 _in_scope(
"libs/future/src/future.pb-c.c"),
391 "a generated-looking future file is not automatically exempt",
396def _selftest_floors(failures: list[str]) ->
None:
397 """Prove both per-root and aggregate non-vacuity floors bite."""
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)
426 expect(bool(_scope_floor_errors(total_short)),
"the aggregate floor fails", failures)
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] = []
434 len(BANNED_TOKENS) == len(set(BANNED_TOKENS)),
435 "token registry has no duplicates",
438 _selftest_tokens(failures)
439 _selftest_scope(failures)
440 _selftest_floors(failures)
441 return report(failures)
444def _report_findings(scanned: int, findings: list[tuple[str, Finding]]) -> int:
445 """Print the zero-baseline result and return its policy exit status."""
447 print(f
"check_no_stdio_streams.py: {scanned} first-party source file(s), 0 findings.")
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"
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")
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")
465 "--selftest", action=
"store_true", help=
"prove the checker in both directions"
467 parser.add_argument(
"files", nargs=
"*", help=
"explicit source files")
468 args = parser.parse_args(argv[1:])
470 if args.all
or args.files:
471 parser.error(
"--selftest accepts no other arguments")
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")
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")
483 return _report_findings(scanned, findings)
486if __name__ ==
"__main__":
487 raise SystemExit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.