From 199f55805876965fadea0f7a7ef29ae9128602ec Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:04:53 -0700 Subject: [PATCH] fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop self-update chain (Desktop -> hermes-setup --update -> hermes update -> hermes desktop --build-only -> relaunch) rebuilds Hermes.exe on the user's machine and declared success on bare file EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted extraction or rcedit rewrite / full disk) or a wrong-architecture unpacked tree therefore shipped as the 'updated' app, which Windows refuses to load with 'This app can't run on your computer' (此应用无法在你的电脑上运行) — and the previous working build had already been wiped by before-pack.mjs, leaving nothing to fall back to. Fix, in three parts: - hermes_cli/main.py: post-build integrity gate on Windows (_ensure_desktop_exe_launchable). Parses the PE header of the freshly built Hermes.exe — MZ/PE magic, section-table completeness vs file size (catches truncation), and COFF machine vs the host arch (catches arm64/x64 mixups). On failure it purges the (likely corrupt) cached Electron zip, invalidates the content-hash build stamp so the updater's retry-once genuinely re-downloads and rebuilds, restores the previous build from the .bak tree when one exists (keeping the corrupt tree as .corrupt for diagnostics), tells the user the update was aborted and their old version kept, and exits nonzero. _desktop_packaged_executable also now prefers a host-loadable PE over pure newest-mtime when multiple win-*-unpacked trees coexist. - apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked tree is preserved as .bak (only when it holds the product exe — partial/corrupt trees still get the plain wipe) instead of being destroyed, providing the rollback material for the gate above. Non-Windows behavior is unchanged. - Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py (23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch, rollback semantics, and the build-only exit contract) and 6 new vitest cases in before-pack.test.mjs for the .bak preservation rules. Progresses #69179 --- apps/desktop/scripts/before-pack.mjs | 47 ++- apps/desktop/scripts/before-pack.test.mjs | 105 ++++- hermes_cli/main.py | 234 +++++++++++ .../hermes_cli/test_desktop_exe_integrity.py | 376 ++++++++++++++++++ 4 files changed, 759 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_desktop_exe_integrity.py diff --git a/apps/desktop/scripts/before-pack.mjs b/apps/desktop/scripts/before-pack.mjs index 8b2359dfba63..b76acfe32d21 100644 --- a/apps/desktop/scripts/before-pack.mjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -57,7 +57,8 @@ * - electronPlatformName: 'win32' | 'darwin' | 'linux' * - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal) */ -import { existsSync, rmSync } from 'node:fs' +import { existsSync, rmSync, renameSync } from 'node:fs' +import path from 'node:path' import { Arch } from 'electron-builder' import { stageNodePty } from './stage-native-deps.mjs' @@ -75,10 +76,52 @@ export function cleanStaleAppOutDir(appOutDir) { return true } +/** + * Windows rollback material (#69179): before wiping the previous unpacked + * tree, preserve it as `.bak` — but ONLY when it holds the product + * exe (i.e. it is a previously-working build, not the corrupted partial state + * cleanStaleAppOutDir exists to remove). If the fresh pack then produces a + * Hermes.exe that Windows can't load (truncated PE from a corrupt cached + * Electron zip, wrong arch), the updater's integrity gate in + * `hermes desktop --build-only` (hermes_cli/main.py + * `_ensure_desktop_exe_launchable`) restores this .bak instead of leaving the + * user with "This app can't run on your computer". + * + * Returns true when the tree was preserved (appOutDir no longer exists), false + * when there was nothing worth preserving (caller falls through to the wipe). + * A rename failure (AV holding a handle) also returns false — the wipe is the + * safe fallback and matches pre-#69179 behavior exactly. + */ +export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') { + if (!appOutDir || typeof appOutDir !== 'string' || !existsSync(appOutDir)) { + return false + } + if (!existsSync(path.join(appOutDir, productExeName))) { + // Partial/corrupt tree (interrupted prior pack) — not rollback material. + return false + } + const backupDir = `${appOutDir}.bak` + try { + rmSync(backupDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + renameSync(appOutDir, backupDir) + return true + } catch { + return false + } +} + export default async function beforePack(context) { const appOutDir = context && context.appOutDir + const platformName = context && context.electronPlatformName try { - if (cleanStaleAppOutDir(appOutDir)) { + // Windows: keep the previous working build as rollback material for the + // post-build integrity gate (#69179) instead of destroying it. Falls + // through to the plain wipe when the old tree is partial/corrupt or the + // rename fails. + const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe` + if (platformName === 'win32' && preserveRollbackBackup(appOutDir, productExe)) { + console.log(`[before-pack] preserved previous unpacked dir for rollback: ${appOutDir}.bak`) + } else if (cleanStaleAppOutDir(appOutDir)) { console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`) } } catch (err) { diff --git a/apps/desktop/scripts/before-pack.test.mjs b/apps/desktop/scripts/before-pack.test.mjs index 44adf9616187..d082ec4d2ad9 100644 --- a/apps/desktop/scripts/before-pack.test.mjs +++ b/apps/desktop/scripts/before-pack.test.mjs @@ -4,7 +4,7 @@ import os from 'node:os' import path from 'node:path' import { test } from 'vitest' -import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs' +import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs' test('cleanStaleAppOutDir removes a populated unpacked directory', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) @@ -50,3 +50,106 @@ test('beforePack default export resolves even when cleanup throws', async () => // remove; the contract under test is that the hook never rejects. await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' })) }) + +// ─── Windows rollback preservation (#69179) ──────────────────────────────── + +test('preserveRollbackBackup moves a working build to .bak', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8') + fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8') + + const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe') + + assert.equal(preserved, true) + // Original slot vacated so electron-builder stages into a clean tree... + assert.equal(fs.existsSync(appOutDir), false) + // ...and the previous working build is intact under .bak for rollback. + assert.equal( + fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), + 'MZ-old-build' + ) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('preserveRollbackBackup replaces a stale .bak from an older update', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current', 'utf8') + fs.mkdirSync(`${appOutDir}.bak`, { recursive: true }) + fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8') + + assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true) + assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current') + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('preserveRollbackBackup refuses a partial tree missing the product exe', () => { + // The corrupted partial state (interrupted prior pack) must NOT become + // rollback material — it is exactly what cleanStaleAppOutDir exists to wipe. + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8') + + assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false) + // Tree untouched; the caller's wipe path handles it. + assert.equal(fs.existsSync(appOutDir), true) + assert.equal(fs.existsSync(`${appOutDir}.bak`), false) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('preserveRollbackBackup ignores missing or invalid input', () => { + assert.equal(preserveRollbackBackup(''), false) + assert.equal(preserveRollbackBackup(undefined), false) + assert.equal(preserveRollbackBackup(null), false) + assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false) +}) + +test('beforePack on win32 preserves the previous build instead of wiping it', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8') + + // No packager info in the context → default 'Hermes.exe' product name. + // node-pty staging is skipped because arch is not a number here. + await beforePack({ appOutDir, electronPlatformName: 'win32' }) + + assert.equal(fs.existsSync(appOutDir), false) + assert.equal( + fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), + 'MZ-working' + ) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('beforePack on linux keeps the plain wipe (no .bak)', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'linux-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'x', 'utf8') + + await beforePack({ appOutDir, electronPlatformName: 'linux' }) + + assert.equal(fs.existsSync(appOutDir), false) + assert.equal(fs.existsSync(`${appOutDir}.bak`), false) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 32292368fe05..95cbcc89621e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5507,9 +5507,227 @@ def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]: existing = [p for p in candidates if p.exists()] if not existing: return None + if sys.platform == "win32" and len(existing) > 1: + # Multiple unpacked trees can coexist (e.g. a stale win-arm64-unpacked + # left behind by a cross-arch experiment next to the real win-unpacked). + # Picking purely by mtime can then hand a wrong-architecture Hermes.exe + # to the launcher, which Windows rejects with "This app can't run on + # your computer" (#69179). Prefer candidates whose PE machine field + # matches the host; fall back to mtime when none can be parsed. + expected = _expected_windows_pe_machines() + matching = [p for p in existing if _pe_machine_or_none(p) in expected] + if matching: + existing = matching return max(existing, key=lambda p: p.stat().st_mtime) +# ─── 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 and used to verify only that the file +# EXISTS before declaring success. A corrupt cached Electron zip whose +# extraction produced a truncated electron.exe, an interrupted rcedit resource +# rewrite, a disk-full pack, or a wrong-arch unpacked tree therefore shipped a +# broken binary that Windows refuses to load ("This app can't run on your +# computer" / 此应用无法在你的电脑上运行). These helpers parse the PE header — +# no signature infrastructure required — so a structurally broken or +# wrong-architecture Hermes.exe is caught BEFORE the updater replaces the +# working app, and the previous build can be restored from the .bak tree that +# apps/desktop/scripts/before-pack.mjs now preserves. + +_PE_MACHINE_I386 = 0x014C +_PE_MACHINE_AMD64 = 0x8664 +_PE_MACHINE_ARM64 = 0xAA64 + +_PE_MACHINE_NAMES = { + _PE_MACHINE_I386: "x86 (32-bit)", + _PE_MACHINE_AMD64: "x64 (AMD64)", + _PE_MACHINE_ARM64: "ARM64", +} + + +def _expected_windows_pe_machines() -> set: + """PE machine values the current Windows host can natively load. + + AMD64 hosts run x64 and (via WOW64) x86. ARM64 hosts run ARM64 and + (Windows 11 emulation) x64. 32-bit x86 hosts run only x86. Unknown + machines return the permissive full set so the integrity gate can never + brick launch on exotic hosts. + """ + import platform as _platform + + machine = (_platform.machine() or "").upper() + if machine in ("AMD64", "X86_64", "X64"): + return {_PE_MACHINE_AMD64, _PE_MACHINE_I386} + if machine in ("ARM64", "AARCH64"): + return {_PE_MACHINE_ARM64, _PE_MACHINE_AMD64} + if machine in ("X86", "I386", "I486", "I586", "I686"): + return {_PE_MACHINE_I386} + return {_PE_MACHINE_AMD64, _PE_MACHINE_ARM64, _PE_MACHINE_I386} + + +def _parse_pe_machine(path: Path) -> int: + """Parse ``path`` as a PE executable and return its COFF machine field. + + Raises ``ValueError`` with a human-readable reason when the file is not a + structurally complete PE: missing MZ/PE magic (an HTML error page or JSON + body saved as .exe), header truncation, or raw section data extending past + the end of the file (the truncated-download / interrupted-extraction + shape). Purely a header walk — cheap even on a 200 MB Electron exe. + """ + import struct + + try: + file_size = path.stat().st_size + except OSError as exc: + raise ValueError(f"unreadable: {exc}") + if file_size < 512: + raise ValueError( + f"file is only {file_size} bytes — far too small to be a Windows executable" + ) + with path.open("rb") as fh: + head = fh.read(64) + if len(head) < 64 or head[:2] != b"MZ": + raise ValueError( + "missing MZ header — not a Windows executable " + "(a truncated or non-binary file saved as .exe?)" + ) + e_lfanew = struct.unpack_from(" file_size: + raise ValueError("corrupt DOS header: PE header offset points past end of file") + fh.seek(e_lfanew) + pe_head = fh.read(24) + if len(pe_head) < 24 or pe_head[:4] != b"PE\x00\x00": + raise ValueError("missing PE signature — corrupt executable header") + machine, n_sections = struct.unpack_from(" Optional[int]: + try: + return _parse_pe_machine(path) + except ValueError: + return None + + +def _desktop_exe_integrity_error(path: Path) -> Optional[str]: + """Return a human-readable reason ``path`` cannot run on this Windows host, + or ``None`` when the exe parses as a complete PE of a loadable architecture. + """ + try: + machine = _parse_pe_machine(path) + except ValueError as exc: + return str(exc) + expected = _expected_windows_pe_machines() + if machine not in expected: + import platform as _platform + + got = _PE_MACHINE_NAMES.get(machine, f"unknown machine 0x{machine:04X}") + return ( + f"architecture mismatch: built a {got} executable but this is a " + f"{_platform.machine()} Windows host" + ) + return None + + +def _desktop_backup_unpacked_dir(packaged_executable: Path) -> Path: + """The rollback tree before-pack.mjs preserves: ``.bak``.""" + unpacked = packaged_executable.parent + return unpacked.parent / (unpacked.name + ".bak") + + +def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]: + """Restore the previous unpacked desktop app from its ``.bak`` tree. + + Returns the restored executable path, or ``None`` when no usable backup + exists (missing, or its exe fails the same integrity probe). The corrupt + tree is kept alongside as ``.corrupt`` for diagnostics. + Best-effort: never raises. + """ + unpacked = packaged_executable.parent + backup_dir = _desktop_backup_unpacked_dir(packaged_executable) + backup_exe = backup_dir / packaged_executable.name + if not backup_exe.exists(): + return None + if _desktop_exe_integrity_error(backup_exe) is not None: + return None + corrupt_dir = unpacked.parent / (unpacked.name + ".corrupt") + try: + shutil.rmtree(corrupt_dir, ignore_errors=True) + try: + unpacked.rename(corrupt_dir) + except OSError: + shutil.rmtree(unpacked, ignore_errors=True) + backup_dir.rename(unpacked) + except OSError: + return None + restored = unpacked / packaged_executable.name + return restored if restored.exists() else None + + +def _ensure_desktop_exe_launchable( + desktop_dir: Path, packaged_executable: Optional[Path] +) -> tuple: + """Windows post-build integrity gate for the self-update rebuild (#69179). + + Returns ``(verified_exe_or_None, rolled_back)``: + + - exe passed the probe → ``(exe, False)`` + - exe corrupt/wrong-arch, previous build restored → ``(old_exe, True)`` + - exe corrupt and nothing restorable → ``(None, False)`` + + On any integrity failure the corrupt cached Electron zip is purged and the + desktop build stamp invalidated, so the updater's retry-once rebuild pulls + a fresh, SHASUM-verified Electron download instead of re-staging the same + corrupt bytes. No-op off Windows and when there is no executable to check. + """ + if packaged_executable is None or sys.platform != "win32": + return packaged_executable, False + + error = _desktop_exe_integrity_error(packaged_executable) + if error is None: + return packaged_executable, False + + print(f"✗ The built Hermes.exe failed its integrity check: {error}") + print(f" at: {packaged_executable}") + + # Self-heal setup for the retry: drop the (likely corrupt) cached Electron + # zip and the content stamp so the next rebuild is a genuine re-download + + # re-stage rather than a replay of the same broken extraction. + _purge_electron_build_cache(desktop_dir) + try: + _desktop_stamp_path().unlink() + except OSError: + pass + + restored = _rollback_desktop_from_backup(packaged_executable) + if restored is not None: + print(" ↩ Update aborted — restored the previous working Hermes.exe from backup.") + print(" Your existing version was kept and still works. Run `hermes desktop`") + print(" (or the in-app update) again to retry with a fresh Electron download.") + return restored, True + + print(" ✗ No usable backup was found to restore.") + print(" Run `hermes desktop --force-build` to rebuild, or re-run the Hermes") + print(" installer to repair the install.") + return None, False + + def _electron_download_cache_dirs() -> list[Path]: """Return the per-user Electron download cache directories for this OS. @@ -6151,6 +6369,22 @@ def cmd_gui(args: argparse.Namespace): # damaged"). No-op on non-macOS and on real-identity builds. _desktop_macos_relaunchable_fixup(desktop_dir) + # Windows integrity gate (#69179): never declare the rebuild a + # success on a Hermes.exe Windows cannot load (truncated PE from + # a corrupt cached Electron zip, wrong-arch tree, interrupted + # rcedit rewrite). Roll back to the .bak tree preserved by + # before-pack.mjs when possible, then fail loudly so the + # updater's retry-once rebuilds from a fresh Electron download + # instead of silently shipping the broken exe. + verified_executable, rolled_back = _ensure_desktop_exe_launchable( + desktop_dir, packaged_executable + ) + if packaged_executable is not None and ( + rolled_back or verified_executable is None + ): + sys.exit(1) + packaged_executable = verified_executable + # Build succeeded — write the stamp so next run can skip _write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode) diff --git a/tests/hermes_cli/test_desktop_exe_integrity.py b/tests/hermes_cli/test_desktop_exe_integrity.py new file mode 100644 index 000000000000..f060b335b49e --- /dev/null +++ b/tests/hermes_cli/test_desktop_exe_integrity.py @@ -0,0 +1,376 @@ +"""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