ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
cppcheck_sources.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"""Produce and validate cppcheck inputs from an isolated Git census.
5
6Cppcheck must receive explicit candidate-tree translation units. Directory
7operands recursively ingest ignored in-tree build output, while an inherited
8Git index/configuration or Python startup hook can silently replace a naive
9manifest. This module is the single cppcheck source-scope authority consumed
10by both the registered gate and the advisory wrapper.
11"""
12
13from __future__ import annotations
14
15import argparse
16import io
17import os
18import stat
19import subprocess
20import sys
21import tempfile
22from pathlib import Path, PurePosixPath
23from typing import BinaryIO, TextIO
24
25# Isolated Python deliberately omits the script directory. Add only the two
26# repository-owned module roots this checker imports; inherited PYTHONPATH and
27# sitecustomize remain unavailable under `-I -S`.
28sys.path.insert(0, str(Path(__file__).resolve().parent))
29sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
30
31import lint_targets
32from git_environment import (
33 sanitized_git_environment,
34 trusted_git_executable,
35)
36
37SOURCE_ROOTS = ("libs/", "examples/", "tools/")
38SOURCE_SUFFIXES = (".c", ".cc", ".cpp", ".cxx")
39MIN_SOURCE_UNITS = 879
40
41
42class CensusError(RuntimeError):
43 """Raised when the source census or transported manifest is untrustworthy."""
44
45 @classmethod
46 def git_failed(cls, detail: str) -> CensusError:
47 """Build the error for a failed Git census command."""
48 return cls(f"git ls-files failed: {detail or 'no diagnostic'}")
49
50 @classmethod
51 def invalid_encoding(cls) -> CensusError:
52 """Build the error for bytes outside the repository path encoding."""
53 return cls("source manifest contains a non-UTF-8 path")
54
55 @classmethod
56 def collapsed(cls, count: int, minimum: int) -> CensusError:
57 """Build the error for a census below its authenticated floor."""
58 return cls(f"cppcheck source census has {count} unit(s); floor is {minimum}")
59
60 @classmethod
61 def unsafe_path(cls, rel: str, reason: str) -> CensusError:
62 """Build the error for a path outside the regular-file boundary."""
63 return cls(f"unsafe cppcheck source path {rel!r}: {reason}")
64
65 @classmethod
66 def invalid_manifest(cls, reason: str) -> CensusError:
67 """Build the error for malformed transported bytes."""
68 return cls(f"invalid cppcheck source manifest: {reason}")
69
70 @classmethod
71 def duplicate_manifest(cls) -> CensusError:
72 """Build the error for duplicate transported paths."""
73 return cls.invalid_manifest("duplicate paths")
74
75 @classmethod
76 def unsorted_manifest(cls) -> CensusError:
77 """Build the error for a nondeterministic transported order."""
78 return cls.invalid_manifest("paths are not deterministically sorted")
79
80 @classmethod
81 def missing_terminator(cls) -> CensusError:
82 """Build the error for a truncated NUL manifest."""
83 return cls.invalid_manifest("missing final NUL terminator")
84
85 @classmethod
86 def empty_manifest_field(cls) -> CensusError:
87 """Build the error for an empty path within a manifest."""
88 return cls.invalid_manifest("empty path field")
89
90 @classmethod
91 def unsafe_transport(cls) -> CensusError:
92 """Build the error for a non-regular manifest transport."""
93 return cls.invalid_manifest("transport is not a regular file")
94
95
96def trusted_python_executable() -> str:
97 """Return the fixed Python authority shared by the shell adapter."""
98 expected = "/usr/bin/python3"
99 configured = os.environ.get("RA8_TRUSTED_PYTHON", expected)
100 if configured != expected:
101 raise CensusError.unsafe_path(configured, "not the trusted Python executable")
102 path = Path(expected)
103 try:
104 path.lstat()
105 except OSError as error:
106 raise CensusError.unsafe_path(expected, "unavailable") from error
107 resolved = path.resolve(strict=True)
108 resolved_info = resolved.lstat()
109 if not stat.S_ISREG(resolved_info.st_mode) or not os.access(resolved, os.X_OK):
110 raise CensusError.unsafe_path(expected, "does not resolve to a regular executable")
111 return expected
112
113
114def git_paths(root: Path) -> list[str]:
115 """Return tracked and non-ignored candidate paths through sanitized Git."""
116 process = subprocess.run( # noqa: S603 -- fixed executable from shared Git authority.
117 [
118 trusted_git_executable(),
119 "-C",
120 str(root),
121 "--work-tree",
122 str(root),
123 "-c",
124 "core.excludesFile=/dev/null",
125 "ls-files",
126 "-z",
127 "--cached",
128 "--others",
129 "--exclude-standard",
130 ],
131 cwd=root,
132 env=sanitized_git_environment(),
133 capture_output=True,
134 check=False,
135 )
136 if process.returncode != 0:
137 detail = os.fsdecode(process.stderr).strip()
138 raise CensusError.git_failed(detail)
139 try:
140 decoded = process.stdout.decode("utf-8")
141 except UnicodeDecodeError as error:
142 raise CensusError.invalid_encoding() from error
143 return [rel for rel in decoded.split("\0") if rel]
144
145
146def lexical_parts(rel: str) -> tuple[str, ...]:
147 """Return safe POSIX path components or reject lexical escape syntax."""
148 raw_parts = rel.split("/")
149 pure = PurePosixPath(rel)
150 if pure.is_absolute() or not raw_parts or any(part in {"", ".", ".."} for part in raw_parts):
151 raise CensusError.unsafe_path(rel, "path is not a confined repository-relative name")
152 return tuple(raw_parts)
153
154
155def is_cppcheck_unit(rel: str, root: Path) -> bool:
156 """Return whether one safe repository path is an in-scope translation unit."""
157 return (
158 rel.startswith(SOURCE_ROOTS)
159 and rel.endswith(SOURCE_SUFFIXES)
160 and lint_targets.language_of(rel, root) == "c"
161 )
162
163
164def regular_source_exists(root: Path, rel: str) -> bool:
165 """Require a non-symlink regular file under non-symlink repo directories."""
166 parts = lexical_parts(rel)
167 root_resolved = root.resolve(strict=True)
168 current = root_resolved
169 for index, part in enumerate(parts):
170 current /= part
171 try:
172 info = current.lstat()
173 except FileNotFoundError:
174 return False
175 if stat.S_ISLNK(info.st_mode):
176 raise CensusError.unsafe_path(rel, "symlink component is forbidden")
177 if index < len(parts) - 1 and not stat.S_ISDIR(info.st_mode):
178 raise CensusError.unsafe_path(rel, "parent component is not a directory")
179 if index == len(parts) - 1 and not stat.S_ISREG(info.st_mode):
180 raise CensusError.unsafe_path(rel, "source is not a regular file")
181 try:
182 current.resolve(strict=True).relative_to(root_resolved)
183 except (OSError, ValueError) as error:
184 raise CensusError.unsafe_path(rel, "resolved path escapes the repository") from error
185 return True
186
187
188def validate_sources(root: Path, sources: list[str], minimum: int) -> list[str]:
189 """Validate order, uniqueness, scope, confinement, and anti-vacuity."""
190 if len(sources) != len(set(sources)):
191 raise CensusError.duplicate_manifest()
192 if sources != sorted(sources):
193 raise CensusError.unsorted_manifest()
194 if len(sources) < minimum:
195 raise CensusError.collapsed(len(sources), minimum)
196 for rel in sources:
197 lexical_parts(rel)
198 if not is_cppcheck_unit(rel, root):
199 raise CensusError.unsafe_path(rel, "outside authenticated roots or suffixes")
200 if not regular_source_exists(root, rel):
201 raise CensusError.unsafe_path(rel, "source disappeared before validation")
202 return sources
203
204
205def collect_sources(root: Path, minimum: int) -> list[str]:
206 """Collect sorted regular sources and reject unsafe candidate entries."""
207 sources: list[str] = []
208 for rel in git_paths(root):
209 lexical_parts(rel)
210 if not is_cppcheck_unit(rel, root):
211 continue
212 if regular_source_exists(root, rel):
213 sources.append(rel)
214 return validate_sources(root, sorted(sources), minimum)
215
216
217def decode_manifest(data: bytes) -> list[str]:
218 """Decode one canonical NUL-terminated path manifest."""
219 if not data or not data.endswith(b"\0"):
220 raise CensusError.missing_terminator()
221 fields = data[:-1].split(b"\0")
222 if any(not field for field in fields):
223 raise CensusError.empty_manifest_field()
224 try:
225 return [field.decode("utf-8") for field in fields]
226 except UnicodeDecodeError as error:
227 raise CensusError.invalid_encoding() from error
228
229
230def emit_sources(sources: list[str], output: BinaryIO, nul_terminated: bool) -> None:
231 """Write a path manifest without shell re-tokenization."""
232 separator = b"\0" if nul_terminated else b"\n"
233 output.write(separator.join(path.encode("utf-8") for path in sources) + separator)
234
235
236def run_census(
237 root: Path,
238 minimum: int,
239 output: BinaryIO,
240 error_output: TextIO,
241 nul_terminated: bool,
242) -> int:
243 """Run the production census and return a process-style status."""
244 try:
245 sources = collect_sources(root, minimum)
246 except CensusError as error:
247 print(f"cppcheck_sources.py: FATAL -- {error}", file=error_output)
248 return 2
249 emit_sources(sources, output, nul_terminated)
250 return 0
251
252
253def run_manifest_validation(
254 root: Path,
255 manifest: Path,
256 output: BinaryIO,
257 error_output: TextIO,
258 nul_terminated: bool,
259) -> int:
260 """Validate transported bytes independently and emit a normalized manifest."""
261 try:
262 info = manifest.lstat()
263 if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
264 raise CensusError.unsafe_transport()
265 sources = validate_sources(root, decode_manifest(manifest.read_bytes()), MIN_SOURCE_UNITS)
266 except (CensusError, OSError) as error:
267 print(f"cppcheck_sources.py: FATAL -- {error}", file=error_output)
268 return 2
269 emit_sources(sources, output, nul_terminated)
270 return 0
271
272
273def fixture_git(root: Path, *args: str, extra_env: dict[str, str] | None = None) -> bytes:
274 """Run trusted Git for a synthetic selftest repository."""
275 environment = sanitized_git_environment()
276 if extra_env:
277 environment.update(extra_env)
278 process = subprocess.run( # noqa: S603 -- fixed executable from shared Git authority.
279 [trusted_git_executable(), "-C", str(root), *args],
280 cwd=root,
281 env=environment,
282 capture_output=True,
283 check=False,
284 )
285 if process.returncode != 0:
286 raise CensusError.git_failed(os.fsdecode(process.stderr).strip())
287 return process.stdout
288
289
290def write_fixture(root: Path) -> None:
291 """Create the ordinary, ignored, generated, and SOUP fixture population."""
292 files = {
293 ".gitignore": "/tools/**/build/\n",
294 "libs/ra8_core/src/tracked.c": "int tracked_source(void) { return 0; }\n",
295 "tools/demo/src/candidate.cpp": "int candidate_source() { return 0; }\n",
296 "tools/demo/build/generated.c": "int ignored_output(void) { return 0; }\n",
297 "libs/ra8_fonts/generated.c": "int generated_font(void) { return 0; }\n",
298 "libs/third_party/vendor.c": "int vendor_source(void) { return 0; }\n",
299 }
300 for rel, content in files.items():
301 path = root / rel
302 path.parent.mkdir(parents=True, exist_ok=True)
303 path.write_text(content, encoding="ascii")
304
305
306def initialize_fixture(root: Path) -> None:
307 """Initialize Git and stage the ordinary, generated, and SOUP fixtures."""
308 fixture_git(root, "init", "-q")
309 fixture_git(
310 root,
311 "add",
312 ".gitignore",
313 "libs/ra8_core/src/tracked.c",
314 "libs/ra8_fonts/generated.c",
315 "libs/third_party/vendor.c",
316 )
317
318
319def expect(passed: bool, label: str, failures: list[str]) -> None:
320 """Record and print one selftest expectation."""
321 print(f" [{'ok' if passed else 'FAIL'}] {label}")
322 if not passed:
323 failures.append(label)
324
325
326def capture_census(root: Path, minimum: int = 1) -> tuple[int, list[str], str]:
327 """Drive the production census entry point and decode successful output."""
328 output = io.BytesIO()
329 errors = io.StringIO()
330 status_code = run_census(root, minimum, output, errors, nul_terminated=True)
331 paths = decode_manifest(output.getvalue()) if status_code == 0 else []
332 return status_code, paths, errors.getvalue()
333
334
335def basic_fixture_selftest(root: Path, failures: list[str]) -> None:
336 """Prove ordinary inclusion and ignored/generated/SOUP exclusion."""
337 status_code, paths, errors = capture_census(root)
338 expected = {
339 "libs/ra8_core/src/tracked.c",
340 "tools/demo/src/candidate.cpp",
341 }
342 expect(status_code == 0 and not errors, "valid Git census succeeds", failures)
343 expect(set(paths) == expected, "ordinary tracked and candidate sources are exact", failures)
344 expect(
345 "tools/demo/build/generated.c" not in paths,
346 "ignored build output is excluded",
347 failures,
348 )
349 expect(
350 not ({"libs/ra8_fonts/generated.c", "libs/third_party/vendor.c"} & set(paths)),
351 "generated and SOUP sources are excluded",
352 failures,
353 )
354
355
356def symlink_fixture_selftest(root: Path, raw_root: Path, failures: list[str]) -> None:
357 """Prove external and internal candidate symlinks fail closed."""
358 failure_status = 2
359 outside = raw_root / "outside.c"
360 outside.write_text("int outside(void) { return 0; }\n", encoding="ascii")
361 external_link = root / "libs/ra8_core/src/untracked_external.c"
362 external_link.symlink_to(outside)
363 status_code, _, errors = capture_census(root)
364 expect(
365 status_code == failure_status and "symlink" in errors,
366 "candidate external symlink fails",
367 failures,
368 )
369 external_link.unlink()
370
371 internal_link = root / "tools/demo/src/untracked_internal.c"
372 internal_link.symlink_to(root / "libs/ra8_core/src/tracked.c")
373 status_code, _, errors = capture_census(root)
374 expect(
375 status_code == failure_status and "symlink" in errors,
376 "candidate internal symlink fails",
377 failures,
378 )
379 internal_link.unlink()
380
381 special = root / "tools/demo/src/untracked_special.c"
382 os.mkfifo(special)
383 try:
384 validate_sources(root, ["tools/demo/src/untracked_special.c"], 1)
385 except CensusError as error:
386 expect("regular file" in str(error), "special source entry fails", failures)
387 else:
388 expect(passed=False, label="special source entry fails", failures=failures)
389 special.unlink()
390
391
392def manifest_fixture_selftest(root: Path, failures: list[str]) -> None:
393 """Prove transported duplicates, scope drift, and low counts fail closed."""
394 valid = "libs/ra8_core/src/tracked.c"
395 duplicate = (valid + "\0" + valid + "\0").encode()
396 try:
397 validate_sources(root, decode_manifest(duplicate), 1)
398 except CensusError as error:
399 expect("duplicate" in str(error), "duplicate manifest path fails", failures)
400 else:
401 expect(passed=False, label="duplicate manifest path fails", failures=failures)
402
403 unexpected = root / "tests/src/unexpected.c"
404 unexpected.parent.mkdir(parents=True, exist_ok=True)
405 unexpected.write_text("int unexpected(void) { return 0; }\n", encoding="ascii")
406 try:
407 validate_sources(root, ["tests/src/unexpected.c"], 1)
408 except CensusError as error:
409 expect("authenticated roots" in str(error), "unexpected scope fails", failures)
410 else:
411 expect(passed=False, label="unexpected scope fails", failures=failures)
412
413 try:
414 validate_sources(root, [valid], 2)
415 except CensusError as error:
416 expect("floor is 2" in str(error), "below-floor manifest fails", failures)
417 else:
418 expect(passed=False, label="below-floor manifest fails", failures=failures)
419
420
421def empty_and_missing_selftest(raw_root: Path, failures: list[str]) -> None:
422 """Prove empty and missing Git censuses fail without emitting paths."""
423 failure_status = 2
424 empty = raw_root / "empty"
425 empty.mkdir()
426 fixture_git(empty, "init", "-q")
427 status_code, paths, errors = capture_census(empty)
428 expect(
429 status_code == failure_status and not paths and "0 unit(s)" in errors,
430 "zero-input census fails closed",
431 failures,
432 )
433 missing = raw_root / "not-a-repo"
434 missing.mkdir()
435 status_code, paths, errors = capture_census(missing)
436 expect(
437 status_code == failure_status and not paths and "git ls-files failed" in errors,
438 "missing Git census fails closed",
439 failures,
440 )
441
442
443def hostile_boundary_selftest(repo_root: Path, raw_root: Path, failures: list[str]) -> None:
444 """Drive the exact isolated interpreter boundary under hostile startup state."""
445 expected = collect_sources(repo_root, MIN_SOURCE_UNITS)
446 empty_index = raw_root / "hostile-index"
447 fixture_git(repo_root, "read-tree", "--empty", extra_env={"GIT_INDEX_FILE": str(empty_index)})
448 excludes = raw_root / "exclude-all"
449 excludes.write_text("*\n", encoding="ascii")
450 config = raw_root / "hostile-git-config"
451 config.write_text(f"[core]\n\texcludesFile = {excludes}\n", encoding="ascii")
452 python_path = raw_root / "hostile-python"
453 python_path.mkdir()
454 marker = raw_root / "sitecustomize-fired"
455 (python_path / "sitecustomize.py").write_text(
456 f"from pathlib import Path\nPath({str(marker)!r}).write_text('fired', encoding='ascii')\n",
457 encoding="ascii",
458 )
459 environment = os.environ.copy()
460 environment.update(
461 {
462 "GIT_CONFIG": str(config),
463 "GIT_INDEX_FILE": str(empty_index),
464 "GIT_WORK_TREE": str(raw_root),
465 "PYTHONHOME": str(raw_root / "fake-home"),
466 "PYTHONPATH": str(python_path),
467 }
468 )
469 process = subprocess.run( # noqa: S603 -- fixed trusted Python boundary.
470 [trusted_python_executable(), "-I", "-S", str(Path(__file__).resolve()), "--null"],
471 cwd=repo_root,
472 env=environment,
473 capture_output=True,
474 check=False,
475 )
476 actual = decode_manifest(process.stdout) if process.returncode == 0 else []
477 expect(
478 process.returncode == 0 and actual == expected and not marker.exists(),
479 "hostile Git and Python startup state cannot replace the manifest",
480 failures,
481 )
482
483
484def run_selftest() -> int:
485 """Prove scope, transport, filesystem, and process-boundary behavior."""
486 failures: list[str] = []
487 with tempfile.TemporaryDirectory(prefix="cppcheck-sources-selftest-") as raw:
488 raw_root = Path(raw)
489 fixture = raw_root / "repo"
490 fixture.mkdir()
491 write_fixture(fixture)
492 initialize_fixture(fixture)
493 basic_fixture_selftest(fixture, failures)
494 symlink_fixture_selftest(fixture, raw_root, failures)
495 manifest_fixture_selftest(fixture, failures)
496 empty_and_missing_selftest(raw_root, failures)
497 repo_root = Path(__file__).resolve().parents[2]
498 hostile_boundary_selftest(repo_root, raw_root, failures)
499 if failures:
500 print(f"cppcheck_sources.py --selftest: {len(failures)} failure(s)", file=sys.stderr)
501 return 1
502 print("cppcheck_sources.py --selftest: all cases pass (both directions).")
503 return 0
504
505
506def parse_args(argv: list[str]) -> argparse.Namespace:
507 """Parse command-line options."""
508 parser = argparse.ArgumentParser(description=__doc__)
509 modes = parser.add_mutually_exclusive_group()
510 modes.add_argument("--selftest", action="store_true", help="run boundary selftests")
511 modes.add_argument("--validate-manifest", type=Path, help="validate a NUL manifest")
512 parser.add_argument("--null", action="store_true", help="NUL-terminate output paths")
513 return parser.parse_args(argv)
514
515
516def main(argv: list[str]) -> int:
517 """Run selftests, validate transport, or emit the repository manifest."""
518 args = parse_args(argv)
519 if args.selftest:
520 return run_selftest()
521 repo_root = Path(__file__).resolve().parents[2]
522 if args.validate_manifest is not None:
523 return run_manifest_validation(
524 repo_root,
525 args.validate_manifest,
526 sys.stdout.buffer,
527 sys.stderr,
528 args.null,
529 )
530 return run_census(
531 repo_root,
532 MIN_SOURCE_UNITS,
533 sys.stdout.buffer,
534 sys.stderr,
535 args.null,
536 )
537
538
539if __name__ == "__main__":
540 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298