ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_soup_upstream.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"""Gate: every vendored SOUP file is the file its upstream project published.
5
6``docs/SOUP/*.md``, ``THIRD_PARTY_LICENSES.md`` and the SBOM's
7``commit-pinned-sha256`` provenance class all assert the same strong claim --
8the vendored tree is byte-identical to a named upstream revision -- and until
9this gate nothing checked it. ``gen_sbom.py``'s digest (#538) proves only that
10the tree has not changed since the SBOM was last regenerated: a tree that was
11already wrong at vendor-in hashes faithfully and reports clean forever.
12
13How upstream identity is established
14------------------------------------
15Each component pins an upstream revision in ``scripts/gen/sbom_registry.py``.
16``--refresh`` fetches that revision **from the upstream project** and writes
17what upstream publishes for every file we vendor into
18``docs/sbom/upstream/<key>.manifest``. For a git upstream that is
19``git ls-tree -r``: a ``--filter=blob:none`` fetch brings the tree objects
20without any file content, and the blob SHA-1s in them are already content
21hashes. For miniz -- whose single-file amalgamation exists only as a release
22zip, never in the upstream git tree -- it is the pinned, SHA-256-verified
23release artifact instead.
24
25``--check`` then runs offline, comparing the blob ids git records for our
26tracked files against those manifests. Two independently produced hashes, so
27no constant is ever compared with itself; and the gate needs no network, so a
28push does not depend on twenty upstream hosts being reachable. ``--refresh``
29is re-run by the weekly ``soup-upstream-refresh`` gate to catch what the
30offline half structurally cannot: an upstream tag that moved, a rewritten
31history, or a project that vanished.
32
33Deliberate deviations are DECLARED, never inferred
34--------------------------------------------------
35Vendored SOUP is sometimes patched on purpose (libwebp's arena allocator,
36TinyXML-2's #151 whitespace fix, stb's bounds hardening) and sometimes carries
37files upstream has none of (mbedtls' build-generated config-check headers).
38Those files must be listed in the registry's ``patched_files`` /
39``local_files`` with a justification; ``--refresh`` REFUSES to write a
40``patch``/``local`` record for a file the registry has not declared. That
41refusal is what keeps the manifest honest: without it, a corrupted file would
42be silently re-recorded as "modified on purpose" on the next refresh, and the
43gate would go green having absorbed the corruption.
44
45Run::
46
47 check_soup_upstream.py # offline: tree vs committed manifests
48 check_soup_upstream.py --refresh # NETWORK: fetch upstream, rewrite them
49 check_soup_upstream.py --verify-upstream # NETWORK: refetch, compare, write nothing
50 check_soup_upstream.py --selftest # prove it fires and stays quiet
51
52Exit 0 clean, 1 on a provenance failure, 2 when the scan itself collapsed
53(a missing manifest set, a floor breach) and no honest verdict is possible.
54"""
55
56from __future__ import annotations
57
58import argparse
59import hashlib
60import subprocess
61import sys
62import zipfile
63from pathlib import Path
64from urllib.request import urlopen
65
66sys.path.insert(0, str(Path(__file__).resolve().parent))
67sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "gen"))
68sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
69
70from git_environment import isolated_git_environment, trusted_git_executable
71from sbom_registry import (
72 PROV_NOT_VENDORED,
73 REGISTRY,
74 UPSTREAM_ARCHIVE,
75 Component,
76)
77from soup_manifest import (
78 KIND_LOCAL,
79 KIND_MOVED,
80 KIND_OK,
81 KIND_PATCH,
82 REPO_ROOT,
83 Entry,
84 ManifestError,
85 format_manifest,
86 git_ls_files,
87 manifest_path,
88 parse_manifest,
89)
90
91EXIT_OK = 0
92EXIT_FAIL = 1
93EXIT_VACUOUS = 2
94
95# Vacuity floors. A manifest set that silently covered zero components would
96# pronounce all of them clean, and a manifest made only of `patch`/`local` rows
97# would prove nothing about upstream at all -- it would record our opinion of
98# our own tree, which is exactly the defect this gate exists to remove. All
99# three are MEASURED against the live tree. Re-measured 2026-08-22 after the
100# unused XML vendor was removed: 19 components, 9150 vendored files, 9133 of
101# them byte-identical to their pinned upstream revision. The file floors keep enough
102# slack that ordinary re-vendoring does not trip them while sitting far above
103# any plausible collapse; MIN_COMPONENTS has no component slack, so adding or
104# deleting a vendored component is meant to fail here until whoever
105# does it re-measures these three numbers deliberately.
106MIN_COMPONENTS = 19
107MIN_ENTRIES = 9000
108MIN_UPSTREAM_VERIFIED = 8900
109
110GIT_TIMEOUT_S = 900
111FETCH_TIMEOUT_S = 300
112
113
114class VacuousScanError(Exception):
115 """Raised when the enumeration collapsed and no honest verdict is possible."""
116
117
118def vendored_components() -> tuple[Component, ...]:
119 """Return every registry entry that is actually vendored in this tree."""
120 return tuple(comp for comp in REGISTRY if comp.provenance != PROV_NOT_VENDORED)
121
122
123def blob_id(data: bytes) -> str:
124 """Return the git blob SHA-1 of `data`.
125
126 Computed here rather than shelled out to ``git hash-object`` on purpose:
127 that command applies the repository's ``.gitattributes``, which would
128 line-ending-normalise an archive member and yield a hash that matches
129 nothing. A vendored blob is raw bytes (``libs/third_party/** -text``), so
130 the raw framing is the correct one.
131
132 Args:
133 data: The file's exact bytes.
134
135 Returns:
136 Lower-case hex SHA-1.
137 """
138 return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() # noqa: S324 -- Git object IDs require SHA-1
139
140
141# --------------------------------------------------------------------------- #
142# Offline verification -- the per-push gate. #
143# --------------------------------------------------------------------------- #
144
145
146def _entry_errors(comp: Component, entry: Entry, ours: tuple[str, str]) -> list[str]:
147 """Return every way `entry` disagrees with what our index holds.
148
149 Args:
150 comp: The component being checked.
151 entry: The manifest record for one file.
152 ours: ``(mode, blob)`` git records for that file.
153
154 Returns:
155 Human-readable error strings; empty when the file is as declared.
156 """
157 mode, blob = ours
158 where = f"{comp.key}: {comp.path}/{entry.rel_path}"
159 errors: list[str] = []
160 if mode != entry.mode:
161 errors.append(f"{where}: mode {mode}, upstream manifest records {entry.mode}")
162 declared_patch = dict(comp.patched_files)
163 declared_local = dict(comp.local_files)
164 if entry.kind in (KIND_OK, KIND_MOVED):
165 if blob != entry.upstream_blob:
166 source = entry.upstream_path or entry.rel_path
167 errors.append(
168 f"{where}: NOT the upstream file. Ours hashes to {blob}; "
169 f"{comp.upstream_ref or comp.upstream_commit}:{source} is {entry.upstream_blob}. "
170 "Either restore the upstream bytes, or declare the change in "
171 "sbom_registry.patched_files and record it in docs/SOUP/."
172 )
173 if entry.rel_path in declared_patch or entry.rel_path in declared_local:
174 errors.append(
175 f"{where}: the registry declares a deviation for this file, but it is recorded "
176 "as byte-identical to upstream. The declaration is stale -- drop it, here and "
177 "in the component's docs/SOUP/ 'Deviations / patches' section."
178 )
179 elif entry.kind == KIND_PATCH:
180 if entry.rel_path not in declared_patch:
181 errors.append(f"{where}: manifest says 'patch' but the registry declares no patch")
182 elif not comp.modified:
183 errors.append(
184 f"{where}: declared as patched while the component records modified=False"
185 )
186 if blob != entry.local_blob:
187 errors.append(
188 f"{where}: patched file changed. Ours hashes to {blob}; the reviewed "
189 f"patch is {entry.local_blob}. A patched file is still pinned -- an "
190 "edit on top of it needs a refresh and a docs/SOUP/ update."
191 )
192 elif entry.rel_path not in declared_local:
193 errors.append(f"{where}: manifest says 'local' but the registry declares no such file")
194 elif blob != entry.local_blob:
195 errors.append(
196 f"{where}: local file changed. Ours hashes to {blob}, manifest {entry.local_blob}"
197 )
198 return errors
199
200
201def _component_errors(comp: Component, root: Path) -> tuple[list[str], int, int]:
202 """Verify one component against its committed manifest.
203
204 Args:
205 comp: The component to verify.
206 root: Repository root to verify inside.
207
208 Returns:
209 ``(errors, entry count, upstream-verified count)``.
210
211 Raises:
212 VacuousScanError: When the manifest is missing, unparseable, or empty.
213 """
214 path = root / manifest_path(comp.key)
215 if not path.is_file():
216 message = (
217 f"{comp.key}: no upstream manifest at {manifest_path(comp.key)}. "
218 "Run check_soup_upstream.py --refresh (needs the network)."
219 )
220 raise VacuousScanError(message)
221 manifest = parse_manifest(comp.key, path.read_text(encoding="utf-8"), manifest_path(comp.key))
222 if not manifest.entries:
223 message = f"{comp.key}: manifest records zero files"
224 raise VacuousScanError(message)
225
226 errors: list[str] = []
227 recorded = manifest.by_path()
228 ours = git_ls_files(comp.path, comp.nested_paths, root)
229 errors.extend(
230 f"{comp.key}: {comp.path}/{rel_path} is in the upstream manifest but not in the "
231 "tree. The vendored subset lost a file."
232 for rel_path in sorted(set(recorded) - set(ours))
233 )
234 errors.extend(
235 f"{comp.key}: {comp.path}/{rel_path} is tracked but absent from the upstream "
236 "manifest. A file appeared inside a vendored SOUP tree."
237 for rel_path in sorted(set(ours) - set(recorded))
238 )
239 for rel_path in sorted(set(ours) & set(recorded)):
240 errors.extend(_entry_errors(comp, recorded[rel_path], ours[rel_path]))
241
242 # The revision the SBOM PUBLISHES and the revision the manifest was
243 # VERIFIED against must be the same one, or the SBOM advertises a pin
244 # nothing checked -- which is the shape of the whole finding.
245 for label, declared, recorded_pin in (
246 ("commit", comp.upstream_commit, manifest.header.get("commit")),
247 ("archive SHA-256", comp.upstream_archive_sha256, manifest.header.get("archive-sha256")),
248 ):
249 if declared and recorded_pin != declared:
250 errors.append(
251 f"{comp.key}: registry pins {label} {declared} but the manifest was generated "
252 f"from {recorded_pin}. The published pin and the verified pin must match."
253 )
254 if not (manifest.header.get("commit") or manifest.header.get("archive-sha256")):
255 message = f"{comp.key}: manifest records no upstream revision"
256 raise VacuousScanError(message)
257 return errors, len(manifest.entries), manifest.verified_count()
258
259
260def run_check(
261 comps: tuple[Component, ...] | None = None,
262 root: Path = REPO_ROOT,
263 floors: tuple[int, int, int] = (MIN_COMPONENTS, MIN_ENTRIES, MIN_UPSTREAM_VERIFIED),
264) -> int:
265 """Verify every vendored component against its committed manifest, offline.
266
267 Args:
268 comps: Components to verify; defaults to every vendored registry entry.
269 root: Repository root to verify inside.
270 floors: ``(components, entries, upstream-verified)`` vacuity floors.
271 Defaulted to the measured tree-wide values, so the CI path uses
272 exactly the constants above; the selftest supplies fixture-sized
273 ones and asserts the real constants separately.
274
275 Returns:
276 Process exit status.
277 """
278 comps = vendored_components() if comps is None else comps
279 errors: list[str] = []
280 entries = verified = 0
281 try:
282 for comp in comps:
283 comp_errors, n_entries, n_verified = _component_errors(comp, root)
284 errors.extend(comp_errors)
285 entries += n_entries
286 verified += n_verified
287 _check_floors(len(comps), entries, verified, floors)
288 except (VacuousScanError, ManifestError) as exc:
289 print(f"check_soup_upstream: FATAL -- {exc}", file=sys.stderr)
290 return EXIT_VACUOUS
291
292 known = {manifest_path(c.key) for c in comps}
293 stray = sorted(
294 p.relative_to(root)
295 for p in (root / manifest_path("x")).parent.rglob("*" + manifest_path("x").suffix)
296 if p.relative_to(root) not in known
297 )
298 errors.extend(f"{path}: manifest with no registry component" for path in stray)
299
300 if errors:
301 for err in errors:
302 print(f" ERROR {err}", file=sys.stderr)
303 print(
304 f"check_soup_upstream: {len(errors)} provenance failure(s) across "
305 f"{len(comps)} vendored components.",
306 file=sys.stderr,
307 )
308 return EXIT_FAIL
309 print(
310 f"check_soup_upstream: {len(comps)} vendored components, {entries} files, "
311 f"{verified} byte-identical to their pinned upstream revision "
312 f"({entries - verified} declared deviations)."
313 )
314 return EXIT_OK
315
316
317def _check_floors(
318 components: int, entries: int, verified: int, floors: tuple[int, int, int]
319) -> None:
320 """Raise when the scan covered implausibly little to be believed.
321
322 Args:
323 components: Components actually verified.
324 entries: Manifest records consumed.
325 verified: Records proven against an upstream-published hash.
326 floors: ``(min components, min entries, min upstream-verified)``.
327
328 Raises:
329 VacuousScanError: When any floor is breached.
330 """
331 min_components, min_entries, min_verified = floors
332 if components < min_components:
333 message = f"only {components} components covered, floor is {min_components}"
334 raise VacuousScanError(message)
335 if entries < min_entries:
336 message = f"only {entries} files covered, floor is {min_entries}"
337 raise VacuousScanError(message)
338 if verified < min_verified:
339 message = (
340 f"only {verified} files were proven against an upstream hash, floor is "
341 f"{min_verified}. A manifest of nothing but declared deviations "
342 "records our opinion of our own tree and proves no upstream identity."
343 )
344 raise VacuousScanError(message)
345
346
347# --------------------------------------------------------------------------- #
348# Upstream fetch -- the network half. #
349# --------------------------------------------------------------------------- #
350
351
352def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
353 """Run a git command, returning the completed process."""
354 return subprocess.run( # noqa: S603 # trusted: fixed git argv, no shell
355 [trusted_git_executable(), *args],
356 cwd=cwd,
357 capture_output=True,
358 text=True,
359 check=False,
360 timeout=GIT_TIMEOUT_S,
361 )
362
363
364def _fetch_git_tree(comp: Component, cache: Path) -> tuple[str, dict[str, tuple[str, str]]]:
365 """Fetch the pinned upstream revision and return its full file listing.
366
367 ``--filter=blob:none --depth 1`` brings the commit and its trees but no file
368 content: the blob SHA-1s recorded in the trees are already the content
369 hashes we need, so the whole verification costs tree metadata only.
370
371 Args:
372 comp: Component whose upstream to fetch.
373 cache: Directory to hold the bare mirrors.
374
375 Returns:
376 ``(resolved commit, {upstream path: (mode, blob)})``.
377
378 Raises:
379 VacuousScanError: When the fetch fails or the ref resolves to nothing.
380 """
381 ref = comp.upstream_ref or comp.upstream_commit
382 if not ref:
383 message = f"{comp.key}: no upstream_ref and no upstream_commit to fetch"
384 raise VacuousScanError(message)
385 mirror = cache / comp.key.replace("/", "__")
386 if not mirror.exists():
387 mirror.mkdir(parents=True)
388 _run_git(["init", "-q", "--bare", "."], mirror)
389 _run_git(["remote", "add", "origin", comp.upstream_repo or comp.url], mirror)
390 proc = _run_git(["fetch", "-q", "--filter=blob:none", "--depth", "1", "origin", ref], mirror)
391 if proc.returncode != 0:
392 message = (
393 f"{comp.key}: fetching {ref} from {comp.upstream_repo or comp.url} failed: "
394 f"{proc.stderr.strip()[:300]}"
395 )
396 raise VacuousScanError(message)
397 commit = _run_git(["rev-parse", "FETCH_HEAD^{commit}"], mirror).stdout.strip()
398 listing = _run_git(["ls-tree", "-r", commit], mirror).stdout
399 tree: dict[str, tuple[str, str]] = {}
400 for line in listing.splitlines():
401 meta, path = line.split("\t", 1)
402 mode, _kind, blob = meta.split()
403 tree[path] = (mode, blob)
404 if not tree:
405 message = f"{comp.key}: upstream {ref} listed zero files"
406 raise VacuousScanError(message)
407 return commit, tree
408
409
410def fetch_git_tree(comp: Component, cache: Path) -> tuple[str, dict[str, tuple[str, str]]]:
411 """Fetch through a nested bare repository isolated from hook routing."""
412 with isolated_git_environment():
413 return _fetch_git_tree(comp, cache)
414
415
416def fetch_archive_tree(comp: Component, cache: Path) -> tuple[str, dict[str, tuple[str, str]]]:
417 """Download the pinned release artifact and return its member listing.
418
419 The artifact is pinned by SHA-256, so this transport is exactly as strong
420 as the git one: the bytes are fixed by a hash recorded in the registry and
421 re-verified on every fetch.
422
423 Args:
424 comp: Component whose archive to fetch.
425 cache: Directory to hold the downloaded artifact.
426
427 Returns:
428 ``(archive sha256, {member path: (mode, blob)})``.
429
430 Raises:
431 VacuousScanError: On a download failure or a digest mismatch.
432 """
433 cache.mkdir(parents=True, exist_ok=True)
434 local = cache / Path(comp.upstream_archive_url or "").name
435 if not local.is_file():
436 try:
437 with urlopen( # noqa: S310 -- manifest URL is pinned and hash-verified
438 comp.upstream_archive_url, timeout=FETCH_TIMEOUT_S
439 ) as src:
440 local.write_bytes(src.read())
441 except OSError as exc:
442 message = f"{comp.key}: downloading {comp.upstream_archive_url} failed: {exc}"
443 raise VacuousScanError(message) from exc
444 data = local.read_bytes()
445 got = hashlib.sha256(data).hexdigest()
446 if got != comp.upstream_archive_sha256:
447 message = (
448 f"{comp.key}: {comp.upstream_archive_url} hashes to {got}, registry pins "
449 f"{comp.upstream_archive_sha256}. The release artifact was replaced."
450 )
451 raise VacuousScanError(message)
452 prefix = comp.upstream_archive_prefix
453 tree: dict[str, tuple[str, str]] = {}
454 with zipfile.ZipFile(local) as archive:
455 for info in archive.infolist():
456 if info.is_dir():
457 continue
458 name = info.filename
459 if prefix and not name.startswith(prefix):
460 continue
461 tree[name[len(prefix) :]] = ("100644", blob_id(archive.read(info)))
462 if not tree:
463 message = f"{comp.key}: archive contained no member under '{prefix}'"
464 raise VacuousScanError(message)
465 return got, tree
466
467
468def _resolve_entry(
469 comp: Component, rel_path: str, ours: tuple[str, str], tree: dict[str, tuple[str, str]]
470) -> Entry:
471 """Classify one vendored file against the upstream listing.
472
473 Declarations are consulted FIRST, and each is checked against upstream
474 rather than trusted. A declaration that has stopped describing the file --
475 a patch someone reverted, a "local" file upstream has since published --
476 is a claim nothing would otherwise notice, which is the failure mode this
477 whole gate exists to remove. Undeclared files then resolve to the same
478 relative path, or to a content-identical file elsewhere upstream (a
479 flattened vendor); anything else is refused rather than recorded as an
480 intentional patch.
481
482 Args:
483 comp: The component being refreshed.
484 rel_path: Component-relative path of the vendored file.
485 ours: ``(mode, blob)`` from our index.
486 tree: Upstream's ``{path: (mode, blob)}``.
487
488 Returns:
489 The manifest record for this file.
490
491 Raises:
492 VacuousScanError: When the file deviates from upstream and the registry
493 has not declared how, or when a declaration is stale.
494 """
495 mode, blob = ours
496 elsewhere = sorted(p for p, (m, b) in tree.items() if b == blob and m == mode)
497 if rel_path in dict(comp.patched_files):
498 return _resolve_patch(comp, rel_path, ours, tree)
499 if rel_path in dict(comp.local_files):
500 if rel_path in tree or elsewhere:
501 found = tree[rel_path][1] if rel_path in tree else f"as {elsewhere[0]}"
502 message = (
503 f"{comp.key}: '{rel_path}' is declared as having no upstream counterpart, but "
504 f"upstream publishes one at the pinned revision ({found}). Move it to "
505 "patched_files, or drop the declaration."
506 )
507 raise VacuousScanError(message)
508 return Entry(KIND_LOCAL, mode, rel_path, local_blob=blob)
509 if tree.get(rel_path) == (mode, blob):
510 return Entry(KIND_OK, mode, rel_path, upstream_blob=blob)
511 if elsewhere:
512 return Entry(KIND_MOVED, mode, rel_path, upstream_blob=blob, upstream_path=elsewhere[0])
513 detail = (
514 f"upstream has it as {tree[rel_path][0]} {tree[rel_path][1]}"
515 if rel_path in tree
516 else "upstream has no file at that path and no file with those bytes"
517 )
518 message = (
519 f"{comp.key}: '{rel_path}' does not match the pinned upstream revision and the "
520 f"registry declares no deviation for it ({detail}). Refusing to record it as an "
521 "intentional patch: that is how a corrupted file becomes 'modified on purpose'."
522 )
523 raise VacuousScanError(message)
524
525
526def _resolve_patch(
527 comp: Component, rel_path: str, ours: tuple[str, str], tree: dict[str, tuple[str, str]]
528) -> Entry:
529 """Build the `KIND_PATCH` record for a declared patch, or reject the declaration.
530
531 Args:
532 comp: The component being refreshed.
533 rel_path: Component-relative path of the declared patch.
534 ours: ``(mode, blob)`` from our index.
535 tree: Upstream's ``{path: (mode, blob)}``.
536
537 Returns:
538 The `KIND_PATCH` record.
539
540 Raises:
541 VacuousScanError: When upstream has no such file, or when our copy is
542 byte-identical to upstream and the declaration is therefore stale.
543 """
544 mode, blob = ours
545 upstream = tree.get(rel_path)
546 if upstream is None:
547 message = (
548 f"{comp.key}: '{rel_path}' is declared as a patch of upstream, but upstream has no "
549 "such file at the pinned revision. Declare it in local_files instead."
550 )
551 raise VacuousScanError(message)
552 if upstream[1] == blob:
553 message = (
554 f"{comp.key}: '{rel_path}' is declared as patched but is byte-identical to "
555 "upstream. The declaration is stale -- drop it from patched_files (and from the "
556 "component's docs/SOUP/ 'Deviations / patches' section) rather than leaving a "
557 "deviation recorded that does not exist."
558 )
559 raise VacuousScanError(message)
560 return Entry(KIND_PATCH, mode, rel_path, upstream_blob=upstream[1], local_blob=blob)
561
562
563def refresh_component(comp: Component, cache: Path) -> tuple[str, dict[str, str]]:
564 """Fetch a component's upstream and build its manifest records.
565
566 Args:
567 comp: The component to refresh.
568 cache: Directory for upstream mirrors and archives.
569
570 Returns:
571 ``(manifest text, header)``; the header carries the per-kind counts.
572
573 Raises:
574 VacuousScanError: On any fetch failure or undeclared deviation.
575 """
576 if comp.upstream_transport == UPSTREAM_ARCHIVE:
577 pin, tree = fetch_archive_tree(comp, cache)
578 header = {
579 "upstream-url": comp.url,
580 "transport": UPSTREAM_ARCHIVE,
581 "archive-url": comp.upstream_archive_url or "",
582 "archive-sha256": pin,
583 }
584 else:
585 pin, tree = fetch_git_tree(comp, cache)
586 header = {
587 "upstream-url": comp.upstream_repo or comp.url,
588 "transport": "git",
589 "ref": comp.upstream_ref or comp.upstream_commit or "",
590 "commit": pin,
591 }
592 ours = git_ls_files(comp.path, comp.nested_paths)
593 if not ours:
594 message = f"{comp.key}: '{comp.path}' enumerated zero tracked files"
595 raise VacuousScanError(message)
596 entries = [_resolve_entry(comp, rel, ours[rel], tree) for rel in sorted(ours)]
597 verified = sum(1 for e in entries if e.kind in (KIND_OK, KIND_MOVED))
598 header["upstream-files"] = str(len(tree))
599 header["vendored-files"] = str(len(entries))
600 header["upstream-verified"] = str(verified)
601 header["patched"] = str(sum(1 for e in entries if e.kind == KIND_PATCH))
602 header["local"] = str(sum(1 for e in entries if e.kind == KIND_LOCAL))
603 return format_manifest(comp.key, header, entries), header
604
605
606def run_refresh(*, write: bool, only: str | None) -> int:
607 """Fetch every component's upstream and rewrite (or verify) its manifest.
608
609 Args:
610 write: True to write the manifests; False to compare and report only.
611 only: Restrict to one registry key, or None for all.
612
613 Returns:
614 Process exit status.
615 """
616 cache = REPO_ROOT / "build" / "soup-upstream"
617 comps = [c for c in vendored_components() if only is None or c.key == only]
618 if not comps:
619 print(f"check_soup_upstream: no vendored component named '{only}'", file=sys.stderr)
620 return EXIT_VACUOUS
621 failures: list[str] = []
622 for comp in comps:
623 try:
624 text, header = refresh_component(comp, cache)
625 except (VacuousScanError, ManifestError) as exc:
626 failures.append(str(exc))
627 print(f" FAIL {comp.key}: {exc}", file=sys.stderr)
628 continue
629 out = REPO_ROOT / manifest_path(comp.key)
630 if write:
631 # Only --refresh touches the tree. --verify-upstream must not, not
632 # even to create a directory: a scheduled job that quietly adopted
633 # upstream's new bytes would launder the event it exists to report.
634 out.parent.mkdir(parents=True, exist_ok=True)
635 out.write_text(text, encoding="utf-8")
636 elif not out.is_file() or out.read_text(encoding="utf-8") != text:
637 failures.append(f"{comp.key}: committed manifest no longer describes upstream")
638 print(
639 f" FAIL {comp.key}: the committed manifest disagrees with upstream "
640 f"{header.get('commit') or header.get('archive-sha256')}. The pinned "
641 "revision moved, or the vendored tree changed without a refresh.",
642 file=sys.stderr,
643 )
644 continue
645 print(
646 f" {'wrote' if write else 'ok '} {comp.key:24s} "
647 f"{header['vendored-files']:>5s} files, "
648 f"{header['upstream-verified']:>5s} upstream-verified, "
649 f"{header['patched']} patched, {header['local']} local"
650 )
651 if failures:
652 print(f"check_soup_upstream: {len(failures)} component(s) failed", file=sys.stderr)
653 return EXIT_FAIL
654 print(f"check_soup_upstream: {len(comps)} components resolved against upstream.")
655 return EXIT_OK
656
657
658def main(argv: list[str]) -> int:
659 """Parse arguments and dispatch to the check / refresh / selftest action."""
660 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
661 parser.add_argument("--refresh", action="store_true", help="NETWORK: rewrite the manifests")
662 parser.add_argument(
663 "--verify-upstream",
664 action="store_true",
665 help="NETWORK: refetch upstream and fail if the committed manifests disagree",
666 )
667 parser.add_argument("--component", help="restrict --refresh/--verify-upstream to one key")
668 parser.add_argument("--selftest", action="store_true", help="prove the checker both ways")
669 args = parser.parse_args(argv)
670 if args.selftest:
671 from soup_selftest import run_selftest # noqa: PLC0415 # selftest-only import
672
673 return run_selftest()
674 if args.refresh or args.verify_upstream:
675 return run_refresh(write=args.refresh, only=args.component)
676 return run_check()
677
678
679if __name__ == "__main__":
680 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298