Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea718a5d69 |
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user