"""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(" 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("hermes_cli.main._windows_native_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() # ─── _ensure_desktop_exe_launchable (the gate) ────────────────────────────── 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("hermes_cli.main._windows_native_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