"""Behavior tests for the Windows desktop-exe integrity gate (#69179). The desktop self-update chain (Desktop → hermes-setup --update → ``hermes update`` → ``hermes desktop --build-only`` → relaunch) rebuilds Hermes.exe on the end user's machine. Before this gate, "build succeeded" was just "the file exists", so a truncated PE (corrupt cached Electron zip), a non-PE file, or a wrong-architecture tree shipped as the new app — Windows then refuses to launch it with "This app can't run on your computer" (此应用无法在你的电脑上运行) and the user has no working install left. These tests exercise the behavior contract only: synthetic PE files go in, verdicts/rollbacks come out. """ from __future__ import annotations import argparse import struct import subprocess import sys from pathlib import Path from unittest.mock import patch import pytest from hermes_cli import main as cli_main PE_AMD64 = 0x8664 PE_ARM64 = 0xAA64 PE_I386 = 0x014C def make_pe(path: Path, machine: int = PE_AMD64, *, truncate_to: int | None = None) -> Path: """Write a minimal, structurally-complete PE file with one section. Layout: DOS header (e_lfanew=0x80) → PE signature + COFF header at 0x80 → one 40-byte section entry whose raw data spans [0x200, 0x400). Total file size 0x400 unless ``truncate_to`` cuts it short (the corrupt-download shape). """ buf = bytearray(0x400) buf[0:2] = b"MZ" struct.pack_into("404 Not Found" + b" " * 600) with pytest.raises(ValueError, match="MZ"): cli_main._parse_pe_machine(fake) def test_parse_pe_machine_rejects_tiny_file(tmp_path): stub = tmp_path / "Hermes.exe" stub.write_bytes(b"MZ") with pytest.raises(ValueError, match="too small"): cli_main._parse_pe_machine(stub) def test_parse_pe_machine_rejects_truncated_sections(tmp_path): """File cut mid-download: headers parse but section data extends past EOF.""" exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300) with pytest.raises(ValueError, match="truncated"): cli_main._parse_pe_machine(exe) def test_parse_pe_machine_rejects_bad_pe_signature(tmp_path): exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64) data = bytearray(exe.read_bytes()) data[0x80:0x84] = b"XX\x00\x00" exe.write_bytes(bytes(data)) with pytest.raises(ValueError, match="PE signature"): cli_main._parse_pe_machine(exe) # ─── _expected_windows_pe_machines ────────────────────────────────────────── @pytest.mark.parametrize( ("host", "loadable", "not_loadable"), [ ("AMD64", {PE_AMD64, PE_I386}, {PE_ARM64}), ("ARM64", {PE_ARM64, PE_AMD64}, {PE_I386}), ("x86", {PE_I386}, {PE_AMD64, PE_ARM64}), ], ) def test_expected_machines_per_host(host, loadable, not_loadable): with patch("platform.machine", return_value=host): expected = cli_main._expected_windows_pe_machines() assert loadable <= expected assert not (not_loadable & expected) def test_expected_machines_unknown_host_is_permissive(): """The gate must never brick launch on hosts we don't recognize.""" with patch("platform.machine", return_value="RISCV64"): expected = cli_main._expected_windows_pe_machines() assert {PE_AMD64, PE_ARM64, PE_I386} <= expected # ─── _desktop_exe_integrity_error ─────────────────────────────────────────── def test_integrity_error_none_for_matching_arch(tmp_path): exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64) with patch("platform.machine", return_value="AMD64"): assert cli_main._desktop_exe_integrity_error(exe) is None def test_integrity_error_reports_arch_mismatch(tmp_path): """ARM64 exe on the reporter's 'Windows 10 AMD64' host — the wrong-arch flavor of 'This app can't run on your computer'.""" exe = make_pe(tmp_path / "Hermes.exe", PE_ARM64) with patch("platform.machine", return_value="AMD64"): error = cli_main._desktop_exe_integrity_error(exe) assert error is not None and "architecture mismatch" in error assert "ARM64" in error def test_integrity_error_reports_corruption_reason(tmp_path): exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64, truncate_to=0x300) error = cli_main._desktop_exe_integrity_error(exe) assert error is not None and "truncated" in error # ─── _desktop_packaged_executable arch preference (win32) ─────────────────── def test_packaged_executable_prefers_host_arch_over_mtime(tmp_path, monkeypatch): """A newer wrong-arch tree must not shadow the loadable one (#69179).""" monkeypatch.setattr(cli_main.sys, "platform", "win32") desktop_dir = tmp_path / "apps" / "desktop" good = make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64) bad = make_pe(desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe", PE_ARM64) # Make the wrong-arch tree the newest, which the pure-mtime pick would take. import os os.utime(bad, (bad.stat().st_atime + 1000, bad.stat().st_mtime + 1000)) with patch("platform.machine", return_value="AMD64"): assert cli_main._desktop_packaged_executable(desktop_dir) == good def test_packaged_executable_falls_back_to_mtime_when_unparseable(tmp_path, monkeypatch): """Non-PE stubs (dev trees, tests) keep the historical newest-wins pick.""" monkeypatch.setattr(cli_main.sys, "platform", "win32") desktop_dir = tmp_path / "apps" / "desktop" a = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" b = desktop_dir / "release" / "win-arm64-unpacked" / "Hermes.exe" for p in (a, b): p.parent.mkdir(parents=True) p.write_text("", encoding="utf-8") import os os.utime(b, (b.stat().st_atime + 1000, b.stat().st_mtime + 1000)) with patch("platform.machine", return_value="AMD64"): assert cli_main._desktop_packaged_executable(desktop_dir) == b # ─── rollback ─────────────────────────────────────────────────────────────── def _win_tree(tmp_path: Path) -> tuple[Path, Path]: desktop_dir = tmp_path / "apps" / "desktop" exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" return desktop_dir, exe def test_rollback_restores_backup_and_keeps_corrupt_copy(tmp_path): desktop_dir, exe = _win_tree(tmp_path) make_pe(exe, PE_AMD64, truncate_to=0x300) # corrupt new build backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe" make_pe(backup_exe, PE_AMD64) # valid old build with patch("platform.machine", return_value="AMD64"): restored = cli_main._rollback_desktop_from_backup(exe) assert restored == exe # The restored exe is the old, valid build. assert cli_main._parse_pe_machine(exe) == PE_AMD64 assert exe.stat().st_size == 0x400 # Corrupt tree preserved for diagnostics; backup consumed. assert (desktop_dir / "release" / "win-unpacked.corrupt" / "Hermes.exe").exists() assert not backup_exe.exists() def test_rollback_returns_none_without_backup(tmp_path): _, exe = _win_tree(tmp_path) make_pe(exe, PE_AMD64, truncate_to=0x300) assert cli_main._rollback_desktop_from_backup(exe) is None # The corrupt tree is left in place (nothing to restore over it). assert exe.exists() def test_rollback_refuses_corrupt_backup(tmp_path): """Never 'restore' a backup that would also fail to launch.""" desktop_dir, exe = _win_tree(tmp_path) make_pe(exe, PE_AMD64, truncate_to=0x300) backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe" make_pe(backup_exe, PE_AMD64, truncate_to=0x280) # backup also corrupt assert cli_main._rollback_desktop_from_backup(exe) is None assert backup_exe.exists() # untouched # ─── _ensure_desktop_exe_launchable (the gate) ────────────────────────────── def test_gate_passes_valid_exe(tmp_path, monkeypatch): monkeypatch.setattr(cli_main.sys, "platform", "win32") desktop_dir, exe = _win_tree(tmp_path) make_pe(exe, PE_AMD64) with patch("platform.machine", return_value="AMD64"): verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe) assert verified == exe assert rolled_back is False def test_gate_noop_off_windows(tmp_path, monkeypatch): monkeypatch.setattr(cli_main.sys, "platform", "linux") desktop_dir, exe = _win_tree(tmp_path) exe.parent.mkdir(parents=True) exe.write_text("not a pe at all", encoding="utf-8") verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe) assert verified == exe assert rolled_back is False def test_gate_rolls_back_corrupt_exe_and_purges_cache(tmp_path, monkeypatch, capsys): monkeypatch.setattr(cli_main.sys, "platform", "win32") monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) desktop_dir, exe = _win_tree(tmp_path) make_pe(exe, PE_AMD64, truncate_to=0x300) make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64) stamp = tmp_path / "home" / "desktop-build-stamp.json" stamp.parent.mkdir(parents=True, exist_ok=True) stamp.write_text("{}", encoding="utf-8") with patch("platform.machine", return_value="AMD64"), \ patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \ patch("hermes_cli.main._desktop_stamp_path", return_value=stamp): verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe) assert rolled_back is True assert verified == exe assert cli_main._parse_pe_machine(exe) == PE_AMD64 # restored old build mock_purge.assert_called_once() assert not stamp.exists() # stamp invalidated so retry genuinely rebuilds out = capsys.readouterr().out assert "integrity check" in out assert "Update aborted" in out assert "existing version was kept" in out def test_gate_fails_clearly_without_backup(tmp_path, monkeypatch, capsys): monkeypatch.setattr(cli_main.sys, "platform", "win32") desktop_dir, exe = _win_tree(tmp_path) fake = exe fake.parent.mkdir(parents=True) fake.write_bytes(b"proxy error" + b" " * 600) with patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \ patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"): verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe) assert verified is None assert rolled_back is False out = capsys.readouterr().out assert "integrity check" in out assert "No usable backup" in out # ─── end-to-end: `hermes desktop --build-only` exits nonzero on corrupt exe ─ def _ns(**kw): defaults = dict( skip_build=False, build_only=True, force_build=False, source=False, fake_boot=False, ignore_existing=False, hermes_root=None, cwd=None, ) defaults.update(kw) return argparse.Namespace(**defaults) def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, capsys): """The updater chain's contract: a rebuild whose Hermes.exe cannot launch must exit nonzero (so hermes-setup's retry-once kicks in) and must restore the previous working build instead of leaving the corrupt one.""" root = tmp_path / "hermes-agent" desktop_dir = root / "apps" / "desktop" desktop_dir.mkdir(parents=True) (desktop_dir / "package.json").write_text("{}", encoding="utf-8") monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.setattr(cli_main.sys, "platform", "win32") exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" make_pe(exe, PE_AMD64, truncate_to=0x300) # what the failed pack produced make_pe(desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe", PE_AMD64) install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ patch("hermes_cli.main._desktop_build_needed", return_value=True), \ patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \ patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \ patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"), \ patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \ patch("platform.machine", return_value="AMD64"), \ patch("hermes_cli.main.subprocess.run", return_value=pack_ok), \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) assert exc.value.code == 1 # The previous working exe was restored... assert cli_main._parse_pe_machine(exe) == PE_AMD64 # ...and the poisoned build was never stamped as good. mock_stamp.assert_not_called() out = capsys.readouterr().out assert "integrity check" in out def test_build_only_succeeds_with_valid_exe(tmp_path, monkeypatch, capsys): root = tmp_path / "hermes-agent" desktop_dir = root / "apps" / "desktop" desktop_dir.mkdir(parents=True) (desktop_dir / "package.json").write_text("{}", encoding="utf-8") monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.setattr(cli_main.sys, "platform", "win32") make_pe(desktop_dir / "release" / "win-unpacked" / "Hermes.exe", PE_AMD64) install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ patch("hermes_cli.main._desktop_build_needed", return_value=True), \ patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \ patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \ patch("platform.machine", return_value="AMD64"), \ patch("hermes_cli.main.subprocess.run", return_value=pack_ok): cli_main.cmd_gui(_ns()) mock_stamp.assert_called_once() assert "Desktop packaged app ready" in capsys.readouterr().out