#!/usr/bin/env python3 from __future__ import annotations import argparse from dataclasses import asdict, dataclass import gzip import hashlib import json from pathlib import Path, PurePosixPath import tarfile from typing import BinaryIO KERNEL_RELEASE = "6.1.31-matrix-axp313a1" SOURCE_COMMIT = "30ff3fd5cf45417622b447a12e7a947402ffe34d" SOURCE_ARCHIVE_NAME = "walnutpi-linux-6.1.31-30ff3fd5.tar.gz" SOURCE_ARCHIVE_SHA256 = "8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f" ARTIFACT_NAME = "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz" MAX_REGULAR_FILES = 4_096 MAX_UNPACKED_BYTES = 512 * 1024 * 1024 REQUIRED_BOOT_FILES = { "boot/Image-matrix-axp313a1", "boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb", "boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb", f"boot/System.map-{KERNEL_RELEASE}", f"boot/config-{KERNEL_RELEASE}", } class KernelArtifactError(ValueError): pass @dataclass(frozen=True) class KernelArtifactInfo: artifact: str artifact_bytes: int artifact_sha256: str kernel_release: str source_commit: str unpacked_file_bytes: int regular_file_count: int module_file_count: int def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _load_json(path: Path) -> dict: try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise KernelArtifactError(f"invalid JSON metadata: {path}") from exc if not isinstance(document, dict): raise KernelArtifactError(f"metadata must be an object: {path}") return document def _verify_outer_sum(directory: Path, expected_name: str) -> tuple[Path, str]: manifest = directory / "SHA256SUMS" if not manifest.is_file(): raise KernelArtifactError(f"SHA256SUMS is missing: {directory}") try: lines = [line for line in manifest.read_text(encoding="ascii").splitlines() if line] except (OSError, UnicodeError) as exc: raise KernelArtifactError(f"SHA256SUMS is unreadable: {directory}") from exc if len(lines) != 1 or " " not in lines[0]: raise KernelArtifactError(f"SHA256SUMS must contain exactly one binary: {directory}") expected_digest, name = lines[0].split(" ", 1) if name != expected_name or len(expected_digest) != 64: raise KernelArtifactError(f"SHA256SUMS entry is wrong: {directory}") path = directory / name if not path.is_file(): raise KernelArtifactError(f"dependency binary is missing: {path}") actual_digest = sha256_file(path) if actual_digest != expected_digest: raise KernelArtifactError(f"dependency SHA-256 mismatch: {path}") return path, actual_digest def _normalized_member_name(raw: str) -> str: while raw.startswith("./"): raw = raw[2:] pure = PurePosixPath(raw) if not raw or raw == "." or pure.is_absolute() or ".." in pure.parts or "\\" in raw: if raw == ".": return "" raise KernelArtifactError(f"unsafe archive path: {raw!r}") return pure.as_posix() def _member_bytes(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes: handle: BinaryIO | None = archive.extractfile(member) if handle is None: raise KernelArtifactError(f"archive member cannot be read: {member.name}") return handle.read() def _parse_internal_sums(body: bytes) -> dict[str, str]: try: lines = body.decode("ascii").splitlines() except UnicodeError as exc: raise KernelArtifactError("internal SHA256SUMS is not ASCII") from exc result: dict[str, str] = {} for line in lines: if not line or " " not in line: raise KernelArtifactError("internal SHA256SUMS contains an invalid line") digest, name = line.split(" ", 1) normalized = _normalized_member_name(name) if len(digest) != 64 or normalized in result: raise KernelArtifactError("internal SHA256SUMS contains an invalid entry") result[normalized] = digest return result def verify_kernel_dependency(directory: Path) -> KernelArtifactInfo: directory = Path(directory).resolve() artifact, artifact_digest = _verify_outer_sum(directory, ARTIFACT_NAME) metadata = _load_json(directory / "METADATA.json") expected_keys = { "schema_version", "architecture", "kernel_release", "source_commit", "source_archive_sha256", "artifact", "artifact_bytes", "artifact_sha256", "unpacked_file_bytes", "regular_file_count", "module_file_count", "provenance", } if set(metadata) != expected_keys: raise KernelArtifactError("kernel metadata fields are not exact") fixed = { "schema_version": 1, "architecture": "aarch64", "kernel_release": KERNEL_RELEASE, "source_commit": SOURCE_COMMIT, "source_archive_sha256": SOURCE_ARCHIVE_SHA256, "artifact": ARTIFACT_NAME, } if any(metadata.get(key) != value for key, value in fixed.items()): raise KernelArtifactError("kernel metadata identity is wrong") if metadata["artifact_bytes"] != artifact.stat().st_size: raise KernelArtifactError("kernel artifact size metadata is wrong") if metadata["artifact_sha256"] != artifact_digest: raise KernelArtifactError("kernel artifact digest metadata is wrong") for key in ("unpacked_file_bytes", "regular_file_count", "module_file_count"): if type(metadata[key]) is not int or metadata[key] <= 0: raise KernelArtifactError(f"kernel metadata {key} must be a positive integer") if not isinstance(metadata["provenance"], str) or not metadata["provenance"].strip(): raise KernelArtifactError("kernel metadata provenance is missing") with artifact.open("rb") as raw: header = raw.read(10) if len(header) != 10 or header[:2] != b"\x1f\x8b" or header[4:8] != b"\0\0\0\0": raise KernelArtifactError("kernel artifact gzip header is not deterministic") try: archive = tarfile.open(artifact, mode="r:gz") except (tarfile.TarError, OSError, gzip.BadGzipFile) as exc: raise KernelArtifactError("kernel artifact is not a readable tar.gz") from exc with archive: regular: dict[str, tarfile.TarInfo] = {} seen: set[str] = set() unpacked_bytes = 0 for member in archive.getmembers(): name = _normalized_member_name(member.name) if not name: if not member.isdir(): raise KernelArtifactError("archive root must be a directory") continue if name in seen: raise KernelArtifactError(f"duplicate archive path: {name}") seen.add(name) if not (member.isfile() or member.isdir()): raise KernelArtifactError(f"archive contains a link or special file: {name}") if member.uid != 0 or member.gid != 0 or int(member.mtime) != 0: raise KernelArtifactError(f"archive ownership or timestamp is not normalized: {name}") if member.isfile(): regular[name] = member unpacked_bytes += member.size if len(regular) > MAX_REGULAR_FILES or unpacked_bytes > MAX_UNPACKED_BYTES: raise KernelArtifactError("kernel artifact exceeds safety limits") required = REQUIRED_BOOT_FILES | {"KERNEL_RELEASE", "SOURCE_COMMIT", "SHA256SUMS"} missing = required - regular.keys() if missing: raise KernelArtifactError(f"kernel artifact is incomplete: {sorted(missing)}") module_prefix = f"lib/modules/{KERNEL_RELEASE}/" unexpected = { name for name in regular if name not in required and not name.startswith(module_prefix) } if unexpected: raise KernelArtifactError(f"kernel artifact contains an unowned file: {sorted(unexpected)}") forbidden = { f"lib/modules/{KERNEL_RELEASE}/build", f"lib/modules/{KERNEL_RELEASE}/source", } if forbidden & seen: raise KernelArtifactError("kernel artifact contains build/source module links") if _member_bytes(archive, regular["KERNEL_RELEASE"]) != f"{KERNEL_RELEASE}\n".encode(): raise KernelArtifactError("kernel release marker is wrong") if _member_bytes(archive, regular["SOURCE_COMMIT"]) != f"{SOURCE_COMMIT}\n".encode(): raise KernelArtifactError("kernel source commit marker is wrong") image = _member_bytes(archive, regular["boot/Image-matrix-axp313a1"]) if f"Linux version {KERNEL_RELEASE}".encode() not in image: raise KernelArtifactError("kernel Image does not contain the expected release") internal = _parse_internal_sums(_member_bytes(archive, regular["SHA256SUMS"])) verified_files = set(regular) - {"SHA256SUMS"} if set(internal) != verified_files: raise KernelArtifactError("internal SHA256SUMS does not cover exactly every payload file") for name, digest in internal.items(): actual = hashlib.sha256(_member_bytes(archive, regular[name])).hexdigest() if actual != digest: raise KernelArtifactError(f"internal SHA-256 mismatch: {name}") module_count = sum(1 for name in regular if name.startswith(module_prefix)) measurements = { "unpacked_file_bytes": unpacked_bytes, "regular_file_count": len(regular), "module_file_count": module_count, } if any(metadata[key] != value for key, value in measurements.items()): raise KernelArtifactError("kernel artifact measurements do not match metadata") return KernelArtifactInfo( artifact=ARTIFACT_NAME, artifact_bytes=artifact.stat().st_size, artifact_sha256=artifact_digest, kernel_release=KERNEL_RELEASE, source_commit=SOURCE_COMMIT, **measurements, ) def verify_source_dependency(directory: Path) -> dict[str, object]: directory = Path(directory).resolve() archive, digest = _verify_outer_sum(directory, SOURCE_ARCHIVE_NAME) metadata = _load_json(directory / "METADATA.json") expected = { "schema_version": 1, "source": "WalnutPi Linux 6.1.31", "source_commit": SOURCE_COMMIT, "archive": SOURCE_ARCHIVE_NAME, "archive_bytes": archive.stat().st_size, "archive_sha256": SOURCE_ARCHIVE_SHA256, "purpose": "Offline reproducible build input for 6.1.31-matrix-axp313a1", } if metadata != expected or digest != SOURCE_ARCHIVE_SHA256: raise KernelArtifactError("kernel source dependency metadata is wrong") return expected def main() -> int: parser = argparse.ArgumentParser(description="Verify offline AXP313A kernel dependencies") parser.add_argument("--kernel", type=Path, required=True) parser.add_argument("--source", type=Path) args = parser.parse_args() result: dict[str, object] = {"kernel": asdict(verify_kernel_dependency(args.kernel))} if args.source: result["source"] = verify_source_dependency(args.source) print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except KernelArtifactError as exc: print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) raise SystemExit(1)