4"""Gate: every vendored SOUP file is the file its upstream project published.
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.
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.
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.
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.
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
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.
56from __future__
import annotations
63from pathlib
import Path
64from urllib.request
import urlopen
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"))
70from git_environment
import isolated_git_environment, trusted_git_executable
71from sbom_registry
import (
77from soup_manifest
import (
108MIN_UPSTREAM_VERIFIED = 8900
114class VacuousScanError(Exception):
115 """Raised when the enumeration collapsed and no honest verdict is possible."""
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)
123def blob_id(data: bytes) -> str:
124 """Return the git blob SHA-1 of `data`.
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.
133 data: The file's exact bytes.
136 Lower-case hex SHA-1.
138 return hashlib.sha1(b
"blob %d\0" % len(data) + data).hexdigest()
146def _entry_errors(comp: Component, entry: Entry, ours: tuple[str, str]) -> list[str]:
147 """Return every way `entry` disagrees with what our index holds.
150 comp: The component being checked.
151 entry: The manifest record for one file.
152 ours: ``(mode, blob)`` git records for that file.
155 Human-readable error strings; empty when the file is as declared.
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
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/."
173 if entry.rel_path
in declared_patch
or entry.rel_path
in declared_local:
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."
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:
184 f
"{where}: declared as patched while the component records modified=False"
186 if blob != entry.local_blob:
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."
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:
196 f
"{where}: local file changed. Ours hashes to {blob}, manifest {entry.local_blob}"
201def _component_errors(comp: Component, root: Path) -> tuple[list[str], int, int]:
202 """Verify one component against its committed manifest.
205 comp: The component to verify.
206 root: Repository root to verify inside.
209 ``(errors, entry count, upstream-verified count)``.
212 VacuousScanError: When the manifest is missing, unparseable, or empty.
214 path = root / manifest_path(comp.key)
215 if not path.is_file():
217 f
"{comp.key}: no upstream manifest at {manifest_path(comp.key)}. "
218 "Run check_soup_upstream.py --refresh (needs the network)."
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)
226 errors: list[str] = []
227 recorded = manifest.by_path()
228 ours = git_ls_files(comp.path, comp.nested_paths, root)
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))
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))
239 for rel_path
in sorted(set(ours) & set(recorded)):
240 errors.extend(_entry_errors(comp, recorded[rel_path], ours[rel_path]))
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")),
249 if declared
and recorded_pin != declared:
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."
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()
261 comps: tuple[Component, ...] |
None =
None,
262 root: Path = REPO_ROOT,
263 floors: tuple[int, int, int] = (MIN_COMPONENTS, MIN_ENTRIES, MIN_UPSTREAM_VERIFIED),
265 """Verify every vendored component against its committed manifest, offline.
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.
278 comps = vendored_components()
if comps
is None else comps
279 errors: list[str] = []
280 entries = verified = 0
283 comp_errors, n_entries, n_verified = _component_errors(comp, root)
284 errors.extend(comp_errors)
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)
292 known = {manifest_path(c.key)
for c
in comps}
295 for p
in (root / manifest_path(
"x")).parent.rglob(
"*" + manifest_path(
"x").suffix)
296 if p.relative_to(root)
not in known
298 errors.extend(f
"{path}: manifest with no registry component" for path
in stray)
302 print(f
" ERROR {err}", file=sys.stderr)
304 f
"check_soup_upstream: {len(errors)} provenance failure(s) across "
305 f
"{len(comps)} vendored components.",
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)."
318 components: int, entries: int, verified: int, floors: tuple[int, int, int]
320 """Raise when the scan covered implausibly little to be believed.
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)``.
329 VacuousScanError: When any floor is breached.
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:
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."
344 raise VacuousScanError(message)
352def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
353 """Run a git command, returning the completed process."""
354 return subprocess.run(
355 [trusted_git_executable(), *args],
360 timeout=GIT_TIMEOUT_S,
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.
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.
372 comp: Component whose upstream to fetch.
373 cache: Directory to hold the bare mirrors.
376 ``(resolved commit, {upstream path: (mode, blob)})``.
379 VacuousScanError: When the fetch fails or the ref resolves to nothing.
381 ref = comp.upstream_ref
or comp.upstream_commit
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:
393 f
"{comp.key}: fetching {ref} from {comp.upstream_repo or comp.url} failed: "
394 f
"{proc.stderr.strip()[:300]}"
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)
405 message = f
"{comp.key}: upstream {ref} listed zero files"
406 raise VacuousScanError(message)
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)
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.
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.
424 comp: Component whose archive to fetch.
425 cache: Directory to hold the downloaded artifact.
428 ``(archive sha256, {member path: (mode, blob)})``.
431 VacuousScanError: On a download failure or a digest mismatch.
433 cache.mkdir(parents=
True, exist_ok=
True)
434 local = cache / Path(comp.upstream_archive_url
or "").name
435 if not local.is_file():
438 comp.upstream_archive_url, timeout=FETCH_TIMEOUT_S
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:
448 f
"{comp.key}: {comp.upstream_archive_url} hashes to {got}, registry pins "
449 f
"{comp.upstream_archive_sha256}. The release artifact was replaced."
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():
459 if prefix
and not name.startswith(prefix):
461 tree[name[len(prefix) :]] = (
"100644", blob_id(archive.read(info)))
463 message = f
"{comp.key}: archive contained no member under '{prefix}'"
464 raise VacuousScanError(message)
469 comp: Component, rel_path: str, ours: tuple[str, str], tree: dict[str, tuple[str, str]]
471 """Classify one vendored file against the upstream listing.
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
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)}``.
489 The manifest record for this file.
492 VacuousScanError: When the file deviates from upstream and the registry
493 has not declared how, or when a declaration is stale.
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]}"
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."
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)
512 return Entry(KIND_MOVED, mode, rel_path, upstream_blob=blob, upstream_path=elsewhere[0])
514 f
"upstream has it as {tree[rel_path][0]} {tree[rel_path][1]}"
516 else "upstream has no file at that path and no file with those bytes"
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'."
523 raise VacuousScanError(message)
527 comp: Component, rel_path: str, ours: tuple[str, str], tree: dict[str, tuple[str, str]]
529 """Build the `KIND_PATCH` record for a declared patch, or reject the declaration.
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)}``.
538 The `KIND_PATCH` record.
541 VacuousScanError: When upstream has no such file, or when our copy is
542 byte-identical to upstream and the declaration is therefore stale.
545 upstream = tree.get(rel_path)
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."
551 raise VacuousScanError(message)
552 if upstream[1] == blob:
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."
559 raise VacuousScanError(message)
560 return Entry(KIND_PATCH, mode, rel_path, upstream_blob=upstream[1], local_blob=blob)
563def refresh_component(comp: Component, cache: Path) -> tuple[str, dict[str, str]]:
564 """Fetch a component's upstream and build its manifest records.
567 comp: The component to refresh.
568 cache: Directory for upstream mirrors and archives.
571 ``(manifest text, header)``; the header carries the per-kind counts.
574 VacuousScanError: On any fetch failure or undeclared deviation.
576 if comp.upstream_transport == UPSTREAM_ARCHIVE:
577 pin, tree = fetch_archive_tree(comp, cache)
579 "upstream-url": comp.url,
580 "transport": UPSTREAM_ARCHIVE,
581 "archive-url": comp.upstream_archive_url
or "",
582 "archive-sha256": pin,
585 pin, tree = fetch_git_tree(comp, cache)
587 "upstream-url": comp.upstream_repo
or comp.url,
589 "ref": comp.upstream_ref
or comp.upstream_commit
or "",
592 ours = git_ls_files(comp.path, comp.nested_paths)
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
606def run_refresh(*, write: bool, only: str |
None) -> int:
607 """Fetch every component's upstream and rewrite (or verify) its manifest.
610 write: True to write the manifests; False to compare and report only.
611 only: Restrict to one registry key, or None for all.
616 cache = REPO_ROOT /
"build" /
"soup-upstream"
617 comps = [c
for c
in vendored_components()
if only
is None or c.key == only]
619 print(f
"check_soup_upstream: no vendored component named '{only}'", file=sys.stderr)
621 failures: list[str] = []
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)
629 out = REPO_ROOT / manifest_path(comp.key)
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")
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.",
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"
652 print(f
"check_soup_upstream: {len(failures)} component(s) failed", file=sys.stderr)
654 print(f
"check_soup_upstream: {len(comps)} components resolved against upstream.")
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")
665 help=
"NETWORK: refetch upstream and fail if the committed manifests disagree",
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)
671 from soup_selftest
import run_selftest
673 return run_selftest()
674 if args.refresh
or args.verify_upstream:
675 return run_refresh(write=args.refresh, only=args.component)
679if __name__ ==
"__main__":
680 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.