Files
hermes-agent/tests/hermes_cli/test_desktop_exe_integrity.py

366 lines
14 KiB
Python

"""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 PE validation and rejection before publication. Windows
host/architecture probes stay native; the Linux callback witness checks staging.
"""
from __future__ import annotations
import argparse
import struct
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli import main as cli_main
from hermes_cli import main_desktop
from hermes_platform.host import facts
PE_AMD64 = 0x8664
PE_ARM64 = 0xAA64
PE_I386 = 0x014C
@pytest.fixture(autouse=True)
def _clear_host_fact_caches():
facts.clear_caches()
yield
facts.clear_caches()
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("<I", buf, 0x3C, 0x80)
buf[0x80:0x84] = b"PE\x00\x00"
# COFF: machine, nsections=1, timestamp, symtab ptr, nsyms, opt-hdr size=0, chars
struct.pack_into("<HHIIIHH", buf, 0x84, machine, 1, 0, 0, 0, 0, 0x0002)
# Section table entry at 0x80 + 24 = 0x98; SizeOfRawData @+16, PointerToRawData @+20
section_off = 0x98
struct.pack_into("<II", buf, section_off + 16, 0x200, 0x200)
data = bytes(buf if truncate_to is None else buf[:truncate_to])
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
# ─── _parse_pe_machine ──────────────────────────────────────────────────────
# ─── _expected_windows_pe_machines ──────────────────────────────────────────
# ─── _windows_native_machine ────────────────────────────────────────────────
# MACHINE_ATTRIBUTES.UserEnabled — mirrors hermes_cli.main.
_USER_ENABLED = 0x00000001
class _SettableFunc:
"""Callable that accepts ctypes ``.restype`` / ``.argtypes`` assignment."""
def __init__(self, fn):
self._fn = fn
self.restype = None
self.argtypes = None
def __call__(self, *args, **kwargs):
return self._fn(*args, **kwargs)
class _FakeKernel32:
"""Stands in for kernel32 so the native-arch probes run on any CI OS."""
def __init__(
self,
native_pe_machine: int,
*,
wow64_ok: bool = True,
user_runnable: set | None = None,
):
self._native_pe = native_pe_machine
self._wow64_ok = wow64_ok
self._user_runnable = user_runnable
self.GetCurrentProcess = _SettableFunc(lambda: -1)
self.IsWow64Process2 = _SettableFunc(self._is_wow64_process2)
self.GetMachineTypeAttributes = _SettableFunc(
self._get_machine_type_attributes
)
def _is_wow64_process2(self, _handle, process_ref, native_ref):
if not self._wow64_ok:
return 0
process_ref._obj.value = PE_AMD64 # emulated x64 process
native_ref._obj.value = self._native_pe
return 1
def _get_machine_type_attributes(self, machine, attributes_ref):
if self._user_runnable is None:
raise AttributeError("GetMachineTypeAttributes unavailable in this fake")
if machine not in self._user_runnable:
attributes_ref._obj.value = 0
return 0
attributes_ref._obj.value = _USER_ENABLED
return 0
def _fake_windll(
native_pe_machine: int,
*,
wow64_ok: bool = True,
user_runnable: set | None = None,
):
kernel32 = _FakeKernel32(
native_pe_machine,
wow64_ok=wow64_ok,
user_runnable=user_runnable,
)
def _windll(name, *args, **kwargs):
assert "kernel32" in str(name).lower()
return kernel32
return _windll
@pytest.mark.platforms("windows")
def test_native_machine_reports_os_arch_not_process_arch():
"""The #69179 WoA regression: x64 Python under ARM64 emulation must report
ARM64 (the OS), not AMD64 (the process) — otherwise the integrity gate
rejects the correct ARM64 rebuild.
``platforms("windows")``: the probe under test is a ``ctypes.WinDLL("kernel32")``
call to ``IsWow64Process2``. A patched ``sys.platform`` only got the branch
entered — there is no kernel32 to bind on Linux, so nothing below the
branch (the HANDLE typing that #71218 was about) was ever executed.
"""
import ctypes
# WinDLL only exists on Windows; create=True so Linux/macOS CI can stub it.
with patch.object(ctypes, "WinDLL", _fake_windll(PE_ARM64), create=True), \
patch("platform.machine", return_value="AMD64"):
assert main_desktop._windows_native_machine() == "ARM64"
@pytest.mark.platforms("windows")
def test_expected_machines_prefers_user_runnable_api_over_arch_name(monkeypatch):
"""GetMachineTypeAttributes answers "can this host load PE machine X?"
directly, so a WoA host that reports AMD64 everywhere else still accepts an
ARM64 exe.
``platforms("windows")``: ``GetMachineTypeAttributes`` is a real kernel32 export
the fake host could not provide.
"""
import ctypes
monkeypatch.setenv("PROCESSOR_ARCHITECTURE", "AMD64")
monkeypatch.delenv("PROCESSOR_ARCHITEW6432", raising=False)
with patch.object(
ctypes,
"WinDLL",
_fake_windll(
PE_ARM64, wow64_ok=False, user_runnable={PE_ARM64, PE_AMD64}
),
create=True,
), patch("platform.machine", return_value="AMD64"):
assert main_desktop._expected_windows_pe_machines() == {PE_ARM64, PE_AMD64}
# ─── _desktop_exe_integrity_error ───────────────────────────────────────────
# ─── _desktop_packaged_executable arch preference (win32) ───────────────────
# ─── staged integrity gate ─────────────────────────────────────────────────
@pytest.mark.platforms("linux")
def test_corrupt_staged_app_keeps_live_bytes_without_backup_recovery(tmp_path, monkeypatch, capsys):
"""Exercise publication and PE parsing, not Windows host/architecture discovery."""
desktop_dir = tmp_path / "apps" / "desktop"
live_exe = desktop_dir / "release" / "linux-unpacked" / "hermes"
make_pe(live_exe)
live_bytes = live_exe.read_bytes()
assert main_desktop._parse_pe_machine(live_exe) == PE_AMD64
# The host-native layout makes discovery/swap real; PE is just fixture data.
staging = main_desktop._desktop_staging_dir(desktop_dir)
staged_exe = make_pe(staging / "linux-unpacked" / "hermes", truncate_to=0x300)
staged_bytes = staged_exe.read_bytes()
backup_exe = make_pe(staging / "linux-unpacked.bak" / "hermes")
old_diagnostic = staging / "linux-unpacked.corrupt" / "diagnostic"
old_diagnostic.parent.mkdir()
old_diagnostic.write_bytes(b"previous diagnostic")
# Raw in-place pack recovery material is not owned by this transaction.
live_backup = make_pe(live_exe.parent.with_name("linux-unpacked.bak") / "hermes")
checked = []
def check_pe(path):
checked.append(path)
try:
main_desktop._parse_pe_machine(path)
except ValueError as exc:
return str(exc)
return None
discard = main_desktop._discard_desktop_staging
def check_before_discard(path):
assert path == staging
assert staged_exe.read_bytes() == staged_bytes
assert backup_exe.read_bytes() == live_bytes
assert old_diagnostic.read_bytes() == b"previous diagnostic"
discard(path)
monkeypatch.setattr(main_desktop, "_discard_desktop_staging", check_before_discard)
with patch.object(main_desktop.os, "rename", wraps=main_desktop.os.rename) as rename:
with pytest.raises(RuntimeError, match="previous desktop app was left untouched"):
main_desktop._promote_staged_desktop_app(desktop_dir, staging, integrity_check=check_pe)
rename.assert_not_called()
assert checked == [staged_exe]
assert live_exe.read_bytes() == live_bytes
assert live_backup.read_bytes() == live_bytes
assert not staging.exists()
assert not list((desktop_dir / "release").glob("*.previous"))
out = capsys.readouterr().out
assert "integrity check" in out
assert "truncated executable" in out
@pytest.mark.platforms("windows")
def test_gate_fails_clearly_without_backup(tmp_path, capsys):
"""Exercise the default Windows PE check, without injecting a validator."""
desktop_dir = tmp_path / "apps" / "desktop"
staging = main_desktop._desktop_staging_dir(desktop_dir)
exe = staging / "win-unpacked" / "Hermes.exe"
exe.parent.mkdir(parents=True)
exe.write_bytes(b"<html>proxy error</html>" + b" " * 600)
with pytest.raises(RuntimeError, match="produced no launchable app"):
main_desktop._promote_staged_desktop_app(desktop_dir, staging)
assert not staging.exists()
assert not (desktop_dir / "release").exists()
out = capsys.readouterr().out
assert "integrity check" in out
assert "missing MZ header" 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.platforms("windows")
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.
``platforms("windows")``: 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()
def pack_into_staging(cmd, *args, **kwargs):
assert kwargs["env"]["PATH"].startswith("C:\\pm-pinned-git\\cmd;")
if cmd[1:3] != ["run", "builder"]:
return subprocess.CompletedProcess(list(cmd), 0)
# electron-builder honours -c.directories.output=<staging>; 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)
def pinned_git(name, *, base_env):
assert name == "git"
return SimpleNamespace(env={**base_env, "PATH": "C:\\pm-pinned-git\\cmd;" + base_env["PATH"]})
with patch("hermes_cli.main_desktop.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.source_build.source_build_env", return_value={"PATH": "/usr/bin"}), \
patch("hermes_cli.source_build.prepare_source_dependencies"), \
patch("pm.ensure", side_effect=pinned_git), \
patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \
patch("hermes_cli.main_desktop._stop_desktop_processes_locking_build", return_value=[]), \
patch("hermes_cli.main_desktop._windows_native_machine", return_value="AMD64"), \
patch("hermes_cli.main_desktop.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 main_desktop._parse_pe_machine(live_exe) == PE_AMD64
# ...the staged corrupt tree was discarded...
assert not list(desktop_dir.glob(".staging-*"))
out = capsys.readouterr().out
assert "integrity check" in out