Pin external actions, images, toolchains, native archives, and tvOS engine artifacts; enforce fail-closed CI checks and keep website privacy disclosures aligned with shipped behavior.
257 lines
11 KiB
Python
Executable File
257 lines
11 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")
|
|
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())
|