diff --git a/hermes_cli/desktop_update_verify.py b/hermes_cli/desktop_update_verify.py index 353fb693cc..a9e88be86b 100644 --- a/hermes_cli/desktop_update_verify.py +++ b/hermes_cli/desktop_update_verify.py @@ -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(" 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") diff --git a/tests/hermes_cli/test_desktop_update_verify.py b/tests/hermes_cli/test_desktop_update_verify.py new file mode 100644 index 0000000000..69590a39bc --- /dev/null +++ b/tests/hermes_cli/test_desktop_update_verify.py @@ -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('', 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''}[damage]) + with pytest.raises((RuntimeError, OSError, ValueError)): + verify.verify_windows_desktop_update(root) diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index de9b6c5d50..18663f49e4 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -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`