Files
plezy/scripts/verify_runtime_inputs.py
T
edde746 e9a213807f ci(linux): check the runner's libraries reach the package metadata
The plane added three runtime libraries that bundle-libs.sh deliberately does not
bundle, so they have to be declared per distro by hand - and two hand-maintained
lists drifting apart is the failure this guard exists to prevent.

check_linux_package_deps.py parses the runner's CMake for every pkg-config module
it links, follows target_link_libraries to prove each one actually reaches the
binary, and requires a package name for it in every distro's depends list. It
fails closed on the shapes a naive parser gets wrong: a pkg_check_modules call
naming several modules, options preceding the module name, and version
constraints like mpv>=0.40 that would otherwise be read as a package nobody
ships.

The smoke job builds the three packages and reads the dependencies back out of
the artifacts, deriving what to expect from build-packages.py rather than
restating it - so a library is declared once and verified everywhere. That job is
off by default, which is exactly why it must not carry its own copy of the list.

The Linux native job names libwayland-dev and libegl-dev instead of riding
GTK's and epoxy's transitive dev dependencies, matching the CMake comment's own
rationale. In CI the host-dependency guard runs once: the named step covers the
staged bundle, and build-packages.py's internal run - which exists for by-hand
packaging - is skipped. The smoke job also drops patchelf, which nothing
invokes.
2026-08-10 08:48:14 +02:00

263 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Offline verification for reviewed Linux native and vendored binding inputs."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Any
HEX_256 = re.compile(r"^[0-9a-f]{64}$")
HEX_COMMIT = re.compile(r"^[0-9a-f]{40}$")
NATIVE_NAMES = {"ffmpeg", "shaderc", "libplacebo", "mpv", "simdutf"}
BINDING_ARTIFACTS = {
"pigeons/messages.dart",
"android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt",
"ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h",
"ios/wakelock_plus/Sources/wakelock_plus/messages.g.m",
}
def _load_json(path: Path, errors: list[str]) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
errors.append(f"{path}: cannot load JSON: {error}")
return {}
if not isinstance(value, dict):
errors.append(f"{path}: top-level value must be an object")
return {}
return value
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _require_text(value: Any, label: str, errors: list[str]) -> str:
if not isinstance(value, str) or not value.strip():
errors.append(f"{label}: must be non-empty text")
return ""
return value
def _locked_package(lock_text: str, name: str) -> tuple[str, str] | None:
pattern = re.compile(
rf"^ {re.escape(name)}:\n(?P<body>(?: .*\n| .*\n)+?)(?=^ [a-zA-Z0-9_]+:|\Z)",
re.MULTILINE,
)
match = pattern.search(lock_text)
if match is None:
return None
body = match.group("body")
version = re.search(r'^ version: "([^\"]+)"$', body, re.MULTILINE)
checksum = re.search(r'^ sha256: "?([0-9a-f]{64})"?$', body, re.MULTILINE)
if version is None or checksum is None:
return None
return version.group(1), checksum.group(1)
def _validate_native(root: Path, errors: list[str]) -> None:
manifest_path = root / "linux/packaging/native-inputs.json"
manifest = _load_json(manifest_path, errors)
if manifest.get("formatVersion") != 1:
errors.append(f"{manifest_path}: formatVersion must be 1")
inputs = manifest.get("inputs")
if not isinstance(inputs, dict) or set(inputs) != NATIVE_NAMES:
errors.append(f"{manifest_path}: inputs must be exactly {sorted(NATIVE_NAMES)}")
return
for name, value in inputs.items():
label = f"{manifest_path}: inputs.{name}"
if not isinstance(value, dict):
errors.append(f"{label}: must be an object")
continue
kind = value.get("kind")
version = _require_text(value.get("version"), f"{label}.version", errors)
url = _require_text(value.get("url"), f"{label}.url", errors)
_require_text(value.get("provenance"), f"{label}.provenance", errors)
if url and not url.startswith("https://"):
errors.append(f"{label}.url: production source must use HTTPS")
# A fallback source is optional, but it is a production source when it is
# used, so it answers to the same rule as the primary.
mirror = value.get("mirror")
if mirror is not None:
if not isinstance(mirror, str) or not mirror.startswith("https://"):
errors.append(f"{label}.mirror: production source must use HTTPS")
if version and url and name in {"ffmpeg", "mpv", "simdutf"} and version not in url:
errors.append(f"{label}.url: must identify declared version {version}")
if kind == "archive":
checksum = value.get("sha256")
if not isinstance(checksum, str) or HEX_256.fullmatch(checksum) is None:
errors.append(f"{label}.sha256: must be a lowercase full SHA-256")
elif kind == "git":
ref = value.get("ref")
commit = value.get("commit")
if not isinstance(ref, str) or ref != f"v{version}":
errors.append(f"{label}.ref: must be v{version}")
if not isinstance(commit, str) or HEX_COMMIT.fullmatch(commit) is None:
errors.append(f"{label}.commit: must be a lowercase full Git commit")
else:
errors.append(f"{label}.kind: must be archive or git")
cmake_path = root / "linux/CMakeLists.txt"
try:
cmake = cmake_path.read_text(encoding="utf-8")
except OSError as error:
errors.append(f"{cmake_path}: cannot read: {error}")
cmake = ""
simdutf = inputs.get("simdutf")
if isinstance(simdutf, dict):
simdutf_url = simdutf.get("url")
simdutf_sha256 = simdutf.get("sha256")
if isinstance(simdutf_url, str) and simdutf_url and f"URL {simdutf_url}" not in cmake:
errors.append(f"{cmake_path}: simdutf URL differs from native-inputs.json")
if (
isinstance(simdutf_sha256, str)
and HEX_256.fullmatch(simdutf_sha256) is not None
and f"URL_HASH SHA256={simdutf_sha256}" not in cmake
):
errors.append(f"{cmake_path}: simdutf SHA-256 differs from native-inputs.json")
builder_path = root / "linux/packaging/build-libmpv.sh"
try:
builder = builder_path.read_text(encoding="utf-8")
except OSError as error:
errors.append(f"{builder_path}: cannot read: {error}")
return
required_builder_contracts = (
"native-inputs.json",
'download_verified "$FFMPEG_URL" "$FFMPEG_SHA256"',
'download_verified "$MPV_URL" "$MPV_SHA256"',
'"$SHADERC_URL" "$SHADERC_REF" "$SHADERC_COMMIT"',
'"$LIBPLACEBO_URL" "$LIBPLACEBO_REF" "$LIBPLACEBO_COMMIT"',
'git submodule update --init --recursive',
)
for contract_text in required_builder_contracts:
if contract_text not in builder:
errors.append(f"{builder_path}: missing manifest-backed acquisition contract {contract_text!r}")
if re.search(r"curl[^\n]*\|[^\n]*tar", builder):
errors.append(f"{builder_path}: archive extraction must not consume a curl stream")
def _validate_wakelock(root: Path, errors: list[str]) -> None:
package = root / "packages/wakelock_plus"
provenance_path = package / "provenance.json"
provenance = _load_json(provenance_path, errors)
if provenance.get("formatVersion") != 1:
errors.append(f"{provenance_path}: formatVersion must be 1")
upstream = provenance.get("upstream")
if not isinstance(upstream, dict) or HEX_COMMIT.fullmatch(str(upstream.get("commit", ""))) is None:
errors.append(f"{provenance_path}: upstream.commit must be a full Git commit")
artifacts = provenance.get("artifacts")
if not isinstance(artifacts, dict) or set(artifacts) != BINDING_ARTIFACTS:
errors.append(f"{provenance_path}: artifacts must be exactly the schema and three host outputs")
else:
for relative, expected in artifacts.items():
path = package / relative
if not isinstance(expected, str) or HEX_256.fullmatch(expected) is None:
errors.append(f"{provenance_path}: invalid artifact SHA-256 for {relative}")
elif not path.is_file():
errors.append(f"{path}: required binding artifact is missing")
else:
actual = _sha256(path)
if actual != expected:
errors.append(f"{path}: SHA-256 drift (expected {expected}, got {actual})")
try:
pubspec = (package / "pubspec.yaml").read_text(encoding="utf-8")
lock = (package / "pubspec.lock").read_text(encoding="utf-8")
schema = (package / "pigeons/messages.dart").read_text(encoding="utf-8")
root_lock = (root / "pubspec.lock").read_text(encoding="utf-8")
except OSError as error:
errors.append(f"{package}: cannot read package provenance input: {error}")
return
generator = provenance.get("generator") if isinstance(provenance.get("generator"), dict) else {}
client = provenance.get("externalDartClient") if isinstance(provenance.get("externalDartClient"), dict) else {}
expected_pigeon = (str(generator.get("version", "")), str(generator.get("archiveSha256", "")))
expected_client = (str(client.get("version", "")), str(client.get("archiveSha256", "")))
if not re.search(rf"^ pigeon: {re.escape(expected_pigeon[0])}$", pubspec, re.MULTILINE):
errors.append(f"{package / 'pubspec.yaml'}: Pigeon must be pinned exactly to {expected_pigeon[0]}")
if not re.search(
rf"^ wakelock_plus_platform_interface: {re.escape(expected_client[0])}$", pubspec, re.MULTILINE
):
errors.append(
f"{package / 'pubspec.yaml'}: wakelock_plus_platform_interface must be pinned exactly to {expected_client[0]}"
)
if _locked_package(lock, "pigeon") != expected_pigeon:
errors.append(f"{package / 'pubspec.lock'}: Pigeon version/checksum differs from provenance.json")
if _locked_package(lock, "wakelock_plus_platform_interface") != expected_client:
errors.append(
f"{package / 'pubspec.lock'}: platform-interface version/checksum differs from provenance.json"
)
if _locked_package(root_lock, "wakelock_plus_platform_interface") != expected_client:
errors.append(
f"{root / 'pubspec.lock'}: runtime platform-interface version/checksum differs from provenance.json"
)
if "dartPackageName: 'wakelock_plus_platform_interface'" not in schema:
errors.append(f"{package / 'pigeons/messages.dart'}: external Dart package name is not explicit")
if re.search(r"\bdart(?:Test)?Out\s*:", schema):
errors.append(f"{package / 'pigeons/messages.dart'}: host-only schema must not generate Dart outputs")
for relative in BINDING_ARTIFACTS - {"pigeons/messages.dart"}:
if relative not in schema:
errors.append(f"{package / 'pigeons/messages.dart'}: missing owned output {relative}")
binding_sources = (
("Kotlin", package / "android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt"),
("Objective-C", package / "ios/wakelock_plus/Sources/wakelock_plus/messages.g.m"),
)
for generated_name, generated_path in binding_sources:
try:
generated = generated_path.read_text(encoding="utf-8")
except OSError as error:
errors.append(f"{generated_path}: cannot read generated binding: {error}")
continue
if "26.2.3" not in generated:
errors.append(f"{generated_name} binding was not generated by Pigeon 26.2.3")
for method in ("WakelockPlusApi.toggle", "WakelockPlusApi.isEnabled"):
if method not in generated:
errors.append(f"{generated_name} binding is missing channel suffix {method}")
for tag in ("129", "130"):
if tag not in generated:
errors.append(f"{generated_name} binding is missing codec tag {tag}")
def validate(root: Path) -> list[str]:
errors: list[str] = []
_validate_native(root, errors)
_validate_wakelock(root, errors)
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
arguments = parser.parse_args()
errors = validate(arguments.root.resolve())
if errors:
print("Runtime input provenance verification failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print("Runtime input provenance verified offline")
return 0
if __name__ == "__main__":
raise SystemExit(main())