from __future__ import annotations import gzip import hashlib import io import json import os from pathlib import Path import tarfile import pytest from scripts.image_kernel_payload import ( ImageKernelPayloadError, stage_payload, verify_staged_payload, ) from scripts.image_bootstrap_payload import ( APP_RELATIVE, TARGET_RELATIVE, ImageBootstrapPayloadError, stage_payload as stage_bootstrap_payload, verify_staged_payload as verify_bootstrap_payload, ) from scripts.kernel_artifact import ( ARTIFACT_NAME, KERNEL_RELEASE, REQUIRED_BOOT_FILES, SOURCE_ARCHIVE_SHA256, SOURCE_COMMIT, KernelArtifactError, sha256_file, verify_kernel_dependency, verify_source_dependency, ) SOURCE_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = SOURCE_ROOT.parent def _dependency( root: Path, *, release_marker: str = KERNEL_RELEASE, source_marker: str = SOURCE_COMMIT, extra_member: tarfile.TarInfo | None = None, ) -> Path: root.mkdir() files = { "boot/Image-matrix-axp313a1": f"prefix Linux version {KERNEL_RELEASE} suffix".encode(), "boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": b"dtb-sd", "boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": b"dtb-emmc", f"boot/System.map-{KERNEL_RELEASE}": b"system-map", f"boot/config-{KERNEL_RELEASE}": b"config", "KERNEL_RELEASE": f"{release_marker}\n".encode(), "SOURCE_COMMIT": f"{source_marker}\n".encode(), f"lib/modules/{KERNEL_RELEASE}/kernel/example.ko": b"module", } sums = "".join( f"{hashlib.sha256(body).hexdigest()} {name}\n" for name, body in sorted(files.items()) ).encode() files["SHA256SUMS"] = sums artifact = root / ARTIFACT_NAME with artifact.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 archive: for name, body in sorted(files.items()): info = tarfile.TarInfo(name) info.size = len(body) info.uid = info.gid = 0 info.mtime = 0 archive.addfile(info, io.BytesIO(body)) if extra_member is not None: extra_member.uid = extra_member.gid = 0 extra_member.mtime = 0 archive.addfile(extra_member) digest = sha256_file(artifact) (root / "SHA256SUMS").write_text(f"{digest} {ARTIFACT_NAME}\n", encoding="ascii") metadata = { "schema_version": 1, "architecture": "aarch64", "kernel_release": KERNEL_RELEASE, "source_commit": SOURCE_COMMIT, "source_archive_sha256": SOURCE_ARCHIVE_SHA256, "artifact": ARTIFACT_NAME, "artifact_bytes": artifact.stat().st_size, "artifact_sha256": digest, "unpacked_file_bytes": sum(len(body) for body in files.values()), "regular_file_count": len(files), "module_file_count": 1, "provenance": "synthetic test payload", } (root / "METADATA.json").write_text(json.dumps(metadata), encoding="utf-8") return root def test_registered_kernel_and_source_dependencies_are_complete(): kernel = PROJECT_ROOT / "发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1" source = PROJECT_ROOT / "发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5" if os.environ.get("MATRIX_SOURCE_ONLY_UPDATE_TESTS") == "1": if not kernel.exists() and not source.exists(): pytest.skip("source-only update payload intentionally omits image build dependencies") info = verify_kernel_dependency(kernel) assert info.module_file_count == 3048 assert info.regular_file_count == 3056 assert info.unpacked_file_bytes == 160051604 assert verify_source_dependency(source)["archive_bytes"] == 249095239 registry = json.loads((PROJECT_ROOT / "发布更新相关/其他依赖/DEPENDENCIES.json").read_text(encoding="utf-8")) active = {item["id"]: item for item in registry["active"]} assert active["axp313a-kernel-runtime"]["path"] == "aarch64-kernel/6.1.31-matrix-axp313a1" assert active["walnutpi-linux-kernel-source"]["path"] == ( "kernel-source/walnutpi-linux-6.1.31-30ff3fd5" ) def test_synthetic_kernel_dependency_round_trip(tmp_path: Path): info = verify_kernel_dependency(_dependency(tmp_path / "kernel")) assert info.kernel_release == KERNEL_RELEASE assert info.module_file_count == 1 assert REQUIRED_BOOT_FILES def test_kernel_dependency_rejects_outer_digest_damage(tmp_path: Path): dependency = _dependency(tmp_path / "kernel") artifact = dependency / ARTIFACT_NAME body = bytearray(artifact.read_bytes()) body[-1] ^= 0xFF artifact.write_bytes(body) with pytest.raises(KernelArtifactError, match="SHA-256 mismatch"): verify_kernel_dependency(dependency) def test_kernel_dependency_rejects_wrong_release_marker(tmp_path: Path): dependency = _dependency(tmp_path / "kernel", release_marker="wrong-release") with pytest.raises(KernelArtifactError, match="release marker"): verify_kernel_dependency(dependency) def test_kernel_dependency_rejects_wrong_source_marker_and_missing_binary(tmp_path: Path): dependency = _dependency(tmp_path / "wrong", source_marker="0" * 40) with pytest.raises(KernelArtifactError, match="source commit marker"): verify_kernel_dependency(dependency) missing = _dependency(tmp_path / "missing") (missing / ARTIFACT_NAME).unlink() with pytest.raises(KernelArtifactError, match="binary is missing"): verify_kernel_dependency(missing) @pytest.mark.parametrize( ("name", "kind", "message"), ( ("unsafe-link", tarfile.SYMTYPE, "link or special file"), ("../escape", tarfile.REGTYPE, "unsafe archive path"), ("secret.txt", tarfile.REGTYPE, "unowned file"), ), ) def test_kernel_dependency_rejects_links_and_unsafe_paths( tmp_path: Path, name: str, kind: bytes, message: str ): extra = tarfile.TarInfo(name) extra.type = kind if kind == tarfile.SYMTYPE: extra.linkname = "boot/Image-matrix-axp313a1" dependency = _dependency(tmp_path / "kernel", extra_member=extra) with pytest.raises(KernelArtifactError, match=message): verify_kernel_dependency(dependency) def test_image_root_payload_stage_verify_and_corruption(tmp_path: Path): dependency = _dependency(tmp_path / "kernel") root = tmp_path / "root" root.mkdir() result = stage_payload(root, dependency, reserve_bytes=0) assert result["kernel_release"] == KERNEL_RELEASE assert verify_staged_payload(root, dependency) == result staged = root / "opt/matrix-image-bootstrap/axp313a" / ARTIFACT_NAME staged.write_bytes(staged.read_bytes() + b"damage") with pytest.raises(KernelArtifactError, match="SHA-256 mismatch"): verify_staged_payload(root, dependency) def test_image_root_payload_refuses_existing_target_and_insufficient_space(tmp_path: Path): dependency = _dependency(tmp_path / "kernel") existing_root = tmp_path / "existing" existing_root.mkdir() stage_payload(existing_root, dependency, reserve_bytes=0) with pytest.raises(ImageKernelPayloadError, match="already exists"): stage_payload(existing_root, dependency, reserve_bytes=0) small_root = tmp_path / "small" small_root.mkdir() with pytest.raises(ImageKernelPayloadError, match="lacks room"): stage_payload(small_root, dependency, reserve_bytes=10**30) def test_image_bootstrap_stages_application_and_kernel_together(tmp_path: Path): dependency = _dependency(tmp_path / "kernel") bundle = tmp_path / "MSCBOOT.TGZ" bundle.write_bytes(b"application-bundle") root = tmp_path / "root" root.mkdir() result = stage_bootstrap_payload(root, bundle, dependency, reserve_bytes=0) assert result["bundle_bytes"] == len(b"application-bundle") assert result["bundle_path"] == "opt/matrix-image-bootstrap/app/MSCBOOT.TGZ" assert verify_bootstrap_payload(root, bundle, dependency) == result assert (root / TARGET_RELATIVE / APP_RELATIVE).read_bytes() == bundle.read_bytes() def test_image_bootstrap_rejects_damage_existing_target_and_combined_space(tmp_path: Path): dependency = _dependency(tmp_path / "kernel") bundle = tmp_path / "MSCBOOT.TGZ" bundle.write_bytes(b"application-bundle") root = tmp_path / "root" root.mkdir() stage_bootstrap_payload(root, bundle, dependency, reserve_bytes=0) with pytest.raises(ImageBootstrapPayloadError, match="already exists"): stage_bootstrap_payload(root, bundle, dependency, reserve_bytes=0) staged = root / TARGET_RELATIVE / APP_RELATIVE staged.write_bytes(b"damaged") with pytest.raises(ImageBootstrapPayloadError, match="differs"): verify_bootstrap_payload(root, bundle, dependency) small_root = tmp_path / "small" small_root.mkdir() with pytest.raises(ImageBootstrapPayloadError, match="both offline payloads"): stage_bootstrap_payload(small_root, bundle, dependency, reserve_bytes=10**30)