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