fix(desktop): validate packaged archive and renderer before Windows success

This commit is contained in:
Teknium
2026-09-07 01:59:34 -07:00
parent 6caf37b6bd
commit f4fa6bf2dc
3 changed files with 118 additions and 7 deletions
+64 -6
View File
@@ -1,15 +1,76 @@
"""Read-only verification at the Windows Desktop handoff receipt boundary."""
from pathlib import Path
import json
from pathlib import Path, PurePosixPath
import re
import struct
from hermes_cli.main_desktop import (
_HTML_TAG_WITH_URL,
_MODULE_TAG,
_desktop_build_needed,
_desktop_exe_integrity_error,
_desktop_packaged_executable,
)
def _verify_packaged_entry(resources: Path) -> None:
"""Read ASAR's Pickle header and the entry declared by packaged package.json.
dist/** is unpacked by electron-builder. No Node install or application
launch is needed at this boundary; this is not a full dependency audit.
"""
archive = resources / "app.asar"
try:
with archive.open("rb") as stream:
size, header_size, payload_size, json_size = struct.unpack("<4I", stream.read(16))
if (size != 4 or header_size != payload_size + 4
or payload_size != 4 + ((json_size + 3) // 4) * 4
or not 0 < json_size <= 64 * 1024 * 1024
or 8 + header_size > archive.stat().st_size):
raise ValueError("invalid ASAR header")
header = json.loads(stream.read(json_size))
def read_member(name: str) -> bytes:
path = PurePosixPath(name)
if not name or path.is_absolute() or ".." in path.parts or "\\" in name or ":" in name:
raise ValueError("invalid ASAR entry path")
node = header
for part in path.parts:
node = node["files"][part]
length = node["size"]
if not isinstance(length, int) or length <= 0:
raise ValueError(f"empty ASAR entry: {name}")
if node.get("unpacked"):
data = (resources / "app.asar.unpacked" / path).read_bytes()
else:
offset = int(node["offset"])
if offset < 0 or 8 + header_size + offset + length > archive.stat().st_size:
raise ValueError(f"truncated ASAR entry: {name}")
stream.seek(8 + header_size + offset)
data = stream.read(length)
if len(data) != length or not data.strip():
raise ValueError(f"incomplete ASAR entry: {name}")
return data
package = json.loads(read_member("package.json"))
read_member(package["main"]).decode("utf-8")
except (OSError, ValueError, KeyError, TypeError, struct.error) as exc:
raise RuntimeError(f"The updated Desktop archive or main entry is invalid: {exc}") from exc
index = resources / "app.asar.unpacked" / "dist" / "index.html"
try:
html = index.read_text(encoding="utf-8")
if not any(_MODULE_TAG.search(match.group(0))
and match.group(0).lower().startswith("<script")
and not re.match(r"^[a-z]+:|^//", match.group(1), re.IGNORECASE)
for match in _HTML_TAG_WITH_URL.finditer(html)):
raise ValueError("renderer has no local module entry")
except (OSError, ValueError) as exc:
raise RuntimeError(f"The updated Desktop renderer entry is invalid: {exc}") from exc
def verify_windows_desktop_update(project_root: Path) -> None:
"""Raise when a zero-exit updater left an unusable or stale packaged app."""
"""Raise when a zero-exit updater left an incomplete or stale packaged app."""
desktop = project_root / "apps" / "desktop"
executable = _desktop_packaged_executable(desktop)
if executable is None:
@@ -17,9 +78,6 @@ def verify_windows_desktop_update(project_root: Path) -> None:
error = _desktop_exe_integrity_error(executable)
if error:
raise RuntimeError(f"The updated Desktop executable is invalid: {error}")
resources = executable.parent / "resources"
for required in (resources / "app.asar", resources / "app.asar.unpacked" / "dist" / "index.html"):
if not required.is_file():
raise RuntimeError(f"The updated Desktop bundle is incomplete: {required}")
_verify_packaged_entry(executable.parent / "resources")
if _desktop_build_needed(desktop, project_root, source_mode=False):
raise RuntimeError("The updated Desktop build is stale, unstamped, or incomplete")
@@ -0,0 +1,53 @@
"""Receipt validation uses packaged output, not just a source stamp."""
import json
import struct
import pytest
from hermes_cli import desktop_update_verify as verify
from hermes_cli.main_desktop import _write_desktop_build_stamp
@pytest.fixture
def bundle(tmp_path, monkeypatch):
desktop = tmp_path / 'apps/desktop'
resources = desktop / 'release/fixture/resources'
dist = resources / 'app.asar.unpacked/dist'
(dist / 'assets').mkdir(parents=True)
(dist / 'index.html').write_text('<script type="module" src="./assets/index.js"></script>', encoding='utf-8')
(dist / 'assets/index.js').write_text('export {};', encoding='utf-8')
entry = b'import "electron";'
(dist / 'electron-main.mjs').write_bytes(entry)
package = json.dumps({'main': 'dist/electron-main.mjs'}).encode()
header = json.dumps({'files': {'package.json': {'size': len(package), 'offset': '0'}, 'dist': {'files': {'electron-main.mjs': {'size': len(entry), 'unpacked': True}}}}}).encode()
padded = header + b'\0' * (-len(header) % 4)
archive = resources / 'app.asar'
archive.write_bytes(struct.pack('<4I', 4, 8 + len(padded), 4 + len(padded), len(header)) + padded + package)
(tmp_path / '.gitignore').write_text('apps/desktop/release/\n', encoding='utf-8')
monkeypatch.setattr(verify, '_desktop_packaged_executable', lambda _: resources.parent / 'Hermes.exe')
monkeypatch.setattr(verify, '_desktop_exe_integrity_error', lambda _: None)
# Host-independent artifact contract; executable lookup itself is covered natively.
from hermes_cli import main_desktop
monkeypatch.setattr(main_desktop, '_desktop_packaged_executable', lambda _: resources.parent / 'Hermes.exe')
_write_desktop_build_stamp(tmp_path, source_mode=False)
return tmp_path, archive, dist
def test_readable_packaged_entry_passes(bundle):
root, _, _ = bundle
verify.verify_windows_desktop_update(root)
@pytest.mark.parametrize('damage', ['archive', 'truncated', 'entry', 'empty-index', 'unreadable-index', 'no-module'])
def test_current_stamp_does_not_hide_damaged_output(bundle, damage):
root, archive, dist = bundle
if damage == 'archive':
archive.write_bytes(b'not an asar')
elif damage == 'truncated':
archive.write_bytes(archive.read_bytes()[:-4])
elif damage == 'entry':
(dist / 'electron-main.mjs').write_bytes(b'')
else:
(dist / 'index.html').write_bytes({'empty-index': b'', 'unreadable-index': b'\xff', 'no-module': b'<html></html>'}[damage])
with pytest.raises((RuntimeError, OSError, ValueError)):
verify.verify_windows_desktop_update(root)
+1 -1
View File
@@ -34,7 +34,7 @@ When you run `hermes update`, the following steps occur:
### Missing Windows updater files
If the maintained updater script is missing (for example after antivirus quarantine), the legacy update forwarder fails instead of reporting a successful hand-off. Repair the installation and review the security software's quarantine report before retrying; do not disable antivirus protection. The maintained updater checks that the updated Python runtime can import the CLI and that the packaged Desktop has a valid Windows executable, its app archive and renderer entry files, and a current build stamp before reporting success. Missing prerequisites are reported before waiting for Desktop shutdown; dependency repair is still allowed to run as part of the update.
If the maintained updater script is missing (for example after antivirus quarantine), the legacy update forwarder fails instead of reporting a successful hand-off. Repair the installation and review the security software's quarantine report before retrying; do not disable antivirus protection. Before reporting success, the maintained updater checks the CLI import, Windows executable header, ASAR header and packaged main entry, readable renderer HTML with a local module entry, initial module files, and current build stamp. These are minimum artifact checks, not a full dependency audit or an application/backend launch test. Missing Python is reported before waiting for Desktop shutdown; dependency repair is still allowed to run as part of the update. Electron checks maintained handoff prerequisites before stopping backends when that layout is present; genuine legacy-flat updater layouts remain supported, so not every missing updater file is detected before backend shutdown.
### Updating against a non-default branch: `--branch`