193 lines
7.0 KiB
Python
193 lines
7.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Live Windows probes for the fail-closed taskkill process-identity guard.
|
|
|
|
Runs only on real Windows (the on-demand ``wine2e/**`` windows-latest lane).
|
|
These tests spawn REAL processes and drive the REAL guard code against the
|
|
live process table — the coverage the mocked Linux suites cannot provide.
|
|
|
|
Class under test (#98814 / #89614):
|
|
|
|
- ``gateway.status.terminate_pid(force=True)`` requires a matching
|
|
``expected_start_time`` and must refuse (never taskkill) on a missing or
|
|
mismatched identity.
|
|
- ``hermes_cli._subprocess_compat.pid_is_hermes`` fails closed on foreign
|
|
processes and identity mismatches.
|
|
- ``hermes_cli.update_cmd._refuse_gateway_ancestor_tree_kill`` refuses to
|
|
nominate any ancestor of the current process for a tree-kill.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
sys.platform != "win32", reason="live taskkill-identity probes are Windows-only"
|
|
)
|
|
|
|
|
|
def _spawn_sleeper(seconds: int = 60) -> subprocess.Popen:
|
|
return subprocess.Popen(
|
|
[sys.executable, "-c", f"import time; time.sleep({seconds})"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
|
|
|
|
def _cleanup(proc: subprocess.Popen) -> None:
|
|
try:
|
|
proc.kill()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class TestTerminatePidIdentityLive:
|
|
def test_matching_identity_kills_real_process(self):
|
|
from gateway.status import get_process_start_time, terminate_pid
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
start = get_process_start_time(proc.pid)
|
|
assert start is not None, "live process must have a fingerprint"
|
|
terminate_pid(proc.pid, force=True, expected_start_time=start)
|
|
assert proc.wait(timeout=15) is not None
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_missing_expectation_refuses_and_process_survives(self):
|
|
from gateway.status import terminate_pid
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
with pytest.raises(OSError, match="start-time guard"):
|
|
terminate_pid(proc.pid, force=True)
|
|
assert proc.poll() is None, "refusal must leave the process running"
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_mismatched_identity_refuses_and_process_survives(self):
|
|
"""The recycled-PID scenario: recorded identity != live identity."""
|
|
from gateway.status import get_process_start_time, terminate_pid
|
|
|
|
# Simulate recycling: capture the identity of a process that then
|
|
# dies, and respawn a DIFFERENT process. We can't force Windows to
|
|
# hand back the same PID, so assert the guard refuses when the stale
|
|
# fingerprint is presented against the new (different) live process.
|
|
victim = _spawn_sleeper(1)
|
|
stale_start = get_process_start_time(victim.pid)
|
|
victim.wait(timeout=30)
|
|
|
|
impostor = _spawn_sleeper()
|
|
try:
|
|
live_start = get_process_start_time(impostor.pid)
|
|
assert live_start is not None
|
|
if stale_start == live_start:
|
|
pytest.skip("fingerprints collided; cannot express mismatch")
|
|
with pytest.raises(OSError, match="identity"):
|
|
terminate_pid(
|
|
impostor.pid, force=True, expected_start_time=stale_start
|
|
)
|
|
assert impostor.poll() is None, "mismatch must never kill"
|
|
finally:
|
|
_cleanup(impostor)
|
|
|
|
def test_dead_pid_identity_unavailable_refuses(self):
|
|
from gateway.status import get_process_start_time, terminate_pid
|
|
|
|
proc = _spawn_sleeper(1)
|
|
pid = proc.pid
|
|
start = get_process_start_time(pid)
|
|
proc.wait(timeout=30)
|
|
# Give the OS a beat to drop the process object.
|
|
time.sleep(0.5)
|
|
if get_process_start_time(pid) == start:
|
|
pytest.skip("PID instantly recycled onto identical fingerprint")
|
|
with pytest.raises(OSError):
|
|
terminate_pid(pid, force=True, expected_start_time=start)
|
|
|
|
|
|
class TestPidIsHermesLive:
|
|
def test_foreign_real_process_is_refused(self):
|
|
"""A live non-Hermes process (bare python sleeper in a temp-ish argv)
|
|
must never be judged safe for taskkill."""
|
|
from hermes_cli._subprocess_compat import pid_is_hermes
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
# sys.executable in CI lives under a uv/hostedtoolcache path with
|
|
# no 'hermes' token; if the checkout path itself contains one this
|
|
# assertion is environment-dependent, so guard for it.
|
|
if "hermes" in sys.executable.lower():
|
|
pytest.skip("interpreter path names hermes; probe would match")
|
|
assert pid_is_hermes(proc.pid) is False
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_stale_fingerprint_is_refused_even_for_hermes_argv(self):
|
|
from gateway.status import get_process_start_time
|
|
from hermes_cli._subprocess_compat import pid_is_hermes
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
live = get_process_start_time(proc.pid)
|
|
assert live is not None
|
|
assert (
|
|
pid_is_hermes(proc.pid, expected_start_time=live + 12345) is False
|
|
)
|
|
finally:
|
|
_cleanup(proc)
|
|
|
|
def test_nonexistent_pid_is_refused(self):
|
|
from hermes_cli._subprocess_compat import pid_is_hermes
|
|
|
|
assert pid_is_hermes(2**24) is False
|
|
|
|
|
|
class TestAncestorRefusalLive:
|
|
def test_real_parent_chain_is_refused(self, capsys):
|
|
"""Walk the REAL psutil parent chain: every ancestor of this test
|
|
process must be refused as a tree-kill target (#98814)."""
|
|
import psutil
|
|
|
|
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
|
|
from hermes_cli.update_cmd import _refuse_gateway_ancestor_tree_kill
|
|
|
|
ancestors = [os.getpid()]
|
|
parent = psutil.Process(os.getpid()).parent()
|
|
while parent is not None and len(ancestors) < 6:
|
|
ancestors.append(parent.pid)
|
|
parent = parent.parent()
|
|
|
|
for pid in ancestors:
|
|
assert _is_pid_ancestor_of_current_process(pid) is True, pid
|
|
|
|
refused = _refuse_gateway_ancestor_tree_kill(
|
|
ancestors, gateway_mode=False
|
|
)
|
|
assert refused is True
|
|
out = capsys.readouterr().out
|
|
assert "taskkill /T" in out
|
|
assert "separate terminal" in out
|
|
|
|
def test_unrelated_live_process_is_not_refused(self):
|
|
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
|
|
from hermes_cli.update_cmd import _refuse_gateway_ancestor_tree_kill
|
|
|
|
proc = _spawn_sleeper()
|
|
try:
|
|
assert _is_pid_ancestor_of_current_process(proc.pid) is False
|
|
assert (
|
|
_refuse_gateway_ancestor_tree_kill(
|
|
[proc.pid], gateway_mode=False
|
|
)
|
|
is False
|
|
)
|
|
finally:
|
|
_cleanup(proc)
|