Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5861,7 +5861,7 @@ def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
return max(existing, key=lambda p: p.stat().st_mtime)


# ─── Desktop exe integrity gate (#69179) ────────────────────────────────────
# ─── Desktop package integrity gate (#69179 / #70825) ─────────────────────
#
# The desktop self-update chain (Desktop → hermes-setup --update →
# `hermes update` → `hermes desktop --build-only` → relaunch) rebuilds
Expand Down Expand Up @@ -6114,6 +6114,49 @@ def _desktop_exe_integrity_error(path: Path) -> Optional[str]:
return None


def _desktop_payload_integrity_error(packaged_executable: Path) -> Optional[str]:
"""Return why the packaged Electron app payload cannot boot, if any.

A valid PE alone is insufficient: Electron shows its own help text when the
adjacent app archive is empty or lacks the package entry point. The Windows
package config unpacks ``dist/**``, so both the archive metadata and the two
runtime entry files are stable, cheap post-build invariants.
"""
resources = packaged_executable.parent / "resources"
app_asar = resources / "app.asar"
try:
asar_size = app_asar.stat().st_size
except OSError as exc:
return f"missing or unreadable resources/app.asar: {exc}"
if asar_size < 4096:
return f"resources/app.asar is only {asar_size} bytes — packaged app payload is empty"
try:
with app_asar.open("rb") as fh:
header = fh.read(min(asar_size, 2 * 1024 * 1024))
except OSError as exc:
return f"could not inspect resources/app.asar: {exc}"
for entry in (b"package.json", b"electron-main.mjs"):
if entry not in header:
return f"resources/app.asar is missing required entry metadata: {entry.decode()}"

required_unpacked = (
resources / "app.asar.unpacked" / "dist" / "electron-main.mjs",
resources / "app.asar.unpacked" / "dist" / "index.html",
)
for runtime_file in required_unpacked:
try:
if runtime_file.stat().st_size <= 0:
return f"packaged runtime file is empty: {runtime_file}"
except OSError:
return f"packaged runtime file is missing: {runtime_file}"
return None


def _desktop_packaged_app_integrity_error(path: Path) -> Optional[str]:
"""Validate both the Windows executable and its Electron app payload."""
return _desktop_exe_integrity_error(path) or _desktop_payload_integrity_error(path)


def _desktop_backup_unpacked_dir(packaged_executable: Path) -> Path:
"""The rollback tree before-pack.mjs preserves: ``<unpacked-dir>.bak``."""
unpacked = packaged_executable.parent
Expand All @@ -6133,7 +6176,7 @@ def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
backup_exe = backup_dir / packaged_executable.name
if not backup_exe.exists():
return None
if _desktop_exe_integrity_error(backup_exe) is not None:
if _desktop_packaged_app_integrity_error(backup_exe) is not None:
return None
corrupt_dir = unpacked.parent / (unpacked.name + ".corrupt")
try:
Expand All @@ -6152,13 +6195,14 @@ def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
def _ensure_desktop_exe_launchable(
desktop_dir: Path, packaged_executable: Optional[Path]
) -> tuple:
"""Windows post-build integrity gate for the self-update rebuild (#69179).
"""Windows post-build integrity gate for the self-update rebuild.

Returns ``(verified_exe_or_None, rolled_back)``:
Returns ``(verified_exe_or_None, rolled_back)`` after validating both the
PE executable and its Electron app payload:

- exe passed the probe → ``(exe, False)``
- exe corrupt/wrong-arch, previous build restored → ``(old_exe, True)``
- exe corrupt and nothing restorable → ``(None, False)``
- package passed the probes → ``(exe, False)``
- package invalid, previous build restored → ``(old_exe, True)``
- package invalid 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
Expand All @@ -6168,7 +6212,7 @@ def _ensure_desktop_exe_launchable(
if packaged_executable is None or sys.platform != "win32":
return packaged_executable, False

error = _desktop_exe_integrity_error(packaged_executable)
error = _desktop_packaged_app_integrity_error(packaged_executable)
if error is None:
return packaged_executable, False

Expand Down
30 changes: 28 additions & 2 deletions tests/hermes_cli/test_desktop_exe_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ def make_pe(path: Path, machine: int = PE_AMD64, *, truncate_to: int | None = No
return path


def make_desktop_payload(exe: Path) -> None:
"""Create the required packaged app payload beside a synthetic Hermes.exe."""
resources = exe.parent / "resources"
dist = resources / "app.asar.unpacked" / "dist"
dist.mkdir(parents=True, exist_ok=True)
(dist / "electron-main.mjs").write_text("export default {}\n" * 80, encoding="utf-8")
(dist / "index.html").write_text("<html><body>Hermes</body></html>\n", encoding="utf-8")
header = b'{"files":{"package.json":{},"dist":{"files":{"electron-main.mjs":{}}}}}'
(resources / "app.asar").write_bytes(header + b"\0" * 8192)


# ─── _parse_pe_machine ──────────────────────────────────────────────────────


Expand Down Expand Up @@ -203,6 +214,7 @@ def test_rollback_restores_backup_and_keeps_corrupt_copy(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
make_desktop_payload(backup_exe)

with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"):
restored = cli_main._rollback_desktop_from_backup(exe)
Expand All @@ -221,8 +233,21 @@ def test_rollback_restores_backup_and_keeps_corrupt_copy(tmp_path):
# ─── _ensure_desktop_exe_launchable (the gate) ──────────────────────────────


def test_gate_rejects_exe_with_empty_app_archive(tmp_path, monkeypatch):
monkeypatch.setattr(cli_main.sys, "platform", "win32")
desktop_dir, exe = _win_tree(tmp_path)
make_pe(exe, PE_AMD64)
resources = exe.parent / "resources"
resources.mkdir(parents=True)
(resources / "app.asar").write_bytes(b"empty")

with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"), \
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


def test_gate_fails_clearly_without_backup(tmp_path, monkeypatch, capsys):
Expand Down Expand Up @@ -274,7 +299,9 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch,

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)
backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe"
make_pe(backup_exe, PE_AMD64)
make_desktop_payload(backup_exe)

install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
Expand All @@ -299,4 +326,3 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch,
out = capsys.readouterr().out
assert "integrity check" in out


Loading