"""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) ────────────────────────────── @pytest.mark.windows_only def test_gate_fails_clearly_without_backup(tmp_path, capsys): """``windows_only``: ``_ensure_desktop_exe_launchable`` is a documented no-op off Windows, so the fake was the only reason the gate ran at all. """ 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) @pytest.mark.windows_only 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 leave the previous working build in place instead of installing the corrupt one. Stage-and-swap (#86443): the pack lands in a staging dir; the integrity gate runs on the STAGED exe and a failure discards staging without ever touching the live ``win-unpacked`` tree. ``windows_only``: the whole chain is Windows-gated — ``win-unpacked`` candidate discovery in ``_desktop_packaged_executable`` and the integrity gate itself both short-circuit off Windows. """ 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) live_exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" make_pe(live_exe, PE_AMD64) # the previous, working app live_bytes = live_exe.read_bytes() install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) def pack_into_staging(cmd, *args, **kwargs): # electron-builder honours -c.directories.output=; emulate a # pack that "succeeds" but writes a truncated exe there. out_flag = next((a for a in cmd if str(a).startswith("-c.directories.output=")), None) assert out_flag is not None, "pack must be redirected into a staging dir" staging = Path(str(out_flag).split("=", 1)[1]) make_pe(staging / "win-unpacked" / "Hermes.exe", PE_AMD64, truncate_to=0x300) return subprocess.CompletedProcess(list(cmd), 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main._resolve_node_runtime_npm", return_value="npm.cmd"), \ 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", side_effect=pack_into_staging), \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) assert exc.value.code == 1 # The previous working exe was never touched... assert live_exe.read_bytes() == live_bytes assert cli_main._parse_pe_machine(live_exe) == PE_AMD64 # ...the staged corrupt tree was discarded... assert not list((desktop_dir / "release").glob(".staging-*")) # ...and the poisoned build was never stamped as good. mock_stamp.assert_not_called() out = capsys.readouterr().out assert "integrity check" in out