from __future__ import annotations import json import hashlib from pathlib import Path import subprocess import sys import tarfile import pytest from scripts.image_config import ( CONFIG_FILE_BYTES, ImageConfigError, create_config_file, read_config_file, update_config_file, ) from scripts.export_release import next_patch from scripts import export_release from scripts.promote_image_release import PASS_FIELDS, _validate_report from scripts.build_sd_image import build_bundle, image_metadata from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget from scripts.kernel_artifact import KernelArtifactInfo from app.ota.versioning import SoftwareVersion SOURCE_ROOT = Path(__file__).resolve().parents[1] def config() -> dict: return { "schema_version": 1, "product": "matrix-screen-controller-walnutpi", "software_version": "1.0.2", "account": {"username": "example", "password": "example-password"}, "wifi": {"ssid": "example-network", "password": "example-wifi-password"}, "ipv4": {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}, } def test_release_staging_changes_version_without_changing_feature_timestamp(tmp_path): source = tmp_path / "source" source.mkdir() (source / "VERSION").write_text("1.0.3\n", encoding="utf-8") feature_timestamp = b"2026-09-04T12:40+08:00\n" (source / "FEATURE_UPDATED_AT").write_bytes(feature_timestamp) destination = tmp_path / "staged" export_release._copy_source(source, destination, SoftwareVersion.parse("1.0.4")) assert (destination / "VERSION").read_text(encoding="utf-8") == "1.0.4\n" assert (destination / "FEATURE_UPDATED_AT").read_bytes() == feature_timestamp assert (source / "VERSION").read_text(encoding="utf-8") == "1.0.3\n" assert (source / "FEATURE_UPDATED_AT").read_bytes() == feature_timestamp def test_image_readme_exposes_only_the_intended_defaults(): value = config() manifest = { "created_at": "2026-09-08T12:00:00+08:00", "artifact": "matrix-screen-controller-1.1.1.img", "image_sha256": "a" * 64, } body = export_release._image_readme(SoftwareVersion.parse("1.1.1"), manifest, "image", value) assert value["account"]["username"] in body assert value["account"]["password"] in body assert value["wifi"]["ssid"] in body assert value["wifi"]["password"] in body assert "公开的默认凭据" in body and "DHCP" in body assert "前置系列基线" not in body and "系统设置上传 OTA" not in body def test_source_readme_does_not_embed_a_development_device_address(): body = (SOURCE_ROOT / "README.md").read_text(encoding="utf-8") assert "192.168.198.94" not in body assert "<设备通过 DHCP 获得的 IPv4>" in body def test_image_validation_report_is_strict_and_digest_bound(tmp_path): report = { "schema_version": 1, "artifact_type": "image", "image_sha256": "b" * 64, "software_version": "1.1.1", "status": "success", "validated_at": "2026-09-08T12:00:00+08:00", **{name: True for name in PASS_FIELDS}, } path = tmp_path / "report.json" path.write_text(json.dumps(report), encoding="utf-8") assert _validate_report(path, "b" * 64, SoftwareVersion.parse("1.1.1")) == report report["unexpected_secret"] = "must be rejected" path.write_text(json.dumps(report), encoding="utf-8") with pytest.raises(ValueError, match="fields"): _validate_report(path, "b" * 64, SoftwareVersion.parse("1.1.1")) def test_image_candidate_does_not_publish_or_consume_version(tmp_path, monkeypatch): source = tmp_path / "核桃派软件源代码" source.mkdir() (source / "VERSION").write_text("1.1.0\n", encoding="utf-8") (source / "FEATURE_UPDATED_AT").write_text("2026-09-08T10:19+08:00\n", encoding="utf-8") (source / "UPGRADE_POLICY.json").write_text('{"schema_version":1,"checkpoints":[]}', encoding="utf-8") history = tmp_path / "发布记录.json" history.write_text('{"schema_version":1,"releases":[]}', encoding="utf-8") config_path = tmp_path / "image-config.json" config_path.write_text(json.dumps(config()), encoding="utf-8") candidate = tmp_path / "candidate" def fake_build(*args): output, bundle = args[-3], args[-2] output.write_bytes(b"candidate-image") bundle.write_bytes(b"bundle") return { "format_version": 3, "software_version": "1.1.1", "created_at": "2026-09-08T12:00:00+08:00", "image_bytes": output.stat().st_size, "image_sha256": hashlib.sha256(output.read_bytes()).hexdigest(), } monkeypatch.setattr(export_release, "SOURCE_ROOT", source) monkeypatch.setattr(export_release, "build_image", fake_build) monkeypatch.setattr(export_release, "_require_image_host", lambda: None) monkeypatch.setattr(export_release.subprocess, "run", lambda *args, **kwargs: None) monkeypatch.setattr( sys, "argv", ["export", "image", "--version", "1.1.1", "--config", str(config_path), "--candidate-output", str(candidate), "--notes", "candidate"], ) assert export_release.main() == 0 assert (source / "VERSION").read_text(encoding="utf-8") == "1.1.0\n" assert history.read_text(encoding="utf-8") == '{"schema_version":1,"releases":[]}' assert not (tmp_path / "发布更新相关" / "导出包" / "1.1.1").exists() assert {path.name for path in candidate.iterdir()} == { "README.md", "manifest.json", "matrix-screen-controller-1.1.1.img", "matrix-screen-controller-1.1.1.img.sha256", } def test_dual_slot_config_round_trip_and_fallback(): initial = create_config_file(config()) assert len(initial) == CONFIG_FILE_BYTES document, generation, slot = read_config_file(initial) assert document["account"]["username"] == "example" assert (generation, slot) == (1, 0) changed = config() changed["wifi"]["ssid"] = "second-network" updated = update_config_file(initial, changed) document, generation, slot = read_config_file(updated) assert document["wifi"]["ssid"] == "second-network" assert (generation, slot) == (2, 1) damaged = bytearray(updated) damaged[34_900] ^= 0xFF document, generation, slot = read_config_file(bytes(damaged)) assert document["wifi"]["ssid"] == "example-network" assert (generation, slot) == (1, 0) def test_config_validation_rejects_bad_network_and_secrets(): value = config() value["wifi"]["password"] = "short" with pytest.raises(ImageConfigError): create_config_file(value) value = config() value["ipv4"] = { "mode": "static", "address": "192.168.1.20", "prefix": 24, "gateway": "192.168.2.1", "dns": ["192.168.1.1"], } with pytest.raises(ImageConfigError): create_config_file(value) def test_release_sequence_is_shared(): assert str(next_patch(SoftwareVersion.parse("1.0.1"))) == "1.0.2" assert str(next_patch(SoftwareVersion.parse("1.0.2"))) == "1.0.3" def test_image_metadata_v3_has_exact_payload_and_boot_identity(): kernel = KernelArtifactInfo( artifact="axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", artifact_bytes=10, artifact_sha256="a" * 64, kernel_release="6.1.31-matrix-axp313a1", source_commit="30ff3fd5cf45417622b447a12e7a947402ffe34d", unpacked_file_bytes=20, regular_file_count=9, module_file_count=1, ) assert image_metadata("1.0.3", 30, "b" * 64, kernel, 40) == { "format_version": 3, "product": "matrix-screen-controller-walnutpi", "software_version": "1.0.3", "bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ", "bundle_bytes": 30, "bundle_sha256": "b" * 64, "kernel_release": "6.1.31-matrix-axp313a1", "kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", "kernel_artifact_bytes": 10, "kernel_artifact_sha256": "a" * 64, "kernel_unpacked_file_bytes": 20, "boot_required_bytes": 40, "boot_safety_bytes": DEFAULT_SAFETY_BYTES, } def test_fat_bundle_excludes_kernel_binary_and_source_dependencies(tmp_path: Path): source = tmp_path / "source" wheelhouse = tmp_path / "wheelhouse" debian = tmp_path / "debian" kernel = tmp_path / "offline/aarch64-kernel" kernel_source = tmp_path / "offline/kernel-source" for directory in (source, wheelhouse, debian, kernel, kernel_source): directory.mkdir(parents=True) (source / "app.py").write_text("app\n", encoding="utf-8") (source / "FEATURE_UPDATED_AT").write_text("2026-09-04T12:40+08:00\n", encoding="utf-8") (wheelhouse / "wheel.whl").write_bytes(b"wheel") (debian / "package.deb").write_bytes(b"deb") (kernel / "kernel.tar.gz").write_bytes(b"kernel") (kernel_source / "source.tar.gz").write_bytes(b"source") output = tmp_path / "bundle.tgz" build_bundle(source, wheelhouse, debian, output) with tarfile.open(output, "r:gz") as archive: names = {member.name for member in archive.getmembers()} assert "project/核桃派软件源代码/app.py" in names assert "project/核桃派软件源代码/FEATURE_UPDATED_AT" in names assert "project/离线依赖/其他依赖/aarch64-py311/wheel.whl" in names assert "project/离线依赖/其他依赖/debian12-aarch64/package.deb" in names assert not any("aarch64-kernel" in name or "kernel-source" in name for name in names) def test_firstboot_defers_controller_start_to_avoid_systemd_ordering_deadlock(): firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") deploy = (SOURCE_ROOT / "scripts" / "deploy_walnutpi.sh").read_text(encoding="utf-8") assert "DEFER_SERVICE_START=1" in firstboot assert 'if [ "$DEFER_SERVICE_START" = "1" ]; then' in deploy assert deploy.count('dedicated_host.py" check --service') == 2 assert "systemctl enable matrix-screen-controller.service" in deploy assert "service start is deferred until reboot" in deploy def test_firstboot_does_not_block_offline_installation_on_wifi_connectivity(): firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") network_config = "scripts.provision_device network --config" deferred_deploy = "DEFER_SERVICE_START=1" offline_success = ( "SUCCESS: provisioning completed while Wi-Fi is not connected; " "the installed controller will keep trying after reboot." ) assert firstboot.index(network_config) < firstboot.index(deferred_deploy) assert "systemctl restart NetworkManager.service" in firstboot assert "nmcli --wait" not in firstboot assert "connection up matrix-screen" not in firstboot assert "for _attempt in" not in firstboot assert "ip route show default | grep -q ." in firstboot assert offline_success in firstboot assert firstboot.index("rm -f -- \"$CONFIG\"") < firstboot.index(offline_success) def test_image_payload_v3_stages_both_payloads_outside_fat(): builder = (SOURCE_ROOT / "scripts/build_sd_image.py").read_text(encoding="utf-8") exporter = (SOURCE_ROOT / "scripts/export_release.py").read_text(encoding="utf-8") installer = (SOURCE_ROOT / "scripts/install_image_bootstrap.sh").read_text(encoding="utf-8") verifier = (SOURCE_ROOT / "scripts/verify_image_bootstrap.sh").read_text(encoding="utf-8") firstboot = (SOURCE_ROOT / "scripts/image_firstboot.sh").read_text(encoding="utf-8") assert 'IMAGE_FORMAT_VERSION = 3' in builder assert 'BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"' in builder assert '"kernel_artifact_sha256"' in builder assert '"kernel_unpacked_file_bytes"' in builder assert "build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies)" in builder assert "aarch64-kernel" in exporter and "kernel-source" in exporter assert "image_bootstrap_payload.py\" stage" in installer assert "image_bootstrap_payload.py\" verify" in verifier assert '"format_version": 3' in firstboot assert "[ ! -e \"$BOOT_MOUNT/MSCBOOT.TGZ\" ]" in verifier assert "scripts/kernel_artifact.py --kernel" in firstboot assert "install_axp313a_kernel.sh --artifact" in firstboot assert "kernel_unpacked_bytes * 2 + 268435456" in firstboot assert "rm -rf -- /opt/matrix-image-bootstrap" in firstboot def test_in_place_image_payload_refresh_is_retired(): refresher = (SOURCE_ROOT / "scripts/refresh_sd_image_payload.py").read_text(encoding="utf-8") assert "in-place image payload refresh is retired" in refresher assert "export_release.py image --repair-current" in refresher assert 'write_file("MSCBOOT.TGZ"' not in refresher def test_export_help_works_without_site_packages(): result = subprocess.run( [sys.executable, "-S", str(SOURCE_ROOT / "scripts/export_release.py"), "--help"], check=False, capture_output=True, text=True, ) assert result.returncode == 0 assert "--repair-current" in result.stdout assert "PIL" not in result.stderr and "fontTools" not in result.stderr def test_boot_budget_counts_candidate_rollback_temporary_and_safety_files(): candidate = { "boot/Image-matrix-axp313a1": 9_000, "boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": 2_000, "boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": 2_000, "boot/System.map-6.1.31-matrix-axp313a1": 3_000, "boot/config-6.1.31-matrix-axp313a1": 1_000, } boot_cmd = b'''# DO NOT EDIT THIS FILE\nsetenv docker_optimizations "on"\nsetenv bootargs "root=/dev/mmcblk1p2"\nif test "${docker_optimizations}" = "on"; then setenv bootargs "${bootargs} cgroup_enable=memory swapaccount=1"; fi\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile_emmc}.dtb\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb\nload ${devtype} ${devnum} ${kernel_addr_r} ${prefix}Image\n''' budget = calculate_budget( candidate_sizes=candidate, boot_cmd=boot_cmd, boot_scr=b"scr", cluster_bytes=4096, available_bytes=10**9, ) assert budget.required_bytes > sum(candidate.values()) assert budget.safety_bytes == 32 * 1024 * 1024 assert budget.total_bytes == budget.required_bytes + budget.safety_bytes def test_repair_commit_replaces_directory_and_record_without_duplicate(tmp_path: Path): final = tmp_path / "1.0.3" staged = tmp_path / ".1.0.3.building" final.mkdir() staged.mkdir() (final / "old").write_text("old", encoding="utf-8") (staged / "new").write_text("new", encoding="utf-8") history_path = tmp_path / "发布记录.json" history = {"schema_version": 1, "releases": [{"version": "1.0.3", "artifact_sha256": "new"}]} history_path.write_text('{"schema_version":1,"releases":[]}', encoding="utf-8") leftover = export_release._commit_repair(final, staged, history_path, history) assert leftover is None assert (final / "new").read_text(encoding="utf-8") == "new" assert not (final / "old").exists() assert json.loads(history_path.read_text(encoding="utf-8")) == history assert not list(tmp_path.glob("*.invalid-backup")) def test_repair_commit_restores_old_directory_when_record_write_fails(tmp_path: Path, monkeypatch): final = tmp_path / "1.0.3" staged = tmp_path / ".1.0.3.building" final.mkdir() staged.mkdir() (final / "old").write_text("old", encoding="utf-8") (staged / "new").write_text("new", encoding="utf-8") history_path = tmp_path / "发布记录.json" original_history = b'{"schema_version":1,"releases":[]}\n' history_path.write_bytes(original_history) def fail_record(_path, _document): raise OSError("simulated record failure") monkeypatch.setattr(export_release, "_atomic_json", fail_record) with pytest.raises(OSError, match="simulated record failure"): export_release._commit_repair( final, staged, history_path, {"schema_version": 1, "releases": [{"version": "1.0.3"}]}, ) assert (final / "old").read_text(encoding="utf-8") == "old" assert not (final / "new").exists() assert history_path.read_bytes() == original_history def test_firstboot_requires_managed_password_ssh_after_identity_reset(): firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") policy = (SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf").read_text(encoding="utf-8") assert policy.splitlines() == [ "PermitRootLogin no", "PasswordAuthentication yes", "KbdInteractiveAuthentication no", "PermitEmptyPasswords no", ] identity = "python3 -m scripts.provision_device identity --config" ssh = "python3 -m scripts.provision_device ssh --config" assert firstboot.index(identity) < firstboot.index(ssh) < firstboot.index("systemctl reboot") assert "systemctl enable --now ssh.service" not in firstboot def test_firstboot_cannot_time_out_or_reload_away_its_running_unit(): firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") unit = (SOURCE_ROOT / "systemd" / "matrix-image-firstboot.service").read_text(encoding="utf-8") success = 'status "SUCCESS: provisioning completed; the device is rebooting into the installed controller."' assert "TimeoutStartSec=infinity" in unit assert "systemctl daemon-reload" not in firstboot assert firstboot.index('rm -f -- /etc/systemd/system/matrix-image-firstboot.service') < firstboot.index(success) assert firstboot.index('rm -rf -- "$WORK"') < firstboot.index(success) assert firstboot.index("sync\n") < firstboot.index(success) < firstboot.index("systemctl reboot") def test_image_export_installs_and_verifies_managed_ssh_policy(): exporter = (SOURCE_ROOT / "scripts" / "export_release.py").read_text(encoding="utf-8") installer = (SOURCE_ROOT / "scripts" / "install_image_bootstrap.sh").read_text(encoding="utf-8") verifier = (SOURCE_ROOT / "scripts" / "verify_image_bootstrap.sh").read_text(encoding="utf-8") name = "10-walnutpi-screen-hardening.conf" assert exporter.count(name) == 2 assert name in installer and 'enable ssh.service' in installer assert name in verifier and 'multi-user.target.wants/ssh.service' in verifier