ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
soup_selftest.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Both-directions selftest for the vendored-SOUP upstream provenance gate.
4
5It lives beside ``check_soup_upstream.py`` rather than inside it only because
6the checker crossed the project file-size cap; ``--selftest`` on that script is
7still the entry point, and the ``soup-upstream`` gate runs it before the scan.
8
9Two properties are asserted, because this claim -- "byte-identical to upstream"
10-- was stated in three places and checked by nothing, so every tree passed it
11and a drifted one would have too:
12
13 * **it fires.** A mutated blob, a changed file mode, a lost file, a ghost
14 manifest row, an edit on top of a reviewed patch, an undeclared deviation,
15 a stale declaration, a pin that disagrees with the SBOM's, and every
16 vacuity floor -- each is provoked one at a time against a REAL scratch git
17 repository and driven through ``run_check()``, the function CI calls.
18 * **it stays quiet.** The untouched fixture verifies clean, every record kind
19 round-trips through the manifest format, and the shipped floors are real
20 numbers rather than zero-with-a-comment.
21"""
22
23from __future__ import annotations
24
25import contextlib
26import io
27import subprocess
28import sys
29import tempfile
30from collections.abc import Callable
31from pathlib import Path
32
33sys.path.insert(0, str(Path(__file__).resolve().parent))
34sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "gen"))
35sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
36
37from check_soup_upstream import (
38 EXIT_FAIL,
39 EXIT_OK,
40 EXIT_VACUOUS,
41 MIN_COMPONENTS,
42 MIN_ENTRIES,
43 MIN_UPSTREAM_VERIFIED,
44 VacuousScanError,
45 _resolve_entry,
46 run_check,
47)
48from git_environment import isolated_git_environment, trusted_git_executable
49from sbom_registry import Component
50from soup_manifest import (
51 KIND_LOCAL,
52 KIND_MOVED,
53 KIND_OK,
54 KIND_PATCH,
55 Entry,
56 ManifestError,
57 format_manifest,
58 git_ls_files,
59 manifest_path,
60 parse_manifest,
61)
62
63# --------------------------------------------------------------------------- #
64# Selftest -- a real scratch repository, driven through run_check(). #
65# --------------------------------------------------------------------------- #
66
67FIXTURE_PATH = "libs/third_party/fixture"
68FIXTURE_FLOORS = (1, 1, 1)
69# The fixture manifest has two `ok`/`moved` rows among its four records; the
70# other two are the declared patch and the declared local file.
71FIXTURE_VERIFIED_ROWS = 2
72# The shipped floors must be real numbers, not 0-with-a-comment. A floor of
73# zero passes for a scan that covered nothing, which is the failure this whole
74# family of constants exists to prevent.
75FLOOR_SANITY_MIN = 1000
76_FIXTURE_FILES = {
77 "src/a.c": b"int a;\n",
78 "src/b.c": b"int b;\n",
79 "LICENSE": b"MIT\n",
80 "patched.c": b"int patched; /* local */\n",
81 "generated.h": b"/* generated here, not upstream */\n",
82}
83
84
85def _quiet(func: Callable[..., int], *args: object) -> int:
86 """Call `func` with its diagnostics captured, returning only its status.
87
88 The must-fire cases below deliberately provoke real failures; letting their
89 error text through would bury the pass/fail report the selftest exists to
90 print. The status is what is asserted, so only the status is kept.
91 """
92 sink = io.StringIO()
93 with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
94 return func(*args)
95
96
97def _fixture_component(
98 *,
99 key: str = "fixture",
100 upstream_commit: str = "0" * 40,
101 modified: bool = True,
102 patched_files: tuple[tuple[str, str], ...] = (("patched.c", "selftest patch"),),
103 local_files: tuple[tuple[str, str], ...] = (("generated.h", "selftest local"),),
104) -> Component:
105 """Build the selftest's synthetic registry entry."""
106 return Component(
107 key=key,
108 name="fixture",
109 version="0",
110 ctype="library",
111 url="https://example.invalid/fixture",
112 path=FIXTURE_PATH,
113 provenance="commit-pinned-sha256",
114 description="selftest fixture",
115 upstream_commit=upstream_commit,
116 modified=modified,
117 patched_files=patched_files,
118 local_files=local_files,
119 )
120
121
122def _git(args: list[str], cwd: Path) -> None:
123 """Run one git command in `cwd`, discarding its output."""
124 subprocess.run( # noqa: S603 # trusted: fixed git argv, no shell
125 [trusted_git_executable(), *args],
126 cwd=cwd,
127 capture_output=True,
128 text=True,
129 check=True,
130 )
131
132
133def _write_fixture_repo(root: Path) -> dict[str, tuple[str, str]]:
134 """Materialise a REAL git repository holding the fixture vendored tree.
135
136 A real repository, not a stub: Git supplies the same tracked/untracked
137 worktree census the gate uses in CI, and the gate derives raw blob ids from
138 those files.
139
140 Args:
141 root: Scratch directory to initialise as a repository.
142
143 Returns:
144 ``{rel path: (mode, blob)}`` exactly as `git_ls_files` will report it.
145 """
146 _git(["init", "-q", "-b", "main", "."], root)
147 for rel, data in _FIXTURE_FILES.items():
148 target = root / FIXTURE_PATH / rel
149 target.parent.mkdir(parents=True, exist_ok=True)
150 target.write_bytes(data)
151 _git(["add", "-A"], root)
152 return git_ls_files(FIXTURE_PATH, (), root)
153
154
155def _selftest_worktree_cases(
156 root: Path, original: dict[str, tuple[str, str]]
157) -> list[tuple[str, bool]]:
158 """Prove unstaged bytes, modes, additions, and deletions affect the census."""
159 source = root / FIXTURE_PATH / "src/a.c"
160 source.write_bytes(b"int changed;\n")
161 mutated = git_ls_files(FIXTURE_PATH, (), root)
162 source.write_bytes(_FIXTURE_FILES["src/a.c"])
163
164 added_path = root / FIXTURE_PATH / "untracked.c"
165 added_path.write_bytes(b"int untracked;\n")
166 added = git_ls_files(FIXTURE_PATH, (), root)
167 added_path.unlink()
168
169 deleted_path = root / FIXTURE_PATH / "src/b.c"
170 deleted_path.unlink()
171 deleted = git_ls_files(FIXTURE_PATH, (), root)
172 deleted_path.write_bytes(_FIXTURE_FILES["src/b.c"])
173
174 source.chmod(0o755)
175 remoded = git_ls_files(FIXTURE_PATH, (), root)
176 source.chmod(0o644)
177 return [
178 (
179 "MUST FIRE: an unstaged vendored-byte mutation changes the worktree blob",
180 mutated["src/a.c"][1] != original["src/a.c"][1],
181 ),
182 (
183 "MUST FIRE: an untracked vendored file enters the worktree census",
184 "untracked.c" in added,
185 ),
186 (
187 "MUST FIRE: a deleted tracked vendor file leaves the worktree census",
188 "src/b.c" not in deleted,
189 ),
190 (
191 "MUST FIRE: an unstaged executable-bit change changes the worktree mode",
192 remoded["src/a.c"][0] == "100755",
193 ),
194 ]
195
196
197def _write_fixture_manifest(root: Path, ours: dict[str, tuple[str, str]], **mutate: str) -> None:
198 """Write the fixture's manifest, optionally corrupting one field.
199
200 Args:
201 root: Scratch repository root.
202 ours: The fixture's ``{rel path: (mode, blob)}``.
203 mutate: ``kind``/``blob``/``mode``/``drop``/``extra`` knobs the
204 must-fire cases use to break exactly one thing.
205 """
206 entries: list[Entry] = []
207 for rel, (mode, blob) in sorted(ours.items()):
208 if rel == mutate.get("drop"):
209 continue
210 if rel == "patched.c":
211 entries.append(Entry(KIND_PATCH, mode, rel, upstream_blob="1" * 40, local_blob=blob))
212 elif rel == "generated.h":
213 entries.append(Entry(KIND_LOCAL, mode, rel, local_blob=blob))
214 else:
215 entries.append(Entry(KIND_OK, mode, rel, upstream_blob=blob))
216 entries = [
217 Entry(
218 e.kind,
219 mutate["mode"] if e.rel_path == mutate.get("mode_of") else e.mode,
220 e.rel_path,
221 upstream_blob=("2" * 40 if e.rel_path == mutate.get("blob_of") else e.upstream_blob),
222 local_blob=("3" * 40 if e.rel_path == mutate.get("local_of") else e.local_blob),
223 upstream_path=e.upstream_path,
224 )
225 for e in entries
226 ]
227 if mutate.get("extra"):
228 entries.append(Entry(KIND_OK, "100644", mutate["extra"], upstream_blob="4" * 40))
229 header = {"upstream-url": "https://example.invalid", "ref": "v0", "commit": "0" * 40}
230 out = root / manifest_path("fixture")
231 out.parent.mkdir(parents=True, exist_ok=True)
232 out.write_text(format_manifest("fixture", header, entries), encoding="utf-8")
233
234
235def _selftest_manifest_cases(
236 root: Path, ours: dict[str, tuple[str, str]]
237) -> list[tuple[str, bool]]:
238 """Break the MANIFEST one way at a time and assert `run_check`'s verdict.
239
240 Args:
241 root: The scratch repository from `_write_fixture_repo`.
242 ours: That fixture's ``{rel path: (mode, blob)}``.
243
244 Returns:
245 One ``(label, passed)`` pair per assertion.
246 """
247 comps = (_fixture_component(),)
248
249 def verdict(**mutate: str) -> int:
250 _write_fixture_manifest(root, ours, **mutate)
251 return _quiet(run_check, comps, root, FIXTURE_FLOORS)
252
253 return [
254 ("MUST NOT FIRE: an untouched fixture verifies clean", verdict() == EXIT_OK),
255 (
256 "MUST FIRE: a vendored file that is not the upstream blob",
257 verdict(blob_of="src/a.c") == EXIT_FAIL,
258 ),
259 (
260 "MUST FIRE: a mode that disagrees with upstream",
261 verdict(mode_of="src/b.c", mode="100755") == EXIT_FAIL,
262 ),
263 (
264 "MUST FIRE: an edit on top of a reviewed patch",
265 verdict(local_of="patched.c") == EXIT_FAIL,
266 ),
267 (
268 "MUST FIRE: a tracked file absent from the manifest",
269 verdict(drop="src/b.c") == EXIT_FAIL,
270 ),
271 (
272 "MUST FIRE: a manifest record with no file in the tree",
273 verdict(extra="ghost.c") == EXIT_FAIL,
274 ),
275 ]
276
277
278def _selftest_registry_cases(
279 root: Path, ours: dict[str, tuple[str, str]]
280) -> list[tuple[str, bool]]:
281 """Hold the manifest correct and break the REGISTRY's declarations instead.
282
283 Args:
284 root: The scratch repository from `_write_fixture_repo`.
285 ours: That fixture's ``{rel path: (mode, blob)}``.
286
287 Returns:
288 One ``(label, passed)`` pair per assertion.
289 """
290 _write_fixture_manifest(root, ours)
291 comps = (_fixture_component(),)
292
293 def verdict(
294 *,
295 key: str = "fixture",
296 upstream_commit: str = "0" * 40,
297 modified: bool = True,
298 patched_files: tuple[tuple[str, str], ...] = (("patched.c", "selftest patch"),),
299 local_files: tuple[tuple[str, str], ...] = (("generated.h", "selftest local"),),
300 ) -> int:
301 component = _fixture_component(
302 key=key,
303 upstream_commit=upstream_commit,
304 modified=modified,
305 patched_files=patched_files,
306 local_files=local_files,
307 )
308 return _quiet(run_check, (component,), root, FIXTURE_FLOORS)
309
310 return [
311 (
312 "MUST FIRE: a patch/local record the registry does not declare",
313 verdict(patched_files=(), local_files=()) == EXIT_FAIL,
314 ),
315 (
316 "MUST FIRE: a patched file on a component recording modified=False",
317 verdict(modified=False) == EXIT_FAIL,
318 ),
319 (
320 "MUST FIRE: a declaration for a file that is byte-identical to upstream (stale)",
321 verdict(
322 patched_files=(("patched.c", "why"), ("src/a.c", "a patch that no longer exists"))
323 )
324 == EXIT_FAIL,
325 ),
326 (
327 "MUST FIRE: the registry pin and the verified pin are different revisions",
328 verdict(upstream_commit="7" * 40) == EXIT_FAIL,
329 ),
330 (
331 "MUST FIRE: a vendored component with no manifest at all",
332 verdict(key="absent") == EXIT_VACUOUS,
333 ),
334 (
335 "MUST FIRE: a manifest whose rows prove nothing against upstream",
336 _quiet(run_check, comps, root, (1, 1, 99)) == EXIT_VACUOUS,
337 ),
338 (
339 "MUST FIRE: a scan covering fewer components than the floor",
340 _quiet(run_check, comps, root, (99, 1, 1)) == EXIT_VACUOUS,
341 ),
342 ]
343
344
345# Each row: (label, vendored path, our (mode, blob), upstream listing).
346# `_resolve_entry` must REFUSE every one of them rather than write a record --
347# a refresh that quietly classified any of these would launder the defect.
348_REFUSAL_CASES = (
349 (
350 "an undeclared file whose bytes differ from upstream",
351 "src/a.c",
352 {"src/a.c": ("100644", "f" * 40)},
353 ),
354 ("an undeclared file upstream does not have at all", "invented.c", {}),
355 (
356 "a 'local' file upstream turns out to publish",
357 "generated.h",
358 {"generated.h": ("100644", "f" * 40)},
359 ),
360 (
361 "a 'local' file whose bytes exist elsewhere upstream",
362 "generated.h",
363 {"somewhere/else.h": ("100644", "9" * 40)},
364 ),
365 ("a 'patch' of a file upstream does not have", "patched.c", {}),
366 (
367 "a 'patch' declaration on a file identical to upstream (stale)",
368 "patched.c",
369 {"patched.c": ("100644", "9" * 40)},
370 ),
371)
372
373
374def _selftest_resolve_cases() -> list[tuple[str, bool]]:
375 """Assert `_resolve_entry`'s mappings, and its refusal to invent a deviation."""
376 comp = _fixture_component()
377 tree = {"src/a.c": ("100644", "a" * 40), "moved/here.c": ("100644", "c" * 40)}
378 cases: list[tuple[str, bool]] = [
379 (
380 "MUST NOT FIRE: a byte-identical file resolves as 'ok'",
381 _resolve_entry(comp, "src/a.c", ("100644", "a" * 40), tree).kind == KIND_OK,
382 ),
383 (
384 "MUST NOT FIRE: a relocated but identical file resolves as 'moved'",
385 _resolve_entry(comp, "flat.c", ("100644", "c" * 40), tree).upstream_path
386 == "moved/here.c",
387 ),
388 (
389 "MUST NOT FIRE: a declared patch keeps upstream's hash alongside ours",
390 _resolve_entry(
391 comp, "patched.c", ("100644", "d" * 40), {"patched.c": ("100644", "e" * 40)}
392 ).upstream_blob
393 == "e" * 40,
394 ),
395 ]
396 for label, path, tree_arg in _REFUSAL_CASES:
397 fired = False
398 try:
399 _resolve_entry(comp, path, ("100644", "9" * 40), tree_arg)
400 except VacuousScanError:
401 fired = True
402 cases.append((f"MUST FIRE: --refresh refuses {label}", fired))
403 return cases
404
405
406def _selftest_format_cases() -> list[tuple[str, bool]]:
407 """Assert the manifest format round-trips and rejects malformed records."""
408 entries = [
409 Entry(KIND_OK, "100644", "a.c", upstream_blob="a" * 40),
410 Entry(KIND_MOVED, "120000", "b.c", upstream_blob="b" * 40, upstream_path="up/b.c"),
411 Entry(KIND_PATCH, "100644", "c.c", upstream_blob="c" * 40, local_blob="d" * 40),
412 Entry(KIND_LOCAL, "100755", "d.c", local_blob="e" * 40),
413 ]
414 text = format_manifest("fixture", {"commit": "0" * 40}, entries)
415 parsed = parse_manifest("fixture", text, Path("fixture"))
416 cases = [
417 ("MUST NOT FIRE: every record kind round-trips", list(parsed.entries) == entries),
418 (
419 "MUST NOT FIRE: only ok/moved rows count as upstream-verified",
420 parsed.verified_count() == FIXTURE_VERIFIED_ROWS,
421 ),
422 ]
423 for label, bad in (
424 ("an unknown record kind", "bogus 100644 " + "a" * 40 + " a.c"),
425 ("a truncated blob id", "ok 100644 abc a.c"),
426 ("a non-octal file mode", "ok 10x644 " + "a" * 40 + " a.c"),
427 ("a duplicate path", "ok 100644 " + "a" * 40 + " a.c\nok 100644 " + "b" * 40 + " a.c"),
428 ):
429 fired = False
430 try:
431 parse_manifest("fixture", f"# component: fixture\n{bad}\n", Path("fixture"))
432 except ManifestError:
433 fired = True
434 cases.append((f"MUST FIRE: the parser rejects {label}", fired))
435 cases.append(
436 (
437 "MUST FIRE: the parser rejects a manifest naming another component",
438 _raises_manifest_error("other", "# component: fixture\n"),
439 )
440 )
441 return cases
442
443
444def _raises_manifest_error(key: str, text: str) -> bool:
445 """Return True when `parse_manifest` rejects `text` for `key`."""
446 try:
447 parse_manifest(key, text, Path("fixture"))
448 except ManifestError:
449 return True
450 return False
451
452
453def _run_selftest_body() -> int:
454 """Prove the gate fires on every provenance defect and stays quiet otherwise.
455
456 Both directions are asserted because only one of them has ever been true of
457 this claim: "byte-identical to upstream" was stated in three places and
458 checked nowhere, so every tree passed and a corrupted one would have too.
459
460 Returns:
461 ``EXIT_OK`` when every case holds, ``EXIT_VACUOUS`` otherwise.
462 """
463 with tempfile.TemporaryDirectory() as tmp:
464 root = Path(tmp)
465 ours = _write_fixture_repo(root)
466 cases = _selftest_worktree_cases(root, ours)
467 cases.extend(_selftest_manifest_cases(root, ours))
468 cases.extend(_selftest_registry_cases(root, ours))
469 cases.extend(_selftest_resolve_cases())
470 cases.extend(_selftest_format_cases())
471 cases.append(
472 (
473 "MUST NOT FIRE: the shipped floors are below the live tree, not zero",
474 MIN_COMPONENTS > 1
475 and MIN_ENTRIES > FLOOR_SANITY_MIN
476 and MIN_UPSTREAM_VERIFIED > FLOOR_SANITY_MIN,
477 )
478 )
479 failed = [label for label, ok in cases if not ok]
480 for label, ok in cases:
481 print(f" {'ok ' if ok else 'FAIL'} {label}")
482 if failed:
483 print(f"check_soup_upstream: selftest FAILED ({len(failed)} case(s))", file=sys.stderr)
484 return EXIT_VACUOUS
485 print(f"check_soup_upstream: selftest passed ({len(cases)} cases, both directions).")
486 return EXIT_OK
487
488
489def run_selftest() -> int:
490 """Run provenance fixtures without inheriting the caller's repository."""
491 with isolated_git_environment():
492 return _run_selftest_body()