diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e33ba92c35a6..eebb4771d2c2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5557,17 +5557,63 @@ _PE_MACHINE_NAMES = { } +def _windows_native_machine() -> str: + """The Windows host OS's NATIVE machine architecture, normalized upper. + + ``platform.machine()`` reports the PROCESS architecture, which lies under + emulation: the desktop update chain runs an x64 hermes-setup.exe (and thus + x64 Python) on Windows-on-ARM devices, where ``platform.machine()`` + returns ``AMD64`` even though the OS is ARM64. The #71119 integrity gate + then rejected the CORRECT ARM64 rebuild as an "architecture mismatch" + (#69179 follow-up report). ``IsWow64Process2`` asks the OS for the true + native machine and works from emulated processes; the classic + ``PROCESSOR_ARCHITEW6432`` fallback only covers WOW64 (32-bit processes) + and is NOT set under x64-on-ARM64 emulation, so it cannot replace the API + probe — it is kept only for pre-1511 Windows 10 hosts without the API. + """ + if sys.platform == "win32": + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + process_machine = ctypes.c_ushort(0) + native_machine = ctypes.c_ushort(0) + if kernel32.IsWow64Process2( + kernel32.GetCurrentProcess(), + ctypes.byref(process_machine), + ctypes.byref(native_machine), + ): + name = { + _PE_MACHINE_ARM64: "ARM64", + _PE_MACHINE_AMD64: "AMD64", + _PE_MACHINE_I386: "X86", + }.get(native_machine.value) + if name: + return name + except (OSError, AttributeError): + # No IsWow64Process2 (pre-1511 Windows 10) — fall through to the + # documented WOW64 env vars, then the process architecture. + pass + env_arch = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get( + "PROCESSOR_ARCHITECTURE" + ) + if env_arch: + return env_arch.upper() + import platform as _platform + + return (_platform.machine() or "").upper() + + 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. + brick launch on exotic hosts. Host detection uses the OS-native machine + (see ``_windows_native_machine``), not the process architecture. """ - import platform as _platform - - machine = (_platform.machine() or "").upper() + machine = _windows_native_machine().upper() if machine in ("AMD64", "X86_64", "X64"): return {_PE_MACHINE_AMD64, _PE_MACHINE_I386} if machine in ("ARM64", "AARCH64"): @@ -5645,12 +5691,10 @@ def _desktop_exe_integrity_error(path: Path) -> Optional[str]: 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" + f"{_windows_native_machine()} Windows host" ) return None diff --git a/tests/hermes_cli/test_desktop_exe_integrity.py b/tests/hermes_cli/test_desktop_exe_integrity.py index f060b335b49e..013244da91e8 100644 --- a/tests/hermes_cli/test_desktop_exe_integrity.py +++ b/tests/hermes_cli/test_desktop_exe_integrity.py @@ -107,7 +107,7 @@ def test_parse_pe_machine_rejects_bad_pe_signature(tmp_path): ], ) def test_expected_machines_per_host(host, loadable, not_loadable): - with patch("platform.machine", return_value=host): + with patch("hermes_cli.main._windows_native_machine", return_value=host): expected = cli_main._expected_windows_pe_machines() assert loadable <= expected assert not (not_loadable & expected) @@ -115,17 +115,91 @@ def test_expected_machines_per_host(host, loadable, not_loadable): 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"): + with patch("hermes_cli.main._windows_native_machine", return_value="RISCV64"): expected = cli_main._expected_windows_pe_machines() assert {PE_AMD64, PE_ARM64, PE_I386} <= expected +# ─── _windows_native_machine ──────────────────────────────────────────────── + + +class _FakeKernel32: + """Stands in for kernel32 so the IsWow64Process2 path runs on any CI OS.""" + + def __init__(self, native_machine: int): + self._native = native_machine + + def GetCurrentProcess(self): + return -1 + + def IsWow64Process2(self, _handle, process_ref, native_ref): + process_ref._obj.value = PE_AMD64 # emulated x64 process + native_ref._obj.value = self._native + return 1 + + +def _fake_windll(native_machine: int): + class _WinDLL: + kernel32 = _FakeKernel32(native_machine) + + return _WinDLL() + + +def test_native_machine_reports_os_arch_not_process_arch(monkeypatch): + """The #69179 WoA regression: x64 Python under ARM64 emulation must report + ARM64 (the OS), not AMD64 (the process) — otherwise the integrity gate + rejects the correct ARM64 rebuild.""" + import ctypes + + monkeypatch.setattr(cli_main.sys, "platform", "win32") + with patch.object(ctypes, "windll", _fake_windll(PE_ARM64), create=True), \ + patch("platform.machine", return_value="AMD64"): + assert cli_main._windows_native_machine() == "ARM64" + + +def test_native_machine_env_fallback_without_api(monkeypatch): + """Pre-1511 Windows 10: no IsWow64Process2 → the WOW64 env var wins.""" + monkeypatch.setattr(cli_main.sys, "platform", "win32") + monkeypatch.setenv("PROCESSOR_ARCHITEW6432", "AMD64") + # On non-Windows interpreters ctypes has no `windll`, which is exactly the + # AttributeError shape a missing API produces. + with patch("platform.machine", return_value="x86"): + assert cli_main._windows_native_machine() == "AMD64" + + +def test_native_machine_platform_fallback(monkeypatch): + """No API, no env vars → the historical platform.machine() answer.""" + monkeypatch.setattr(cli_main.sys, "platform", "win32") + monkeypatch.delenv("PROCESSOR_ARCHITEW6432", raising=False) + monkeypatch.delenv("PROCESSOR_ARCHITECTURE", raising=False) + with patch("platform.machine", return_value="AMD64"): + assert cli_main._windows_native_machine() == "AMD64" + + +def test_native_machine_non_windows_uses_platform(monkeypatch): + monkeypatch.setattr(cli_main.sys, "platform", "linux") + with patch("platform.machine", return_value="aarch64"): + assert cli_main._windows_native_machine() == "AARCH64" + + +def test_integrity_gate_accepts_arm64_exe_from_emulated_x64_process(monkeypatch, tmp_path): + """End-to-end shape of the reporter's failure: ARM64 host, x64 updater + process, correctly-built ARM64 Hermes.exe. The gate must pass it.""" + import ctypes + + monkeypatch.setattr(cli_main.sys, "platform", "win32") + exe = make_pe(tmp_path / "Hermes.exe", PE_ARM64) + with patch.object(ctypes, "windll", _fake_windll(PE_ARM64), create=True), \ + patch("platform.machine", return_value="AMD64"): + assert cli_main._desktop_exe_integrity_error(exe) is None + + # ─── _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"): + with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"): assert cli_main._desktop_exe_integrity_error(exe) is None @@ -133,7 +207,7 @@ 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"): + with patch("hermes_cli.main._windows_native_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 @@ -159,7 +233,7 @@ def test_packaged_executable_prefers_host_arch_over_mtime(tmp_path, monkeypatch) os.utime(bad, (bad.stat().st_atime + 1000, bad.stat().st_mtime + 1000)) - with patch("platform.machine", return_value="AMD64"): + with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"): assert cli_main._desktop_packaged_executable(desktop_dir) == good @@ -175,7 +249,7 @@ def test_packaged_executable_falls_back_to_mtime_when_unparseable(tmp_path, monk import os os.utime(b, (b.stat().st_atime + 1000, b.stat().st_mtime + 1000)) - with patch("platform.machine", return_value="AMD64"): + with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"): assert cli_main._desktop_packaged_executable(desktop_dir) == b @@ -194,7 +268,7 @@ def test_rollback_restores_backup_and_keeps_corrupt_copy(tmp_path): 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"): + with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"): restored = cli_main._rollback_desktop_from_backup(exe) assert restored == exe @@ -232,7 +306,7 @@ 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"): + with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"): verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe) assert verified == exe assert rolled_back is False @@ -259,7 +333,7 @@ def test_gate_rolls_back_corrupt_exe_and_purges_cache(tmp_path, monkeypatch, cap stamp.parent.mkdir(parents=True, exist_ok=True) stamp.write_text("{}", encoding="utf-8") - with patch("platform.machine", return_value="AMD64"), \ + with patch("hermes_cli.main._windows_native_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) @@ -336,7 +410,7 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, 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._windows_native_machine", return_value="AMD64"), \ patch("hermes_cli.main.subprocess.run", return_value=pack_ok), \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) @@ -368,7 +442,7 @@ def test_build_only_succeeds_with_valid_exe(tmp_path, monkeypatch, capsys): 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._windows_native_machine", return_value="AMD64"), \ patch("hermes_cli.main.subprocess.run", return_value=pack_ok): cli_main.cmd_gui(_ns())