fix(desktop): validate packaged archive and renderer before Windows success
This commit is contained in:
@@ -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)
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user