#!/usr/bin/env python3 from __future__ import annotations import argparse from datetime import datetime, timezone import gzip import hashlib import json from pathlib import Path import shutil import sys import tarfile SOURCE_ROOT = Path(__file__).resolve().parents[1] if str(SOURCE_ROOT) not in sys.path: sys.path.insert(0, str(SOURCE_ROOT)) from scripts.fat16_image import Fat16Image from scripts.image_config import PRODUCT_ID, create_config_file, read_config_file, validate_config from scripts.debian_closure import IMAGE_DEBIAN_ROOTS, verify_local_closure from scripts.kernel_artifact import KernelArtifactInfo, verify_kernel_dependency, verify_source_dependency from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget, candidate_sizes_from_dependency EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"} NATIVE_BUILD_OUTPUTS = { "app/display/native/libh618_hub75.so", "app/display/native/hub75_benchmark", "app/display/native/hub75_native_test", "app/display/native/hub75_safeoff", } IMAGE_FORMAT_VERSION = 3 BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ" def _allowed_source(path: Path, root: Path) -> bool: relative = path.relative_to(root) if any(part in EXCLUDED_PARTS for part in relative.parts): return False if relative.as_posix() in NATIVE_BUILD_OUTPUTS: return False return path.suffix not in {".pyc", ".pyo"} def _add_file(tar: tarfile.TarFile, path: Path, name: str) -> None: info = tar.gettarinfo(str(path), arcname=name) info.uid = info.gid = 0 info.uname = info.gname = "root" info.mtime = 0 info.mode = 0o755 if path.suffix == ".sh" else 0o644 with path.open("rb") as handle: tar.addfile(info, handle) def build_bundle( source: Path, wheelhouse: Path, debian: Path, output: Path, system_dependencies: Path | None = None, ) -> None: with output.open("wb") as raw: with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar: for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()): if path.is_file() and _allowed_source(path, source): _add_file(tar, path, f"project/核桃派软件源代码/{path.relative_to(source).as_posix()}") for root, archive_root in ( (wheelhouse, "project/离线依赖/其他依赖/aarch64-py311"), (debian, "project/离线依赖/其他依赖/debian12-aarch64"), ): for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()): if path.is_file(): _add_file(tar, path, f"{archive_root}/{path.relative_to(root).as_posix()}") if system_dependencies is not None: for path in sorted(system_dependencies.rglob("*"), key=lambda item: item.relative_to(system_dependencies).as_posix()): if path.is_file(): _add_file( tar, path, f"project/离线依赖/其他依赖/frp/0.71.0/linux-arm64/{path.relative_to(system_dependencies).as_posix()}", ) 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 image_metadata( version: str, bundle_bytes: int, bundle_sha256: str, kernel: KernelArtifactInfo, boot_required_bytes: int, boot_safety_bytes: int = DEFAULT_SAFETY_BYTES, ) -> dict: return { "format_version": IMAGE_FORMAT_VERSION, "product": PRODUCT_ID, "software_version": version, "bundle_path": BUNDLE_IMAGE_PATH, "bundle_bytes": bundle_bytes, "bundle_sha256": bundle_sha256, "kernel_release": kernel.kernel_release, "kernel_artifact": kernel.artifact, "kernel_artifact_bytes": kernel.artifact_bytes, "kernel_artifact_sha256": kernel.artifact_sha256, "kernel_unpacked_file_bytes": kernel.unpacked_file_bytes, "boot_required_bytes": boot_required_bytes, "boot_safety_bytes": boot_safety_bytes, } def build_image( base_image: Path, source: Path, wheelhouse: Path, debian: Path, kernel: Path, kernel_source: Path, config: dict | bytes, output: Path, bootstrap_bundle: Path, system_dependencies: Path | None = None, ) -> dict: base_image = Path(base_image).resolve() source = Path(source).resolve() wheelhouse = Path(wheelhouse).resolve() debian = Path(debian).resolve() kernel = Path(kernel).resolve() kernel_source = Path(kernel_source).resolve() output = Path(output).resolve() bootstrap_bundle = Path(bootstrap_bundle).resolve() system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None if system_dependencies is None: raise ValueError("the verified frpc system dependency bundle is required") if isinstance(config, bytes): config_bytes = config config, _, _ = read_config_file(config_bytes) config = validate_config(config) else: config = validate_config(config) config_bytes = create_config_file(config) version = config["software_version"] if (source / "VERSION").read_text(encoding="utf-8").strip() != version: raise ValueError("image configuration version does not match source VERSION") for manifest in ( wheelhouse / "SHA256SUMS", debian / "SHA256SUMS", debian / "BASE_IMAGE_PACKAGES.tsv", system_dependencies / "SHA256SUMS", ): if not manifest.is_file(): raise FileNotFoundError(manifest) verify_local_closure(debian, debian / "BASE_IMAGE_PACKAGES.tsv", list(IMAGE_DEBIAN_ROOTS)) kernel_info = verify_kernel_dependency(kernel) verify_source_dependency(kernel_source) if output.exists() or bootstrap_bundle.exists(): raise FileExistsError(output if output.exists() else bootstrap_bundle) output.parent.mkdir(parents=True, exist_ok=True) bootstrap_bundle.parent.mkdir(parents=True, exist_ok=True) try: build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies) bundle_digest = sha256_file(bootstrap_bundle) shutil.copyfile(base_image, output) except BaseException: output.unlink(missing_ok=True) bootstrap_bundle.unlink(missing_ok=True) raise try: firstboot = (source / "scripts" / "image_firstboot.sh").read_bytes() with Fat16Image(output, writable=True) as image: image.write_file("MSCCFG.BIN", config_bytes, contiguous=True) image.write_file("MSCINIT", firstboot) with Fat16Image(output) as image: boot_budget = calculate_budget( candidate_sizes=candidate_sizes_from_dependency(kernel), boot_cmd=image.read_file("BOOT.CMD"), boot_scr=image.read_file("BOOT.SCR"), cluster_bytes=image.cluster_bytes, available_bytes=image.free_bytes(), ) if boot_budget.available_bytes < boot_budget.total_bytes: raise RuntimeError( "FAT boot partition lacks safe candidate-kernel space: " f"available={boot_budget.available_bytes}, required={boot_budget.required_bytes}, " f"safety={boot_budget.safety_bytes}, total={boot_budget.total_bytes}" ) meta = image_metadata( version, bootstrap_bundle.stat().st_size, bundle_digest, kernel_info, boot_budget.required_bytes, boot_budget.safety_bytes, ) rendered_meta = ( json.dumps(meta, ensure_ascii=False, sort_keys=True, indent=2) + "\n" ).encode("utf-8") with Fat16Image(output, writable=True) as image: image.write_file("MSCMETA.JSN", rendered_meta) with Fat16Image(output) as image: if image.read_file("MSCCFG.BIN") != config_bytes: raise RuntimeError("image configuration read-back failed") if image.find("MSCBOOT.TGZ") is not None: raise RuntimeError("FAT boot partition unexpectedly contains the application bundle") if image.read_file("MSCMETA.JSN") != rendered_meta: raise RuntimeError("image metadata read-back failed") if image.read_file("MSCINIT") != firstboot: raise RuntimeError("image first-boot program read-back failed") final_budget = calculate_budget( candidate_sizes=candidate_sizes_from_dependency(kernel), boot_cmd=image.read_file("BOOT.CMD"), boot_scr=image.read_file("BOOT.SCR"), cluster_bytes=image.cluster_bytes, available_bytes=image.free_bytes(), ) if final_budget.available_bytes < final_budget.total_bytes: raise RuntimeError("final FAT boot partition no longer satisfies the recorded space budget") except BaseException: output.unlink(missing_ok=True) bootstrap_bundle.unlink(missing_ok=True) raise return { **meta, "created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), "image_bytes": output.stat().st_size, "image_sha256": sha256_file(output), } def main() -> int: parser = argparse.ArgumentParser(description="Build a Rufus-writable WalnutPi controller image") parser.add_argument("--base-image", type=Path, required=True) parser.add_argument("--source", type=Path, required=True) parser.add_argument("--wheelhouse", type=Path, required=True) parser.add_argument("--debian", type=Path, required=True) parser.add_argument("--kernel", type=Path, required=True) parser.add_argument("--kernel-source", type=Path, required=True) parser.add_argument("--config", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--bootstrap-bundle-output", type=Path, required=True) parser.add_argument("--frpc-bundle", type=Path, required=True) args = parser.parse_args() config = json.loads(args.config.read_text(encoding="utf-8")) info = build_image( args.base_image, args.source, args.wheelhouse, args.debian, args.kernel, args.kernel_source, config, args.output, args.bootstrap_bundle_output, args.frpc_bundle, ) print(json.dumps(info, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())