fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179)

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 <appOutDir>.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
This commit is contained in:
teknium1 2026-07-24 17:04:53 -07:00 committed by Teknium
parent 9ac8b24fd5
commit 199f558058
4 changed files with 759 additions and 3 deletions

View file

@ -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 `<appOutDir>.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) {

View file

@ -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 })
}
})

View file

@ -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("<I", head, 0x3C)[0]
if e_lfanew <= 0 or e_lfanew + 24 > 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("<HH", pe_head, 4)
size_of_optional = struct.unpack_from("<H", pe_head, 20)[0]
fh.seek(e_lfanew + 24 + size_of_optional)
max_section_end = 0
for _ in range(n_sections):
section = fh.read(40)
if len(section) < 40:
raise ValueError("truncated PE section table")
size_of_raw, pointer_to_raw = struct.unpack_from("<II", section, 16)
max_section_end = max(max_section_end, pointer_to_raw + size_of_raw)
if file_size < max_section_end:
raise ValueError(
f"truncated executable: file is {file_size} bytes but its PE "
f"sections extend to {max_section_end} bytes"
)
return machine
def _pe_machine_or_none(path: Path) -> 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: ``<unpacked-dir>.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 ``<unpacked-dir>.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)

View file

@ -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("<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 ──────────────────────────────────────────────────────
def test_parse_pe_machine_reads_machine_field(tmp_path):
exe = make_pe(tmp_path / "Hermes.exe", PE_AMD64)
assert cli_main._parse_pe_machine(exe) == PE_AMD64
exe_arm = make_pe(tmp_path / "arm.exe", PE_ARM64)
assert cli_main._parse_pe_machine(exe_arm) == PE_ARM64
def test_parse_pe_machine_rejects_non_pe_file(tmp_path):
"""An HTML error page saved as .exe — the classic corrupted-download body."""
fake = tmp_path / "Hermes.exe"
fake.write_bytes(b"<html><body>404 Not Found</body></html>" + 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"<html>proxy error</html>" + 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