4"""Generate and validate the ra8-firmware Software Bill of Materials (SBOM).
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.
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
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.
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.
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.
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.
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
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.
77from __future__
import annotations
88from pathlib
import Path
89from typing
import TextIO
91sys.path.insert(0, str(Path(__file__).resolve().parent))
92sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
94from git_environment
import isolated_git_environment, trusted_git_executable
95from sbom_registry
import (
101REPO_ROOT = Path(__file__).resolve().parents[2]
102FIXED_VENDOR_ROOTS = (
103 Path(
"libs/third_party"),
104 Path(
"apps/shared_libs/third_party"),
106SBOM_REL_PATH = Path(
"docs/sbom/ra8-firmware.cdx.json")
108PROJECT_NAME =
"ra8-firmware"
109BOM_FORMAT =
"CycloneDX"
110CYCLONEDX_SPEC =
"1.5"
112GENERATOR_NAME =
"gen_sbom.py"
114DIGEST_ALG =
"SHA-256"
115GIT_MODE_SYMLINK =
"120000"
116TOOL_VENDOR_ROOT_PARTS = 3
125COMPONENT_FILE_FLOOR = 1
126TOTAL_FILE_FLOOR = 5000
133class VacuousScanError(Exception):
134 """Raised when an enumeration collapsed and no honest verdict is possible."""
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`.
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.
147 rel_path: Repo-relative path of a component (a directory or one file).
148 root: Repository worktree to enumerate; defaults to this checkout.
151 ``(mode, path)`` pairs, unsorted; ``digest_entries`` imposes the order.
154 VacuousScanError: When ``git ls-files`` cannot run at all.
156 proc = subprocess.run(
162 "--exclude-standard",
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"):
181 if not source.exists()
and not source.is_symlink():
183 if source.is_symlink():
184 mode = GIT_MODE_SYMLINK
186 mode =
"100755" if source.stat().st_mode & stat.S_IXUSR
else "100644"
187 entries.append((mode, path))
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.
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.
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.
206 The lower-case hex SHA-256 digest.
209 VacuousScanError: When `entries` is empty -- a digest of nothing is stable
210 and meaningless, and must never render as a verified component.
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
221 str(target.readlink()).encode()
if mode == GIT_MODE_SYMLINK
else target.read_bytes()
223 header = f
"{mode} {len(inner)} {inner} {len(payload)}\n".encode()
224 hasher.update(header)
225 hasher.update(payload)
226 return hasher.hexdigest()
229_DIGEST_CACHE: dict[str, tuple[str, int]] = {}
232def tree_digest(comp: Component) -> tuple[str, int]:
233 """Re-derive `comp`'s integrity digest and tracked-file count from the tree.
236 comp: The registry component to hash.
239 ``(hex digest, file count)``.
242 VacuousScanError: When the component enumerates no tracked file.
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]
250def hashed_components() -> tuple[Component, ...]:
251 """Return the registry entries that must carry a derived integrity digest.
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.
257 return tuple(comp
for comp
in REGISTRY
if comp.provenance != PROV_NOT_VENDORED)
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:
264 path = REPO_ROOT / comp.path / comp.probe_file
265 if not path.is_file():
267 return path.read_text(encoding=
"utf-8", errors=
"replace")
270def probe_version(comp: Component) -> str |
None:
271 """Re-derive `comp`'s version from its in-tree source, or None.
273 Supports two shapes: a single-capture regex (``probe_re``) or a
274 MAJOR/MINOR/PATCH macro triplet identified by ``probe_prefix``.
276 text = _read_source(comp)
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:
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)
289 parts.append(match.group(1))
290 return ".".join(parts)
294def _vendor_root_for(path: Path) -> Path |
None:
295 """Return the supported vendor root containing ``path``, if any.
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.
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])
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"
318 Path(
"tools") / tool.name /
"third_party"
319 for tool
in tools.iterdir()
320 if tool.is_dir()
and (tool /
"third_party").is_dir()
322 for comp
in REGISTRY:
323 root = _vendor_root_for(Path(comp.path))
326 return tuple(sorted(roots, key=
lambda path: path.as_posix()))
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
336 (rel_root / path.name).as_posix()
for path
in base.iterdir()
if path.is_dir()
341def _catalogued_top_dirs() -> set[str]:
342 """Return direct-child paths of supported vendor roots covered by the registry.
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.
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())
359def _directory_drift(on_disk: set[str], catalogued: set[str]) -> list[str]:
360 """Return both directions of vendor-root/registry drift."""
362 f
"{extra}: on disk but not in REGISTRY (uncatalogued SOUP)"
363 for extra
in sorted(on_disk - catalogued)
366 f
"{missing}: in REGISTRY but not on disk" for missing
in sorted(catalogued - on_disk)
371def cross_check() -> tuple[list[str], list[str]]:
372 """Cross-check the registry against the tree.
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).
380 errors: list[str] = []
381 warnings: list[str] = []
383 catalogued = _catalogued_top_dirs()
384 on_disk = _third_party_dirs()
385 errors.extend(_directory_drift(on_disk, catalogued))
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")
393 if not comp_path.exists():
394 errors.append(f
"{comp.key}: recorded path '{comp.path}' does not exist")
396 _check_version(comp, errors)
397 _check_license_file(comp, errors, warnings)
399 _check_scan_not_vacuous(errors)
400 return errors, warnings
403def _check_scan_not_vacuous(errors: list[str]) ->
None:
404 """Append an error if the vendored enumeration collapsed below its floor.
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
412 errors: Error list to append to, in place.
415 for comp
in hashed_components():
417 total += tree_digest(comp)[1]
418 except VacuousScanError
as exc:
419 errors.append(f
"{comp.key}: {exc}")
420 if total
and total < TOTAL_FILE_FLOOR:
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."
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:
432 probed = probe_version(comp)
434 errors.append(f
"{comp.key}: version probe found no version in '{comp.probe_file}'")
435 elif probed != comp.expected_version:
437 f
"{comp.key}: version drift -- source says {probed}, "
438 f
"registry says {comp.expected_version}"
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)")
448 if not (REPO_ROOT / comp.license_file).is_file():
449 errors.append(f
"{comp.key}: declared LICENSE '{comp.license_file}' is missing")
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}}]
463def _properties_block(comp: Component, file_count: int) -> list[dict]:
464 """Build the CycloneDX ``properties`` array for a component.
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.
472 comp: The registry component being rendered.
473 file_count: Tracked files that went into `comp`'s digest.
476 The CycloneDX property objects, in stable order.
478 props: list[dict] = [
479 {
"name":
"ra8:provenance",
"value": comp.provenance},
480 {
"name":
"ra8:path",
"value": comp.path},
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})
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}]
524 entry[
"externalReferences"] = [{
"type":
"vcs",
"url": comp.url}]
525 entry[
"properties"] = _properties_block(comp, count)
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)}"
535def build_bom() -> dict:
536 """Assemble the full CycloneDX 1.5 BOM document as an ordered dict."""
538 "bomFormat": BOM_FORMAT,
539 "specVersion": CYCLONEDX_SPEC,
540 "serialNumber": _serial_number(),
541 "version": BOM_REVISION,
543 "tools": [{
"vendor": PROJECT_NAME,
"name": GENERATOR_NAME}],
545 "type":
"application",
546 "bom-ref": PROJECT_NAME,
547 "name": PROJECT_NAME,
548 "version":
"unversioned",
549 "description":
"Renesas RA8D2 (Cortex-M85) bare-metal firmware.",
553 "name":
"ra8:sbomNote",
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/."
563 "components": [component_entry(c)
for c
in REGISTRY],
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"
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)
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())
589 summary_stream = sys.stderr
if to_stdout
else sys.stdout
591 sys.stdout.write(text)
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)
600 print(f
" ERROR {err}", file=sys.stderr)
605def _committed_digests(text: str) -> dict[str, str]:
606 """Extract ``bom-ref -> SHA-256 content`` from a committed SBOM document.
609 text: The committed SBOM JSON.
612 One entry per component that publishes a SHA-256 hash; components
613 without one are absent from the mapping.
616 doc = json.loads(text)
617 except json.JSONDecodeError:
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",
"")
627def _integrity_errors(committed: str) -> list[str]:
628 """Name every component whose vendored tree no longer hashes to its record.
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.
638 committed: Text of the committed SBOM document.
641 Human-readable error strings, one per drifted component.
643 recorded = _committed_digests(committed)
644 errors: list[str] = []
645 for comp
in hashed_components():
646 was = recorded.get(comp.key)
650 now, count = tree_digest(comp)
651 except VacuousScanError:
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."
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():
669 f
"{GENERATOR_NAME}: {SBOM_REL_PATH} is missing; run gen_sbom.py",
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:
678 f
"{GENERATOR_NAME}: {SBOM_REL_PATH} is stale; run gen_sbom.py to regenerate",
681 errors = [*errors,
"committed SBOM does not match the registry"]
682 for warn
in warnings:
683 print(f
" WARN {warn}")
686 print(f
" ERROR {err}", file=sys.stderr)
688 hashed = len(hashed_components())
689 files = sum(tree_digest(comp)[1]
for comp
in hashed_components())
691 f
"{GENERATOR_NAME}: SBOM matches the tree ({len(REGISTRY)} components; "
692 f
"{hashed} SHA-256 digests re-derived over {files} vendored files)."
697def run_commits() -> int:
698 """Print one ``<key> <upstream-commit>`` line per commit-pinned component.
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.
708 pinned = [comp
for comp
in REGISTRY
if comp.upstream_commit
is not None]
710 print(f
"{comp.key} {comp.upstream_commit}")
712 print(f
"{GENERATOR_NAME}: no commit-pinned component in REGISTRY", file=sys.stderr)
717def _selftest_tree(root: Path) -> list[tuple[str, str]]:
718 """Materialise a small fixture tree under `root` and return its entries.
721 root: Directory to create the fixture under.
724 ``(mode, path)`` pairs in the shape `_git_ls_files` produces.
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")
731 (
"100644",
"vendor/src/a.c"),
732 (
"100644",
"vendor/src/b.c"),
733 (
"100644",
"vendor/LICENSE"),
737def _selftest_worktree_cases(root: Path) -> list[tuple[str, bool]]:
738 """Prove the SBOM census observes unstaged vendor-tree state both ways."""
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")
748 [trusted_git_executable(),
"init",
"-q",
"-b",
"main",
"."],
753 [trusted_git_executable(),
"add",
"-A"],
757 original_digest = digest_entries(repo, _git_ls_files(
"vendor", repo),
"vendor")
759 tracked.write_bytes(b
"int changed;\n")
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}
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,
773 "MUST FIRE: an untracked vendor file enters the SBOM census",
774 "vendor/untracked.c" in paths,
777 "MUST FIRE: a deleted tracked vendor file leaves the SBOM census",
778 "vendor/removed.c" not in paths,
781 "MUST NOT FIRE: an ignored vendor file stays outside the SBOM census",
782 "vendor/ignored.c" not in paths,
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.
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.
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.
802 One ``(label, passed)`` pair per assertion.
804 (root /
"vendor" /
"src" /
"c.c").write_bytes(b
"int a;\n")
807 digest_entries(root, [],
"vendor")
808 except VacuousScanError:
812 "MUST FIRE: an added file changes the digest",
813 digest_entries(root, [*entries, (
"100644",
"vendor/src/c.c")],
"vendor") != base,
816 "MUST FIRE: a removed file changes the digest",
817 digest_entries(root, entries[:-1],
"vendor") != base,
820 "MUST FIRE: a mode change changes the digest",
821 digest_entries(root, [(
"100755", entries[0][1]), *entries[1:]],
"vendor") != base,
823 (
"MUST FIRE: an empty enumeration raises rather than hashing nothing", vacuous),
827def _selftest_registry_cases() -> list[tuple[str, bool]]:
828 """Return registry/tree ownership assertions for every supported vendor shape."""
831 "MUST FIRE: the live registry publishes a digest for every vendored component",
832 len(hashed_components()) == len(REGISTRY) - 1,
835 "MUST NOT FIRE: matching entries across all vendor-root shapes stay quiet",
836 not _directory_drift(
838 "libs/third_party/platform",
839 "apps/shared_libs/third_party/app",
840 "tools/viewer/third_party/tool_only",
843 "libs/third_party/platform",
844 "apps/shared_libs/third_party/app",
845 "tools/viewer/third_party/tool_only",
850 "MUST FIRE: an uncatalogued app vendor is detected",
853 {
"libs/third_party/platform",
"apps/shared_libs/third_party/extra"},
854 {
"libs/third_party/platform"},
859 "MUST FIRE: a missing app vendor is detected",
862 {
"libs/third_party/platform"},
863 {
"libs/third_party/platform",
"apps/shared_libs/third_party/app"},
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"),
873 "MUST FIRE: a repository-wide tools vendor bucket is unsupported",
874 _vendor_root_for(Path(
"tools/third_party/decoder"))
is None,
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)``.
883 root: The fixture root from `_selftest_tree`.
884 entries: That fixture's entry list.
887 One ``(label, passed)`` pair per assertion, both directions covered.
889 base = digest_entries(root, entries,
"vendor")
890 cases: list[tuple[str, bool]] = [
892 "MUST NOT FIRE: an unchanged tree hashes identically",
893 digest_entries(root, entries,
"vendor") == base,
896 "MUST NOT FIRE: enumeration order does not change the digest",
897 digest_entries(root, list(reversed(entries)),
"vendor") == base,
901 (root /
"vendor" /
"src" /
"a.c").write_bytes(b
"int a;\n/* injected */\n")
904 "MUST FIRE: one mutated vendored byte changes the digest",
905 digest_entries(root, entries,
"vendor") != base,
908 (root /
"vendor" /
"src" /
"a.c").write_bytes(b
"int a;\n")
911 "MUST NOT FIRE: restoring the byte restores the digest",
912 digest_entries(root, entries,
"vendor") == base,
916 cases.extend(_selftest_shape_cases(root, entries, base))
918 cases.extend(_selftest_registry_cases())
922def _run_selftest_body() -> int:
923 """Prove the integrity digest fires on a mutation and stays quiet otherwise.
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).
931 ``EXIT_OK`` when every case holds, ``EXIT_VACUOUS`` otherwise.
933 with tempfile.TemporaryDirectory()
as 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}")
941 print(f
"{GENERATOR_NAME}: selftest FAILED ({len(failed)} case(s))", file=sys.stderr)
943 print(f
"{GENERATOR_NAME}: selftest passed ({len(cases)} cases, both directions).")
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()
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.")
959 help=
"fail if the committed SBOM is stale or the tree drifted",
964 help=
"prove the integrity digest detects a mutation, then exit",
970 help=
"print the SBOM to stdout instead of writing the file",
975 help=
"print `<key> <upstream-commit>` per commit-pinned component",
977 args = parser.parse_args(argv)
979 return run_selftest()
985 return run_write(args.to_stdout)
986 except VacuousScanError
as exc:
987 print(f
"{GENERATOR_NAME}: FATAL -- {exc}", file=sys.stderr)
991if __name__ ==
"__main__":
992 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.