161 lines
5.7 KiB
Python
161 lines
5.7 KiB
Python
"""The live-DB guard must survive a scrubbed child environment (#82770).
|
|
|
|
Forensic background: a read-only sweep of production ``state.db`` files found
|
|
hundreds of zero-message "open" gateway session rows carrying test-fixture
|
|
identities (``chat-1`` / ``user-1`` / ``wx-chat``), with matching
|
|
``gateway_routing`` scopes pointing at ``pytest-of-*`` temp directories.
|
|
|
|
The escape is structural, not a one-off test bug. Hermetic isolation rides
|
|
entirely on the process environment: ``HERMES_HOME`` says *where* to write and
|
|
``PYTEST_CURRENT_TEST`` / ``PYTEST_VERSION`` say *whether the guard is armed*.
|
|
Both live in the same carrier, so a child spawned with a rebuilt environment
|
|
loses them together — it aims at the developer's real ``state.db`` and
|
|
silences the only check that would have stopped it, in one step.
|
|
|
|
Process ancestry is the signal that survives an env rebuild, so these tests
|
|
pin that the guard is armed by ancestry when the environment no longer says
|
|
"pytest".
|
|
|
|
These tests drive ``_ensure_test_isolation`` rather than constructing a real
|
|
``SessionDB``: if the guard regresses, the assertion must fail *without* the
|
|
test itself writing to the developer's live database.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_state
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# Probe run in the child: resolve the REAL platform state root (not a
|
|
# hardcoded ~/.hermes — that root is %LOCALAPPDATA%\hermes on Windows) and
|
|
# report whether the guard refuses it.
|
|
_CHILD_PROBE = """
|
|
import sys
|
|
sys.path.insert(0, {repo!r})
|
|
import hermes_state
|
|
|
|
root = hermes_state._real_platform_state_root()
|
|
if root is None:
|
|
print("NO-ROOT")
|
|
else:
|
|
try:
|
|
hermes_state._ensure_test_isolation(root / "state.db")
|
|
except RuntimeError:
|
|
print("REFUSED")
|
|
else:
|
|
print("ALLOWED")
|
|
"""
|
|
|
|
|
|
def _scrubbed_env(**overrides):
|
|
"""The environment a rebuilt-from-scratch child spawn ends up with.
|
|
|
|
Also strips ``HERMES_TEST_ISOLATION`` — the conftest-exported marker
|
|
layer would otherwise arm the guard first and these tests would no
|
|
longer prove anything about the ancestry fallback they exist to pin.
|
|
"""
|
|
env = {
|
|
k: v
|
|
for k, v in os.environ.items()
|
|
if not k.startswith("PYTEST_")
|
|
and k not in ("HERMES_HOME", "HERMES_TEST_ISOLATION")
|
|
}
|
|
env.update(overrides)
|
|
return env
|
|
|
|
|
|
def _run_probe(env):
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", _CHILD_PROBE.format(repo=str(REPO_ROOT))],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(REPO_ROOT),
|
|
timeout=120,
|
|
)
|
|
verdict = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
|
|
if verdict == "NO-ROOT":
|
|
pytest.skip("no real platform state root resolvable on this machine")
|
|
assert verdict in ("REFUSED", "ALLOWED"), (
|
|
f"probe produced no verdict.\nstdout={result.stdout!r}\n"
|
|
f"stderr={result.stderr!r}"
|
|
)
|
|
return verdict
|
|
|
|
|
|
class TestScrubbedChildEnvironment:
|
|
def test_child_without_pytest_env_still_refuses_production_db(self):
|
|
"""The #82770 escape: no PYTEST_* and no HERMES_HOME, yet still a test.
|
|
|
|
This is the exact shape of the leak — the child resolves the real
|
|
``state.db`` because ``HERMES_HOME`` is gone, and the env-only guard
|
|
sees a "normal user run" because ``PYTEST_*`` is gone with it.
|
|
"""
|
|
assert _run_probe(_scrubbed_env()) == "REFUSED"
|
|
|
|
def test_child_inheriting_pytest_env_still_refuses_production_db(self):
|
|
"""The pre-existing env path must keep working unchanged."""
|
|
env = dict(os.environ)
|
|
env.pop("HERMES_HOME", None)
|
|
env.setdefault("PYTEST_CURRENT_TEST", "tests/x.py::test_x (call)")
|
|
assert _run_probe(env) == "REFUSED"
|
|
|
|
def test_env_bypass_lets_a_deliberate_child_through(self):
|
|
"""A test that genuinely needs the live DB in a child can opt out.
|
|
|
|
``_STATE_DB_GUARD_BYPASS`` is a module global and cannot cross a
|
|
process boundary, so ancestry-armed children need an env-carried
|
|
escape hatch or they would have no way to opt out at all.
|
|
"""
|
|
env = _scrubbed_env(**{hermes_state._STATE_DB_GUARD_BYPASS_ENV: "1"})
|
|
assert _run_probe(env) == "ALLOWED"
|
|
|
|
|
|
class TestPytestProcessRecognition:
|
|
"""Unit-level checks for the ancestry predicate's matching rules."""
|
|
|
|
class _FakeProc:
|
|
def __init__(self, cmdline):
|
|
self._cmdline = cmdline
|
|
|
|
def cmdline(self):
|
|
return self._cmdline
|
|
|
|
@pytest.mark.parametrize(
|
|
"cmdline",
|
|
[
|
|
["/usr/bin/python", "-m", "pytest", "tests/"],
|
|
["/venv/bin/pytest", "-q"],
|
|
[r"C:\venv\Scripts\pytest.exe", "-q"],
|
|
["/usr/bin/py.test", "tests/"],
|
|
],
|
|
)
|
|
def test_recognises_pytest_invocations(self, cmdline):
|
|
assert hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
|
|
|
|
@pytest.mark.parametrize(
|
|
"cmdline",
|
|
[
|
|
["hermes", "gateway", "start"],
|
|
["/usr/bin/python", "-m", "hermes_cli.main", "sessions", "list"],
|
|
# A path that merely *contains* "pytest" is not a pytest process:
|
|
# tmp paths like /tmp/pytest-of-dev/... show up in real argv.
|
|
["hermes", "run", "--file", "/tmp/pytest-of-dev/test0/input.txt"],
|
|
],
|
|
)
|
|
def test_ignores_non_pytest_invocations(self, cmdline):
|
|
assert not hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
|
|
|
|
def test_unreadable_process_is_not_pytest(self):
|
|
class _Denied:
|
|
def cmdline(self):
|
|
raise PermissionError("access denied")
|
|
|
|
assert not hermes_state._process_looks_like_pytest(_Denied())
|