ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
gen_sbom.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"""Generate and validate the ra8-firmware Software Bill of Materials (SBOM).
5
6This is the supply-chain provenance gate for the vendored third-party SOUP
7(Software Of Unknown Provenance) under the platform and application vendor
8roots, plus any registry-backed ``tools/<tool>/third_party/<component>`` and the one
9bundled font data asset under ``libs/ra8_fonts/``. It emits a machine-readable
10CycloneDX 1.5 JSON document at ``docs/sbom/ra8-firmware.cdx.json`` that
11records, for every component: name, version, SPDX license (with the
12Apache-2.0 election for the dual-licensed crypto), package URL (purl) where
13one is meaningful, in-tree path, upstream URL, and provenance class.
14
15The curated ``REGISTRY`` in the sibling module ``sbom_registry.py`` is the
16single source of truth for the fields that cannot be derived mechanically
17(license election, upstream URL, purl, provenance); this module is the logic
18that renders and validates it. Everything that CAN be cross-checked against
19the tree is:
20
21 * **Directory drift** -- every direct child of a supported vendor root must
22 have a registry entry, and every registry directory must exist on disk.
23 A newly vendored component with no entry fails the gate.
24 * **Version drift** -- for components whose in-tree headers carry a version
25 macro (the ThreadX family, Mbed TLS, TF-PSA-Crypto, miniz,
26 stb), the macro is re-read from source and compared to the recorded
27 version. Versions are never invented; a component with no upstream
28 release tag (litehtml, NimBLE dev snapshots) is pinned to the exact
29 upstream commit its vendored tree is byte-identical to (T5-09).
30 * **License-file presence** -- each entry that names a LICENSE file must
31 have it on disk. stb ships no standalone LICENSE (text in header tails)
32 and is reported as a known gap rather than a hard failure.
33 * **Content integrity** -- every vendored component carries a SHA-256
34 ``aggregate`` digest over its whole tree, RE-DERIVED from disk on every
35 run (see ``tree_digest``). A single mutated vendored byte changes the
36 digest, so ``--check`` fails.
37
38That last check is *self*-referential by nature: it proves the tree has not
39changed since the SBOM was regenerated, never that the tree was right when it
40was vendored. The complementary check lives in
41``scripts/checks/check_soup_upstream.py`` (#548), which compares every vendored
42file against the blob hash its upstream project publishes for the pinned
43revision. The two are deliberately separate: this one needs no network and
44covers every byte under a component path, that one needs a fetch (done weekly)
45and covers the identity claim the digest cannot reach.
46
47That last one used to be the hole. ``aggregate_sha256`` was a hand-transcribed
48literal in ``sbom_registry.py``, present on four of twenty-three components and
49absent from NimBLE -- the one component that had actually drifted. Nothing
50ever computed it, so ``--check``'s byte-comparison of regenerated-against-
51committed JSON compared a constant with itself, and appending a line to a
52vendored source still printed ``SBOM matches the tree`` with status 0 (#538).
53Provenance is now DERIVED, never transcribed: a value re-computed from the tree
54on each run is the only kind that can disagree with the tree.
55
56The emitted JSON is deterministic (no wall-clock timestamp, content-derived
57serial number, ``ensure_ascii``) so ``--check`` can compare it byte-for-byte
58against the committed file and so the SBOM is reproducible.
59
60Run::
61
62 gen_sbom.py # regenerate the committed SBOM + print a summary
63 gen_sbom.py --check # fail if the committed SBOM is stale, the tree
64 # drifted from the registry, or a vendored file
65 # changed (the CI/hook gate)
66 gen_sbom.py --print # write nothing; print the SBOM JSON to stdout
67 gen_sbom.py --commits # print `<key> <upstream-commit>` per pinned
68 # component (consumed by the weekly OSV scan)
69 gen_sbom.py --selftest # prove the digest detects a mutation AND stays
70 # stable on an unchanged tree, then exit
71
72Exit 0 if clean, 1 on drift / a catalogued-tree mismatch (including a version
73macro that no longer parses), 2 when the enumeration itself collapsed and no
74verdict is possible. argparse exits 2 on a usage error.
75"""
76
77from __future__ import annotations
78
79import argparse
80import hashlib
81import json
82import re
83import stat
84import subprocess
85import sys
86import tempfile
87import uuid
88from pathlib import Path
89from typing import TextIO
90
91sys.path.insert(0, str(Path(__file__).resolve().parent))
92sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
93
94from git_environment import isolated_git_environment, trusted_git_executable
95from sbom_registry import (
96 PROV_NOT_VENDORED,
97 REGISTRY,
98 Component,
99)
100
101REPO_ROOT = Path(__file__).resolve().parents[2]
102FIXED_VENDOR_ROOTS = (
103 Path("libs/third_party"),
104 Path("apps/shared_libs/third_party"),
105)
106SBOM_REL_PATH = Path("docs/sbom/ra8-firmware.cdx.json")
107
108PROJECT_NAME = "ra8-firmware"
109BOM_FORMAT = "CycloneDX"
110CYCLONEDX_SPEC = "1.5"
111BOM_REVISION = 1
112GENERATOR_NAME = "gen_sbom.py"
113
114DIGEST_ALG = "SHA-256"
115GIT_MODE_SYMLINK = "120000"
116TOOL_VENDOR_ROOT_PARTS = 3
117
118# Vacuity floors. A digest over an empty file list is a perfectly stable
119# hash of nothing, and would report a component as verified when its
120# enumeration had collapsed -- the same shape as every other finding under the
121# gate-honesty epic. Both floors are MEASURED, not round: 9738 file records
122# across the 22 vendored components on 2026-07-28 (nimble 827, threadx 4758,
123# ... fonts/Literata 1). The total floor is set well below that so ordinary
124# vendored churn does not trip it, but far above any plausible collapse.
125COMPONENT_FILE_FLOOR = 1
126TOTAL_FILE_FLOOR = 5000
127
128EXIT_OK = 0
129EXIT_DRIFT = 1
130EXIT_VACUOUS = 2
131
132
133class VacuousScanError(Exception):
134 """Raised when an enumeration collapsed and no honest verdict is possible."""
135
136
137def _git_ls_files(rel_path: str, root: Path = REPO_ROOT) -> list[tuple[str, str]]:
138 """Return ``(git mode, repo-relative path)`` for the worktree under `rel_path`.
139
140 Git supplies tracked plus untracked/non-ignored path names rather than a
141 filesystem walk, so build output and ignored scratch files cannot perturb a
142 provenance hash. Deleted index entries are dropped; current worktree modes
143 are recorded so unstaged moves, additions, deletions, and chmod changes are
144 all visible to the gate.
145
146 Args:
147 rel_path: Repo-relative path of a component (a directory or one file).
148 root: Repository worktree to enumerate; defaults to this checkout.
149
150 Returns:
151 ``(mode, path)`` pairs, unsorted; ``digest_entries`` imposes the order.
152
153 Raises:
154 VacuousScanError: When ``git ls-files`` cannot run at all.
155 """
156 proc = subprocess.run( # noqa: S603 # trusted: fixed git argv, no shell
157 [ # noqa: S607 -- trusted: fixed git argv
158 "git",
159 "ls-files",
160 "--cached",
161 "--others",
162 "--exclude-standard",
163 "-z",
164 "--",
165 rel_path,
166 ],
167 cwd=root,
168 capture_output=True,
169 text=True,
170 check=False,
171 )
172 if proc.returncode != 0:
173 message = f"`git ls-files -- {rel_path}` failed ({proc.returncode}): {proc.stderr.strip()}"
174 raise VacuousScanError(message)
175 entries: list[tuple[str, str]] = []
176 for record in proc.stdout.split("\0"):
177 if not record:
178 continue
179 path = record
180 source = root / path
181 if not source.exists() and not source.is_symlink():
182 continue
183 if source.is_symlink():
184 mode = GIT_MODE_SYMLINK
185 else:
186 mode = "100755" if source.stat().st_mode & stat.S_IXUSR else "100644"
187 entries.append((mode, path))
188 return entries
189
190
191def digest_entries(base: Path, entries: list[tuple[str, str]], strip: str) -> str:
192 """Hash `entries` into one SHA-256 over path, mode and content.
193
194 Every field is length-framed before it is fed to the hash, so no rename can
195 be made to collide with a content edit by moving bytes across the boundary
196 between the two. Paths are made component-relative (``strip`` is removed)
197 so relocating a vendored tree wholesale is not reported as a modification,
198 while renaming a file *inside* it is.
199
200 Args:
201 base: Directory the paths in `entries` are resolved against.
202 entries: ``(git mode, path)`` pairs from `_git_ls_files`.
203 strip: Path prefix to remove, making each path component-relative.
204
205 Returns:
206 The lower-case hex SHA-256 digest.
207
208 Raises:
209 VacuousScanError: When `entries` is empty -- a digest of nothing is stable
210 and meaningless, and must never render as a verified component.
211 """
212 if len(entries) < COMPONENT_FILE_FLOOR:
213 message = f"'{strip}' enumerated 0 tracked files, floor is {COMPONENT_FILE_FLOOR}"
214 raise VacuousScanError(message)
215 prefix = strip.rstrip("/") + "/"
216 hasher = hashlib.sha256()
217 for mode, path in sorted(entries, key=lambda item: item[1]):
218 inner = path[len(prefix) :] if path.startswith(prefix) else Path(path).name
219 target = base / path
220 payload = (
221 str(target.readlink()).encode() if mode == GIT_MODE_SYMLINK else target.read_bytes()
222 )
223 header = f"{mode} {len(inner)} {inner} {len(payload)}\n".encode()
224 hasher.update(header)
225 hasher.update(payload)
226 return hasher.hexdigest()
227
228
229_DIGEST_CACHE: dict[str, tuple[str, int]] = {}
230
231
232def tree_digest(comp: Component) -> tuple[str, int]:
233 """Re-derive `comp`'s integrity digest and tracked-file count from the tree.
234
235 Args:
236 comp: The registry component to hash.
237
238 Returns:
239 ``(hex digest, file count)``.
240
241 Raises:
242 VacuousScanError: When the component enumerates no tracked file.
243 """
244 if comp.path not in _DIGEST_CACHE:
245 entries = _git_ls_files(comp.path)
246 _DIGEST_CACHE[comp.path] = (digest_entries(REPO_ROOT, entries, comp.path), len(entries))
247 return _DIGEST_CACHE[comp.path]
248
249
250def hashed_components() -> tuple[Component, ...]:
251 """Return the registry entries that must carry a derived integrity digest.
252
253 Everything vendored qualifies. ``PROV_NOT_VENDORED`` is the single
254 exclusion and it is self-proving: `cross_check` already errors when such a
255 path exists on disk, so the class cannot be used to hide a real tree.
256 """
257 return tuple(comp for comp in REGISTRY if comp.provenance != PROV_NOT_VENDORED)
258
259
260def _read_source(comp: Component) -> str | None:
261 """Return the text of `comp`'s version-probe file, or None if unreadable."""
262 if comp.probe_file is None:
263 return None
264 path = REPO_ROOT / comp.path / comp.probe_file
265 if not path.is_file():
266 return None
267 return path.read_text(encoding="utf-8", errors="replace")
268
269
270def probe_version(comp: Component) -> str | None:
271 """Re-derive `comp`'s version from its in-tree source, or None.
272
273 Supports two shapes: a single-capture regex (``probe_re``) or a
274 MAJOR/MINOR/PATCH macro triplet identified by ``probe_prefix``.
275 """
276 text = _read_source(comp)
277 if text is None:
278 return None
279 if comp.probe_re is not None:
280 match = re.search(comp.probe_re, text)
281 return match.group(1) if match else None
282 if comp.probe_prefix is not None:
283 parts = []
284 for level in ("MAJOR", "MINOR", "PATCH"):
285 pattern = rf"{comp.probe_prefix}_{level}_VERSION(?:\s+|\s*=\s*)(\d+)"
286 match = re.search(pattern, text)
287 if match is None:
288 return None
289 parts.append(match.group(1))
290 return ".".join(parts)
291 return None
292
293
294def _vendor_root_for(path: Path) -> Path | None:
295 """Return the supported vendor root containing ``path``, if any.
296
297 Tool SOUP is permitted only at ``tools/<tool>/third_party/<component>``.
298 This keeps ownership local without creating a repository-wide tools vendor
299 bucket, while allowing the registry to grow if a truly tool-exclusive
300 dependency is introduced later.
301 """
302 parts = path.parts
303 if parts[:2] == ("libs", "third_party"):
304 return Path(*parts[:2])
305 if parts[:3] == ("apps", "shared_libs", "third_party"):
306 return Path(*parts[:3])
307 if len(parts) >= TOOL_VENDOR_ROOT_PARTS and parts[0] == "tools" and parts[2] == "third_party":
308 return Path(*parts[:3])
309 return None
310
311
312def _vendor_roots() -> tuple[Path, ...]:
313 """Discover fixed, registry-declared, and on-disk tool-private roots."""
314 roots = set(FIXED_VENDOR_ROOTS)
315 tools = REPO_ROOT / "tools"
316 if tools.is_dir():
317 roots.update(
318 Path("tools") / tool.name / "third_party"
319 for tool in tools.iterdir()
320 if tool.is_dir() and (tool / "third_party").is_dir()
321 )
322 for comp in REGISTRY:
323 root = _vendor_root_for(Path(comp.path))
324 if root is not None:
325 roots.add(root)
326 return tuple(sorted(roots, key=lambda path: path.as_posix()))
327
328
329def _third_party_dirs() -> set[str]:
330 """Return repo-relative direct-child paths under every supported vendor root."""
331 found: set[str] = set()
332 for rel_root in _vendor_roots():
333 base = REPO_ROOT / rel_root
334 if base.is_dir():
335 found.update(
336 (rel_root / path.name).as_posix() for path in base.iterdir() if path.is_dir()
337 )
338 return found
339
340
341def _catalogued_top_dirs() -> set[str]:
342 """Return direct-child paths of supported vendor roots covered by the registry.
343
344 A nested path such as ``esp-hosted/common/protobuf-c`` catalogues the
345 ``esp-hosted`` top-level directory, so a tree carrying a separately
346 pinned sub-component does not read as uncatalogued.
347 """
348 dirs: set[str] = set()
349 for comp in REGISTRY:
350 parts = Path(comp.path).parts
351 for rel_root in _vendor_roots():
352 prefix = rel_root.parts
353 if parts[: len(prefix)] == prefix and len(parts) > len(prefix):
354 dirs.add((rel_root / parts[len(prefix)]).as_posix())
355 break
356 return dirs
357
358
359def _directory_drift(on_disk: set[str], catalogued: set[str]) -> list[str]:
360 """Return both directions of vendor-root/registry drift."""
361 errors = [
362 f"{extra}: on disk but not in REGISTRY (uncatalogued SOUP)"
363 for extra in sorted(on_disk - catalogued)
364 ]
365 errors.extend(
366 f"{missing}: in REGISTRY but not on disk" for missing in sorted(catalogued - on_disk)
367 )
368 return errors
369
370
371def cross_check() -> tuple[list[str], list[str]]:
372 """Cross-check the registry against the tree.
373
374 Returns ``(errors, warnings)``. Errors are hard failures (a directory
375 the registry claims is missing, an uncatalogued directory, or a version
376 macro that disagrees with the recorded version). Warnings are advisory
377 (a missing LICENSE file for a component that declares one is an error;
378 stb's documented no-LICENSE gap is a warning).
379 """
380 errors: list[str] = []
381 warnings: list[str] = []
382
383 catalogued = _catalogued_top_dirs()
384 on_disk = _third_party_dirs()
385 errors.extend(_directory_drift(on_disk, catalogued))
386
387 for comp in REGISTRY:
388 comp_path = REPO_ROOT / comp.path
389 if comp.provenance == PROV_NOT_VENDORED:
390 if comp_path.exists():
391 warnings.append(f"{comp.key}: marked not-vendored but present on disk")
392 continue
393 if not comp_path.exists():
394 errors.append(f"{comp.key}: recorded path '{comp.path}' does not exist")
395 continue
396 _check_version(comp, errors)
397 _check_license_file(comp, errors, warnings)
398
399 _check_scan_not_vacuous(errors)
400 return errors, warnings
401
402
403def _check_scan_not_vacuous(errors: list[str]) -> None:
404 """Append an error if the vendored enumeration collapsed below its floor.
405
406 Every component is individually floored inside `digest_entries`; this is
407 the aggregate trip-wire that catches a whole-tree collapse (a bad cwd, a
408 git that ran but returned nothing) before it can render as 23 verified
409 components.
410
411 Args:
412 errors: Error list to append to, in place.
413 """
414 total = 0
415 for comp in hashed_components():
416 try:
417 total += tree_digest(comp)[1]
418 except VacuousScanError as exc: # one bad component must not stop the sweep
419 errors.append(f"{comp.key}: {exc}")
420 if total and total < TOTAL_FILE_FLOOR:
421 errors.append(
422 f"only {total} vendored file(s) enumerated across the registry, floor is "
423 f"{TOTAL_FILE_FLOOR}. A collapsed enumeration reports every component "
424 "verified because it hashed nothing."
425 )
426
427
428def _check_version(comp: Component, errors: list[str]) -> None:
429 """Append an error if the probed version disagrees with the record."""
430 if comp.expected_version is None:
431 return
432 probed = probe_version(comp)
433 if probed is None:
434 errors.append(f"{comp.key}: version probe found no version in '{comp.probe_file}'")
435 elif probed != comp.expected_version:
436 errors.append(
437 f"{comp.key}: version drift -- source says {probed}, "
438 f"registry says {comp.expected_version}"
439 )
440
441
442def _check_license_file(comp: Component, errors: list[str], warnings: list[str]) -> None:
443 """Error on a declared-but-missing LICENSE; warn on the stb gap."""
444 if comp.license_file is None:
445 if comp.spdx is not None:
446 warnings.append(f"{comp.key}: no standalone LICENSE file in-tree (license in headers)")
447 return
448 if not (REPO_ROOT / comp.license_file).is_file():
449 errors.append(f"{comp.key}: declared LICENSE '{comp.license_file}' is missing")
450
451
452def _licenses_block(comp: Component) -> list[dict] | None:
453 """Build the CycloneDX ``licenses`` array for a component."""
454 if comp.spdx is not None:
455 if " OR " in comp.spdx or " AND " in comp.spdx:
456 return [{"expression": comp.spdx}]
457 return [{"license": {"id": comp.spdx}}]
458 if comp.license_name is not None:
459 return [{"license": {"name": comp.license_name}}]
460 return None
461
462
463def _properties_block(comp: Component, file_count: int) -> list[dict]:
464 """Build the CycloneDX ``properties`` array for a component.
465
466 ``ra8:fileCount`` is published alongside the digest deliberately: a digest
467 alone is opaque, so a collapsed enumeration would change it without saying
468 why. The count makes the size of the hashed set visible in the committed
469 SBOM and therefore in every diff.
470
471 Args:
472 comp: The registry component being rendered.
473 file_count: Tracked files that went into `comp`'s digest.
474
475 Returns:
476 The CycloneDX property objects, in stable order.
477 """
478 props: list[dict] = [
479 {"name": "ra8:provenance", "value": comp.provenance},
480 {"name": "ra8:path", "value": comp.path},
481 ]
482 if comp.provenance != PROV_NOT_VENDORED:
483 props.append({"name": "ra8:fileCount", "value": str(file_count)})
484 if comp.upstream_commit is not None:
485 props.append({"name": "ra8:upstreamCommit", "value": comp.upstream_commit})
486 if comp.upstream_ref is not None:
487 props.append({"name": "ra8:upstreamRef", "value": comp.upstream_ref})
488 if comp.upstream_archive_sha256 is not None:
489 props.append({"name": "ra8:upstreamArchiveSha256", "value": comp.upstream_archive_sha256})
490 if comp.license_original is not None:
491 props.append({"name": "ra8:licenseOriginal", "value": comp.license_original})
492 if comp.license_election is not None:
493 props.append({"name": "ra8:licenseElection", "value": comp.license_election})
494 if comp.license_file is not None:
495 props.append({"name": "ra8:licenseFile", "value": comp.license_file})
496 if comp.copyright is not None:
497 props.append({"name": "ra8:copyright", "value": comp.copyright})
498 props.append({"name": "ra8:modified", "value": "true" if comp.modified else "false"})
499 for i, note in enumerate(comp.extra_notes):
500 props.append({"name": f"ra8:note{i}", "value": note})
501 return props
502
503
504def component_entry(comp: Component) -> dict:
505 """Render one registry `Component` as a CycloneDX component object."""
506 entry: dict = {"type": comp.ctype, "bom-ref": comp.key, "name": comp.name}
507 if comp.group is not None:
508 entry["group"] = comp.group
509 entry["version"] = comp.version
510 entry["description"] = comp.description
511 entry["scope"] = comp.scope
512 licenses = _licenses_block(comp)
513 if licenses is not None:
514 entry["licenses"] = licenses
515 if comp.license_note is not None:
516 entry["copyright"] = comp.license_note if comp.copyright is None else comp.copyright
517 if comp.purl is not None:
518 entry["purl"] = comp.purl
519 if comp.provenance != PROV_NOT_VENDORED:
520 digest, count = tree_digest(comp)
521 entry["hashes"] = [{"alg": DIGEST_ALG, "content": digest}]
522 else:
523 count = 0
524 entry["externalReferences"] = [{"type": "vcs", "url": comp.url}]
525 entry["properties"] = _properties_block(comp, count)
526 return entry
527
528
529def _serial_number() -> str:
530 """Return a content-derived (deterministic) CycloneDX serial number."""
531 canonical = "|".join(f"{c.key}={c.version}={c.spdx or c.license_name}" for c in REGISTRY)
532 return f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, canonical)}"
533
534
535def build_bom() -> dict:
536 """Assemble the full CycloneDX 1.5 BOM document as an ordered dict."""
537 return {
538 "bomFormat": BOM_FORMAT,
539 "specVersion": CYCLONEDX_SPEC,
540 "serialNumber": _serial_number(),
541 "version": BOM_REVISION,
542 "metadata": {
543 "tools": [{"vendor": PROJECT_NAME, "name": GENERATOR_NAME}],
544 "component": {
545 "type": "application",
546 "bom-ref": PROJECT_NAME,
547 "name": PROJECT_NAME,
548 "version": "unversioned",
549 "description": "Renesas RA8D2 (Cortex-M85) bare-metal firmware.",
550 },
551 "properties": [
552 {
553 "name": "ra8:sbomNote",
554 "value": (
555 "Generated by scripts/gen/gen_sbom.py from the "
556 "REGISTRY cross-checked against every supported third_party root. "
557 "Human inventory: THIRD_PARTY_LICENSES.md. Per-"
558 "component qualification: docs/SOUP/."
559 ),
560 },
561 ],
562 },
563 "components": [component_entry(c) for c in REGISTRY],
564 }
565
566
567def serialize(bom: dict) -> str:
568 """Serialize the BOM deterministically (ASCII, 2-space indent, newline)."""
569 return json.dumps(bom, indent=2, ensure_ascii=True) + "\n"
570
571
572def _print_summary(warnings: list[str], stream: TextIO) -> None:
573 """Print a one-line-per-class provenance summary to `stream`."""
574 by_prov: dict[str, int] = {}
575 for comp in REGISTRY:
576 by_prov[comp.provenance] = by_prov.get(comp.provenance, 0) + 1
577 print(f"{GENERATOR_NAME}: {len(REGISTRY)} components", file=stream)
578 for prov in sorted(by_prov):
579 print(f" {prov:24s} {by_prov[prov]}", file=stream)
580 for warn in warnings:
581 print(f" WARN {warn}", file=stream)
582
583
584def run_write(to_stdout: bool) -> int:
585 """Regenerate the SBOM; write it (or print it) and report cross-checks."""
586 errors, warnings = cross_check()
587 text = serialize(build_bom())
588 # In --print mode stdout must stay pure JSON, so the summary goes to stderr.
589 summary_stream = sys.stderr if to_stdout else sys.stdout
590 if to_stdout:
591 sys.stdout.write(text)
592 else:
593 out = REPO_ROOT / SBOM_REL_PATH
594 out.parent.mkdir(parents=True, exist_ok=True)
595 out.write_text(text, encoding="utf-8")
596 print(f"{GENERATOR_NAME}: wrote {SBOM_REL_PATH}")
597 _print_summary(warnings, summary_stream)
598 if errors:
599 for err in errors:
600 print(f" ERROR {err}", file=sys.stderr)
601 return EXIT_DRIFT
602 return EXIT_OK
603
604
605def _committed_digests(text: str) -> dict[str, str]:
606 """Extract ``bom-ref -> SHA-256 content`` from a committed SBOM document.
607
608 Args:
609 text: The committed SBOM JSON.
610
611 Returns:
612 One entry per component that publishes a SHA-256 hash; components
613 without one are absent from the mapping.
614 """
615 try:
616 doc = json.loads(text)
617 except json.JSONDecodeError:
618 return {}
619 out: dict[str, str] = {}
620 for entry in doc.get("components", []):
621 for item in entry.get("hashes", []):
622 if item.get("alg") == DIGEST_ALG:
623 out[entry.get("bom-ref", "")] = item.get("content", "")
624 return out
625
626
627def _integrity_errors(committed: str) -> list[str]:
628 """Name every component whose vendored tree no longer hashes to its record.
629
630 This is the message that matters. A bare "the SBOM is stale, regenerate
631 it" would invite exactly the wrong reflex -- regenerating adopts the
632 mutation and the provenance claim is quietly relaxed. Naming the component
633 and the two digests says what actually happened: a file under a vendored
634 SOUP tree changed, and either the change is illegitimate or the component's
635 ``docs/SOUP/`` "Modifications" section owes an entry.
636
637 Args:
638 committed: Text of the committed SBOM document.
639
640 Returns:
641 Human-readable error strings, one per drifted component.
642 """
643 recorded = _committed_digests(committed)
644 errors: list[str] = []
645 for comp in hashed_components():
646 was = recorded.get(comp.key)
647 if was is None:
648 continue
649 try:
650 now, count = tree_digest(comp)
651 except VacuousScanError:
652 continue # already reported by _check_scan_not_vacuous
653 if now != was:
654 errors.append(
655 f"{comp.key}: VENDORED TREE DRIFT -- {count} file(s) under '{comp.path}' "
656 f"now hash to {now}, the committed SBOM records {was}. A vendored SOUP "
657 "tree changed. Do not just regenerate: confirm the change is intended, "
658 f"and record it in docs/SOUP/ as a modification of the upstream pin."
659 )
660 return errors
661
662
663def run_check() -> int:
664 """Fail if the committed SBOM is stale or the tree drifted from the registry."""
665 errors, warnings = cross_check()
666 out = REPO_ROOT / SBOM_REL_PATH
667 if not out.is_file():
668 print(
669 f"{GENERATOR_NAME}: {SBOM_REL_PATH} is missing; run gen_sbom.py",
670 file=sys.stderr,
671 )
672 return EXIT_DRIFT
673 actual = out.read_text(encoding="utf-8")
674 integrity = _integrity_errors(actual)
675 errors = [*errors, *integrity]
676 if actual != serialize(build_bom()) and not integrity:
677 print(
678 f"{GENERATOR_NAME}: {SBOM_REL_PATH} is stale; run gen_sbom.py to regenerate",
679 file=sys.stderr,
680 )
681 errors = [*errors, "committed SBOM does not match the registry"]
682 for warn in warnings:
683 print(f" WARN {warn}")
684 if errors:
685 for err in errors:
686 print(f" ERROR {err}", file=sys.stderr)
687 return EXIT_DRIFT
688 hashed = len(hashed_components())
689 files = sum(tree_digest(comp)[1] for comp in hashed_components())
690 print(
691 f"{GENERATOR_NAME}: SBOM matches the tree ({len(REGISTRY)} components; "
692 f"{hashed} SHA-256 digests re-derived over {files} vendored files)."
693 )
694 return EXIT_OK
695
696
697def run_commits() -> int:
698 """Print one ``<key> <upstream-commit>`` line per commit-pinned component.
699
700 This is the machine interface behind the weekly OSV CVE scan
701 (``scripts/checks/osv_scan.sh``): OSV.dev indexes C/C++ advisories as GIT
702 commit ranges queryable only by commit hash (GitHub purls do not
703 resolve), so the scan materializes each pinned commit as a stub git
704 checkout and lets ``osv-scanner`` issue the exact commit queries.
705 Exits nonzero when the registry carries no pins at all, which would
706 mean the scan is wired to nothing.
707 """
708 pinned = [comp for comp in REGISTRY if comp.upstream_commit is not None]
709 for comp in pinned:
710 print(f"{comp.key} {comp.upstream_commit}")
711 if not pinned:
712 print(f"{GENERATOR_NAME}: no commit-pinned component in REGISTRY", file=sys.stderr)
713 return EXIT_DRIFT
714 return EXIT_OK
715
716
717def _selftest_tree(root: Path) -> list[tuple[str, str]]:
718 """Materialise a small fixture tree under `root` and return its entries.
719
720 Args:
721 root: Directory to create the fixture under.
722
723 Returns:
724 ``(mode, path)`` pairs in the shape `_git_ls_files` produces.
725 """
726 (root / "vendor" / "src").mkdir(parents=True)
727 (root / "vendor" / "src" / "a.c").write_bytes(b"int a;\n")
728 (root / "vendor" / "src" / "b.c").write_bytes(b"int b;\n")
729 (root / "vendor" / "LICENSE").write_bytes(b"MIT\n")
730 return [
731 ("100644", "vendor/src/a.c"),
732 ("100644", "vendor/src/b.c"),
733 ("100644", "vendor/LICENSE"),
734 ]
735
736
737def _selftest_worktree_cases(root: Path) -> list[tuple[str, bool]]:
738 """Prove the SBOM census observes unstaged vendor-tree state both ways."""
739 repo = root / "repo"
740 vendor = repo / "vendor"
741 vendor.mkdir(parents=True)
742 tracked = vendor / "tracked.c"
743 removed = vendor / "removed.c"
744 tracked.write_bytes(b"int tracked;\n")
745 removed.write_bytes(b"int removed;\n")
746 (repo / ".gitignore").write_text("vendor/ignored.c\n", encoding="ascii")
747 subprocess.run( # noqa: S603 -- fixed Git authority and fixture-only argv
748 [trusted_git_executable(), "init", "-q", "-b", "main", "."],
749 cwd=repo,
750 check=True,
751 )
752 subprocess.run( # noqa: S603 -- fixed Git authority and fixture-only argv
753 [trusted_git_executable(), "add", "-A"],
754 cwd=repo,
755 check=True,
756 )
757 original_digest = digest_entries(repo, _git_ls_files("vendor", repo), "vendor")
758
759 tracked.write_bytes(b"int changed;\n")
760 tracked.chmod(0o755)
761 removed.unlink()
762 (vendor / "untracked.c").write_bytes(b"int untracked;\n")
763 (vendor / "ignored.c").write_bytes(b"int ignored;\n")
764 entries = _git_ls_files("vendor", repo)
765 paths = {path for _mode, path in entries}
766 return [
767 (
768 "MUST FIRE: unstaged bytes and mode change the SBOM worktree digest",
769 digest_entries(repo, entries, "vendor") != original_digest
770 and ("100755", "vendor/tracked.c") in entries,
771 ),
772 (
773 "MUST FIRE: an untracked vendor file enters the SBOM census",
774 "vendor/untracked.c" in paths,
775 ),
776 (
777 "MUST FIRE: a deleted tracked vendor file leaves the SBOM census",
778 "vendor/removed.c" not in paths,
779 ),
780 (
781 "MUST NOT FIRE: an ignored vendor file stays outside the SBOM census",
782 "vendor/ignored.c" not in paths,
783 ),
784 ]
785
786
787def _selftest_shape_cases(
788 root: Path, entries: list[tuple[str, str]], base: str
789) -> list[tuple[str, bool]]:
790 """Assert that changing the SHAPE of the file set changes the digest.
791
792 Content mutation is covered by the caller; these are the cases a naive
793 "hash the concatenated bytes" digest would miss -- an added or removed
794 file, and a mode change.
795
796 Args:
797 root: The fixture root from `_selftest_tree`.
798 entries: That fixture's entry list, content-restored.
799 base: The digest of the unmodified fixture.
800
801 Returns:
802 One ``(label, passed)`` pair per assertion.
803 """
804 (root / "vendor" / "src" / "c.c").write_bytes(b"int a;\n")
805 vacuous = False
806 try:
807 digest_entries(root, [], "vendor")
808 except VacuousScanError:
809 vacuous = True
810 return [
811 (
812 "MUST FIRE: an added file changes the digest",
813 digest_entries(root, [*entries, ("100644", "vendor/src/c.c")], "vendor") != base,
814 ),
815 (
816 "MUST FIRE: a removed file changes the digest",
817 digest_entries(root, entries[:-1], "vendor") != base,
818 ),
819 (
820 "MUST FIRE: a mode change changes the digest",
821 digest_entries(root, [("100755", entries[0][1]), *entries[1:]], "vendor") != base,
822 ),
823 ("MUST FIRE: an empty enumeration raises rather than hashing nothing", vacuous),
824 ]
825
826
827def _selftest_registry_cases() -> list[tuple[str, bool]]:
828 """Return registry/tree ownership assertions for every supported vendor shape."""
829 return [
830 (
831 "MUST FIRE: the live registry publishes a digest for every vendored component",
832 len(hashed_components()) == len(REGISTRY) - 1,
833 ),
834 (
835 "MUST NOT FIRE: matching entries across all vendor-root shapes stay quiet",
836 not _directory_drift(
837 {
838 "libs/third_party/platform",
839 "apps/shared_libs/third_party/app",
840 "tools/viewer/third_party/tool_only",
841 },
842 {
843 "libs/third_party/platform",
844 "apps/shared_libs/third_party/app",
845 "tools/viewer/third_party/tool_only",
846 },
847 ),
848 ),
849 (
850 "MUST FIRE: an uncatalogued app vendor is detected",
851 bool(
852 _directory_drift(
853 {"libs/third_party/platform", "apps/shared_libs/third_party/extra"},
854 {"libs/third_party/platform"},
855 )
856 ),
857 ),
858 (
859 "MUST FIRE: a missing app vendor is detected",
860 bool(
861 _directory_drift(
862 {"libs/third_party/platform"},
863 {"libs/third_party/platform", "apps/shared_libs/third_party/app"},
864 )
865 ),
866 ),
867 (
868 "MUST NOT FIRE: the narrow tool-private vendor shape is supported",
869 _vendor_root_for(Path("tools/viewer/third_party/decoder"))
870 == Path("tools/viewer/third_party"),
871 ),
872 (
873 "MUST FIRE: a repository-wide tools vendor bucket is unsupported",
874 _vendor_root_for(Path("tools/third_party/decoder")) is None,
875 ),
876 ]
877
878
879def _selftest_cases(root: Path, entries: list[tuple[str, str]]) -> list[tuple[str, bool]]:
880 """Run every digest assertion against the fixture and return ``(label, ok)``.
881
882 Args:
883 root: The fixture root from `_selftest_tree`.
884 entries: That fixture's entry list.
885
886 Returns:
887 One ``(label, passed)`` pair per assertion, both directions covered.
888 """
889 base = digest_entries(root, entries, "vendor")
890 cases: list[tuple[str, bool]] = [
891 (
892 "MUST NOT FIRE: an unchanged tree hashes identically",
893 digest_entries(root, entries, "vendor") == base,
894 ),
895 (
896 "MUST NOT FIRE: enumeration order does not change the digest",
897 digest_entries(root, list(reversed(entries)), "vendor") == base,
898 ),
899 ]
900
901 (root / "vendor" / "src" / "a.c").write_bytes(b"int a;\n/* injected */\n")
902 cases.append(
903 (
904 "MUST FIRE: one mutated vendored byte changes the digest",
905 digest_entries(root, entries, "vendor") != base,
906 )
907 )
908 (root / "vendor" / "src" / "a.c").write_bytes(b"int a;\n")
909 cases.append(
910 (
911 "MUST NOT FIRE: restoring the byte restores the digest",
912 digest_entries(root, entries, "vendor") == base,
913 )
914 )
915
916 cases.extend(_selftest_shape_cases(root, entries, base))
917
918 cases.extend(_selftest_registry_cases())
919 return cases
920
921
922def _run_selftest_body() -> int:
923 """Prove the integrity digest fires on a mutation and stays quiet otherwise.
924
925 Both directions are asserted because only one of them was ever true before:
926 the old hardcoded ``aggregate_sha256`` was perfectly stable on an unchanged
927 tree and equally stable on a mutated one. A selftest that checked only the
928 quiet direction would have passed against the broken code (#538).
929
930 Returns:
931 ``EXIT_OK`` when every case holds, ``EXIT_VACUOUS`` otherwise.
932 """
933 with tempfile.TemporaryDirectory() as tmp:
934 root = Path(tmp)
935 cases = _selftest_cases(root, _selftest_tree(root))
936 cases.extend(_selftest_worktree_cases(root))
937 failed = [label for label, ok in cases if not ok]
938 for label, ok in cases:
939 print(f" {'ok ' if ok else 'FAIL'} {label}")
940 if failed:
941 print(f"{GENERATOR_NAME}: selftest FAILED ({len(failed)} case(s))", file=sys.stderr)
942 return EXIT_VACUOUS
943 print(f"{GENERATOR_NAME}: selftest passed ({len(cases)} cases, both directions).")
944 return EXIT_OK
945
946
947def run_selftest() -> int:
948 """Run SBOM worktree fixtures without inheriting the caller's repository."""
949 with isolated_git_environment():
950 return _run_selftest_body()
951
952
953def main(argv: list[str]) -> int:
954 """Parse arguments and dispatch to the write / check / print / commits action."""
955 parser = argparse.ArgumentParser(description="Generate/validate the ra8-firmware SBOM.")
956 parser.add_argument(
957 "--check",
958 action="store_true",
959 help="fail if the committed SBOM is stale or the tree drifted",
960 )
961 parser.add_argument(
962 "--selftest",
963 action="store_true",
964 help="prove the integrity digest detects a mutation, then exit",
965 )
966 parser.add_argument(
967 "--print",
968 dest="to_stdout",
969 action="store_true",
970 help="print the SBOM to stdout instead of writing the file",
971 )
972 parser.add_argument(
973 "--commits",
974 action="store_true",
975 help="print `<key> <upstream-commit>` per commit-pinned component",
976 )
977 args = parser.parse_args(argv)
978 if args.selftest:
979 return run_selftest()
980 try:
981 if args.check:
982 return run_check()
983 if args.commits:
984 return run_commits()
985 return run_write(args.to_stdout)
986 except VacuousScanError as exc:
987 print(f"{GENERATOR_NAME}: FATAL -- {exc}", file=sys.stderr)
988 return EXIT_VACUOUS
989
990
991if __name__ == "__main__":
992 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298