280 lines
11 KiB
Python
280 lines
11 KiB
Python
#!/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)
|