348 lines
13 KiB
Python
348 lines
13 KiB
Python
"""Regression tests: a failed quarantine restore must never strand `hermes`.
|
|
|
|
On Windows the updater renames the live ``hermes*.exe`` shims aside
|
|
(``hermes.exe.old.<unix-ms>``) so uv can write replacements. Gaps in the
|
|
recovery path ended with ``hermes`` gone from PATH — and, because the command
|
|
that repairs it IS ``hermes update``, unrecoverable without a manual reinstall
|
|
(#75584):
|
|
|
|
1. Restoring a shim got a single attempt whose ``OSError`` was swallowed in
|
|
silence, while the outbound quarantine rename already retried a lock.
|
|
2. The startup sweep unlinked every ``*.exe.old.*``. When the original shim was
|
|
already missing, that .old file was the ONLY surviving copy — deleting it
|
|
converted a one-rename recovery into a full reinstall. It also raced a
|
|
concurrent in-flight update, destroying the quarantine that update's own
|
|
restore was about to rename back.
|
|
|
|
These tests pin the hardened behavior: retry, rescue, report, order by parsed
|
|
stamp, and leave files we did not create alone.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import _early_recovery as er
|
|
from hermes_cli import _install_repair as ir
|
|
from hermes_cli import main as cli_main
|
|
|
|
|
|
def _make_scripts_dir(tmp_path: Path) -> Path:
|
|
scripts = tmp_path / "venv" / "Scripts"
|
|
scripts.mkdir(parents=True)
|
|
return scripts
|
|
|
|
|
|
def _stamp(ms_ago: int = 0) -> int:
|
|
return int(time.time() * 1000) - ms_ago
|
|
|
|
|
|
def _run_cleanup(scripts: Path):
|
|
"""Drive the sweep with the Windows gate forced and the registry stubbed.
|
|
|
|
``_cleanup_pending_shim_renames`` reaches into PendingFileRenameOperations;
|
|
it has its own tests and must not run here.
|
|
"""
|
|
return patch.multiple(
|
|
cli_main,
|
|
_is_windows=lambda: True,
|
|
_cleanup_pending_shim_renames=lambda _scripts_dir: 0,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# orphan rescue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cleanup_rescues_orphan_when_original_missing(tmp_path):
|
|
"""The .old file is the last copy of the shim — put it back, don't delete."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
orphan = scripts / f"hermes.exe.old.{_stamp()}"
|
|
orphan.write_bytes(b"MZ-orphan")
|
|
|
|
with _run_cleanup(scripts):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert (scripts / "hermes.exe").read_bytes() == b"MZ-orphan"
|
|
assert not orphan.exists()
|
|
|
|
|
|
def test_cleanup_rescue_survives_a_transient_lock(tmp_path, capsys):
|
|
"""The rescue rename retries a lock instead of stranding on first failure.
|
|
|
|
This is the window the sweep runs in: the shim is ALREADY gone from PATH, so
|
|
giving up here leaves the user stranded exactly as if the sweep had deleted
|
|
the file.
|
|
"""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
orphan = scripts / f"hermes.exe.old.{_stamp()}"
|
|
orphan.write_bytes(b"MZ-orphan")
|
|
|
|
real_rename = os.rename
|
|
calls = {"n": 0}
|
|
|
|
def flaky(src, dst):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise PermissionError(32, "being used by another process")
|
|
return real_rename(src, dst)
|
|
|
|
with _run_cleanup(scripts), patch.object(er.os, "rename", flaky):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert (scripts / "hermes.exe").read_bytes() == b"MZ-orphan"
|
|
assert calls["n"] >= 2, "rescue must retry after a transient lock"
|
|
assert capsys.readouterr().err == "", "a recovered rescue must stay quiet"
|
|
|
|
|
|
def test_cleanup_rescue_reports_when_it_cannot_recover(tmp_path, capsys):
|
|
"""A rescue that exhausts its retries must say so, not fail silently."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
orphan = scripts / f"hermes.exe.old.{_stamp()}"
|
|
orphan.write_bytes(b"MZ-orphan")
|
|
|
|
def always_locked(src, dst):
|
|
raise PermissionError(32, "being used by another process")
|
|
|
|
with _run_cleanup(scripts), patch.object(er.os, "rename", always_locked):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "FAILED to restore hermes.exe" in captured.err
|
|
assert "move" in captured.err, "must print the literal recovery command"
|
|
assert captured.out == "", "stdout must stay clean for JSON-RPC"
|
|
assert orphan.exists(), "the last copy must survive a failed rescue"
|
|
|
|
|
|
def test_cleanup_rescue_is_quiet_when_another_process_wins(tmp_path, capsys):
|
|
"""Two sweeps, one orphan: the loser must no-op cleanly, not report failure."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
orphan = scripts / f"hermes.exe.old.{_stamp()}"
|
|
orphan.write_bytes(b"MZ-orphan")
|
|
original = scripts / "hermes.exe"
|
|
|
|
def loses_race(src, dst):
|
|
# The "winner" lands the shim while our attempt is in flight.
|
|
original.write_bytes(b"MZ-from-winner")
|
|
raise PermissionError(32, "being used by another process")
|
|
|
|
with _run_cleanup(scripts), patch.object(er.os, "rename", loses_race):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
captured = capsys.readouterr()
|
|
assert original.read_bytes() == b"MZ-from-winner"
|
|
assert captured.err == "", "losing a benign race is not a failure"
|
|
assert captured.out == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ordering and provenance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cleanup_rescues_newest_by_parsed_stamp_not_lexicographic(tmp_path):
|
|
"""Mixed-width stamps: ordering must follow the parsed integer.
|
|
|
|
``sorted(reverse=True)`` over raw filenames puts ``.old.999`` above a
|
|
13-digit epoch-ms stamp, which would rescue the wrong bytes onto the live
|
|
shim name.
|
|
"""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
(scripts / "hermes.exe.old.999").write_bytes(b"MZ-stray-short-stamp")
|
|
(scripts / f"hermes.exe.old.{_stamp(60_000)}").write_bytes(b"MZ-genuine")
|
|
|
|
with _run_cleanup(scripts):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert (scripts / "hermes.exe").read_bytes() == b"MZ-genuine"
|
|
|
|
|
|
def test_cleanup_ignores_names_it_did_not_create(tmp_path):
|
|
"""An unparseable suffix is not ours: never rescued, never deleted."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
(scripts / "hermes.exe").write_bytes(b"MZ-live")
|
|
foreign = scripts / "hermes.exe.old.backup"
|
|
foreign.write_bytes(b"MZ-someone-elses-file")
|
|
|
|
with _run_cleanup(scripts):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert foreign.exists(), "the sweep must not delete files of unknown provenance"
|
|
assert foreign.read_bytes() == b"MZ-someone-elses-file"
|
|
assert (scripts / "hermes.exe").read_bytes() == b"MZ-live"
|
|
|
|
|
|
def test_cleanup_does_not_rescue_from_a_foreign_name(tmp_path):
|
|
"""Missing shim + only a foreign .old: leave it be rather than guess."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
foreign = scripts / "hermes.exe.old.backup"
|
|
foreign.write_bytes(b"MZ-someone-elses-file")
|
|
|
|
with _run_cleanup(scripts):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert not (scripts / "hermes.exe").exists()
|
|
assert foreign.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# concurrency grace window
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cleanup_leaves_fresh_quarantine_for_concurrent_update(tmp_path):
|
|
"""A young .old may belong to an update in flight elsewhere — hands off."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
(scripts / "hermes.exe").write_bytes(b"MZ-live")
|
|
fresh = scripts / f"hermes.exe.old.{_stamp()}"
|
|
fresh.write_bytes(b"MZ-inflight")
|
|
|
|
with _run_cleanup(scripts):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert fresh.exists(), "a live quarantine must survive another process's sweep"
|
|
|
|
|
|
def test_cleanup_still_sweeps_genuinely_stale_quarantine(tmp_path):
|
|
"""Past the grace window, with the shim present, it's garbage — sweep it."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
(scripts / "hermes.exe").write_bytes(b"MZ-live")
|
|
ancient_ms = (cli_main._QUARANTINE_GRACE_SECONDS + 60) * 1000
|
|
stale = scripts / f"hermes.exe.old.{_stamp(ancient_ms)}"
|
|
stale.write_bytes(b"MZ-stale")
|
|
|
|
with _run_cleanup(scripts):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert not stale.exists()
|
|
assert (scripts / "hermes.exe").read_bytes() == b"MZ-live"
|
|
|
|
|
|
def test_cleanup_age_comes_from_filename_not_mtime(tmp_path):
|
|
"""rename() preserves mtime, so only the name records the quarantine time."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
(scripts / "hermes.exe").write_bytes(b"MZ-live")
|
|
fresh = scripts / f"hermes.exe.old.{_stamp()}"
|
|
fresh.write_bytes(b"MZ-inflight")
|
|
week_ago = time.time() - 7 * 24 * 3600
|
|
os.utime(fresh, (week_ago, week_ago))
|
|
|
|
with _run_cleanup(scripts):
|
|
cli_main._cleanup_quarantined_exes(scripts)
|
|
|
|
assert fresh.exists(), "grace window must key off the .old.<ms> stamp"
|
|
|
|
|
|
def test_quarantine_stamp_ms_parses_and_rejects():
|
|
assert cli_main._quarantine_stamp_ms(Path("hermes.exe.old.1787020473885")) == 1787020473885
|
|
assert cli_main._quarantine_stamp_ms(Path("hermes.exe.old.backup")) is None
|
|
assert cli_main._quarantine_stamp_ms(Path("hermes.exe")) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the shared restore helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_helper_retries_then_succeeds(tmp_path):
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
quarantined = scripts / "hermes.exe.old.123"
|
|
quarantined.write_bytes(b"MZ-old-hermes")
|
|
original = scripts / "hermes.exe"
|
|
|
|
real_rename = os.rename
|
|
calls = {"n": 0}
|
|
|
|
def flaky(src, dst):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise PermissionError(32, "being used by another process")
|
|
return real_rename(src, dst)
|
|
|
|
with patch.object(er.os, "rename", flaky):
|
|
failed = er.restore_quarantined_shims([(original, quarantined)])
|
|
|
|
assert failed == []
|
|
assert original.read_bytes() == b"MZ-old-hermes"
|
|
assert calls["n"] >= 2
|
|
|
|
|
|
def test_helper_reports_failure_and_returns_the_pair(tmp_path, capsys):
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
quarantined = scripts / "hermes.exe.old.123"
|
|
quarantined.write_bytes(b"MZ-old-hermes")
|
|
original = scripts / "hermes.exe"
|
|
|
|
def always_locked(src, dst):
|
|
raise PermissionError(32, "being used by another process")
|
|
|
|
with patch.object(er.os, "rename", always_locked):
|
|
failed = er.restore_quarantined_shims([(original, quarantined)])
|
|
|
|
captured = capsys.readouterr()
|
|
assert failed == [(original, quarantined)]
|
|
assert "FAILED to restore hermes.exe" in captured.err
|
|
assert "hermes.exe.old.123" in captured.err
|
|
assert "move" in captured.err
|
|
assert captured.out == ""
|
|
|
|
|
|
def test_helper_is_a_noop_when_installer_wrote_a_fresh_shim(tmp_path, capsys):
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
quarantined = scripts / "hermes.exe.old.123"
|
|
quarantined.write_bytes(b"MZ-old")
|
|
original = scripts / "hermes.exe"
|
|
original.write_bytes(b"MZ-fresh")
|
|
|
|
failed = er.restore_quarantined_shims([(original, quarantined)])
|
|
|
|
assert failed == []
|
|
assert original.read_bytes() == b"MZ-fresh", "must not clobber the fresh shim"
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# both call sites route through the helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_main_restore_reports_on_stderr(tmp_path, capsys):
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
quarantined = scripts / "hermes.exe.old.123"
|
|
quarantined.write_bytes(b"MZ-old-hermes")
|
|
original = scripts / "hermes.exe"
|
|
|
|
def always_locked(src, dst):
|
|
raise PermissionError(32, "being used by another process")
|
|
|
|
with patch.object(er.os, "rename", always_locked):
|
|
cli_main._restore_quarantined_exes([(original, quarantined)])
|
|
|
|
captured = capsys.readouterr()
|
|
assert "FAILED to restore hermes.exe" in captured.err
|
|
assert captured.out == ""
|
|
|
|
|
|
def test_repair_restore_reports_on_stderr(tmp_path, capsys):
|
|
"""The early-recovery path must warn on stderr (acp speaks JSON-RPC on stdout)."""
|
|
scripts = _make_scripts_dir(tmp_path)
|
|
quarantined = scripts / "hermes.exe.old.123"
|
|
quarantined.write_bytes(b"MZ-old-hermes")
|
|
original = scripts / "hermes.exe"
|
|
|
|
def always_locked(src, dst):
|
|
raise PermissionError(32, "being used by another process")
|
|
|
|
with patch.object(er.os, "rename", always_locked):
|
|
ir._restore_quarantined_exes([(original, quarantined)])
|
|
|
|
captured = capsys.readouterr()
|
|
assert "FAILED to restore hermes.exe" in captured.err
|
|
assert captured.out == "", "stdout must stay clean for JSON-RPC"
|