Files
hermes-agent/tests/home_io_guard.py
kshitijk4poor 2b98a469d3 refactor(tests): resolve a guarded path once; tripwire test runs without symlinks
The installed-app check now hands its resolved path to check() instead of
both resolving it, and uses the captured app paths as already normalized.
The symlink case of the tripwire test is added only where the host can
create symlinks, so Windows without Developer Mode still runs the rest.
2026-09-29 02:40:55 +05:30

211 lines
10 KiB
Python

"""Guard Python filesystem calls in tests, not arbitrary native/subprocess I/O."""
from __future__ import annotations
import builtins
from functools import lru_cache, wraps
import io
import os
from pathlib import Path
import shutil
import sqlite3
import sys
import threading
_INTERPRETER_PREFIXES = tuple({
Path(p).resolve() for p in (sys.prefix, sys.base_prefix, sys.exec_prefix, sys.base_exec_prefix)
} | {
# A PM-activated developer shell runs sys.prefix's python against a dependency generation
# whose site-packages sits under the (real) Hermes home; third-party imports from it are the
# interpreter's installation, not Hermes state.
Path(p).resolve() for p in sys.path if p and Path(p).name in ("site-packages", "dist-packages")
} | {
# The default install checks the repo out INSIDE the home (install.sh:
# INSTALL_DIR=$HERMES_HOME/hermes-agent). Reading test data, sources for tracebacks, or the
# checkout's own .venv is not Hermes state; without this every run from a default install
# trips on its first traceback.
Path(__file__).resolve().parent.parent,
})
# The same prefixes as plain strings for the check() fast path. PurePath comparison folds case on
# Windows; ``os.path.normcase`` (identity on POSIX) reproduces that for string compares. Prefixes
# resolve once at import, as before: they are fixed for the process lifetime.
_normcase = os.path.normcase
_INTERPRETER_PREFIX_STRS = tuple(_normcase(os.fspath(p)) for p in _INTERPRETER_PREFIXES)
def _within(path: str, prefix: str) -> bool:
"""``Path(path).is_relative_to(prefix)`` for two normalized, case-folded absolute strings."""
if path == prefix:
return True
return path.startswith(prefix) if prefix == os.sep else path.startswith(prefix + os.sep)
def _contains(path: str, prefix: str) -> bool:
"""``Path(prefix).is_relative_to(path)``: *path* is *prefix* or one of its ancestors."""
if path == prefix:
return True
return prefix.startswith(path) if path == os.sep else prefix.startswith(path + os.sep)
class HomeIOGuard:
def __init__(self, roots, installed_apps=lambda: ()):
self.roots = roots
# The machine's installed desktop app (``packaged_gui_app_paths()`` before any test ran):
# deleting, replacing or writing into it is refused. Reads stay allowed: several update
# paths only probe it.
self.installed_apps = installed_apps
self.checking = threading.local()
self.directories: dict[int, Path] = {}
def check(self, value, *, dir_fd=None, metadata=False, destructive=False):
if value is None or isinstance(value, int) or getattr(self.checking, "active", False):
return
self.checking.active = True
try:
candidate = os.fsdecode(value)
if candidate.startswith("~"):
# A test may have patched Path.expanduser to fail; the guard must not
# turn that into its own crash — the unexpanded path is checked instead.
try:
candidate = os.fspath(Path(candidate).expanduser())
except Exception:
pass
if dir_fd is not None and not os.path.isabs(candidate):
parent = self.directories.get(dir_fd)
if parent is None:
raise AssertionError("TEST BUG: untracked dir_fd in guarded filesystem I/O")
candidate = os.path.join(os.fspath(parent), candidate)
absolute = _normcase(os.path.abspath(candidate))
# /proc/<pid>/fd/N is descriptor inspection (deleted-WAL holder scans stat the magic
# link to compare inode identity); resolving it names whatever file that fd holds,
# which is not I/O against the home.
if metadata and (absolute == "/proc" or absolute.startswith("/proc" + os.sep)):
return
resolved = self._refuse_installed_app_change(value, absolute) if destructive else None
roots = tuple(_normcase(os.fspath(r)) for r in self.roots())
# Resolving the root itself (get_default_hermes_root's relative_to
# probe) reads no state; only its contents are guarded.
if metadata and absolute in roots:
return
# ``shutil.which`` stats/accesses ``<PATH entry>/<name>``. A developer shell puts
# PM's tool store (~/.hermes/tools/...) on PATH; probing an executable there is
# command lookup, not reading Hermes state. CI has no such entries.
if metadata:
path = os.environ.get("PATH", "")
cwd = os.getcwd() if self._relative_path_entries(path) else None
if os.path.dirname(absolute) in self._path_entries(path, cwd):
return
# The interpreter's own installation (a PM-managed python under ~/.hermes/tools):
# stdlib source reads (linecache, traceback) are not Hermes state either, nor is
# realpath() walking up through its ancestors.
for prefix in _INTERPRETER_PREFIX_STRS:
if _within(absolute, prefix) or (metadata and _contains(absolute, prefix)):
return
# Check the lexical path first: resolving must not probe a protected
# tree merely to decide that the original path was forbidden.
for root in roots:
if _within(absolute, root):
self.refuse(value)
if resolved is None:
resolved = _normcase(os.path.realpath(absolute))
if metadata and resolved in roots:
return
# A fixture symlink to the running interpreter resolves into its installation.
for prefix in _INTERPRETER_PREFIX_STRS:
if _within(resolved, prefix):
return
for root in roots:
if _within(resolved, root):
self.refuse(value)
finally:
self.checking.active = False
def _refuse_installed_app_change(self, value, absolute):
"""Fail on a change to an installed app (already normalized, literal and resolved);
returns the resolved path so check() does not resolve it again, or None."""
apps = self.installed_apps()
if not apps:
return None
resolved = _normcase(os.path.realpath(absolute))
for app in apps:
if _within(absolute, app) or _within(resolved, app):
import pytest # noqa: PLC0415
# pytest.fail, not AssertionError: removal helpers catch Exception and would log
# the refusal as a warning while the test passed.
pytest.fail(f"TEST BUG: changing the REAL installed Hermes desktop app: {value}\n"
"Stub hermes_cli.gui_uninstall.packaged_gui_app_paths to a temporary path.")
return resolved
@staticmethod
def refuse(value):
raise AssertionError(
f"TEST BUG: file I/O against the REAL hermes home: {value}\n"
"Use the isolated HERMES_HOME or a temporary fixture instead."
)
@staticmethod
@lru_cache(maxsize=8)
def _relative_path_entries(path: str) -> bool:
return any(entry and not os.path.isabs(entry) for entry in path.split(os.pathsep))
@staticmethod
@lru_cache(maxsize=8)
def _path_entries(path: str, cwd: str | None):
# Relative PATH entries change meaning after chdir; absolute ones need no cwd.
return frozenset(
_normcase(os.path.normpath(os.path.join(cwd or "", entry)))
for entry in path.split(os.pathsep) if entry
)
def install(self, monkeypatch):
def wrap(module, name, parameters, *, metadata=False, destructive=False):
original = getattr(module, name)
@wraps(original)
def guarded(*args, **kwargs):
for index, (parameter, descriptor) in enumerate(parameters):
value = args[index] if index < len(args) else kwargs.get(parameter)
self.check(value, dir_fd=kwargs.get(descriptor) if descriptor else None, metadata=metadata,
destructive=destructive(args, kwargs) if callable(destructive) else destructive)
return original(*args, **kwargs)
monkeypatch.setattr(module, name, guarded)
def open_writes(args, kwargs):
mode = args[1] if len(args) > 1 else kwargs.get("mode", "r")
return any(flag in mode for flag in "wax+")
for module in (builtins, io):
wrap(module, "open", (("file", None),), destructive=open_writes)
for name in ("mkdir", "unlink", "remove", "rmdir", "chmod", "utime"):
wrap(os, name, (("path", "dir_fd"),), destructive=name != "mkdir")
for name in ("stat", "lstat", "readlink", "access"):
wrap(os, name, (("path", "dir_fd"),), metadata=True)
for name in ("makedirs", "listdir", "scandir"):
wrap(os, name, (("name" if name == "makedirs" else "path", None),))
for name in ("rename", "replace"):
wrap(os, name, (("src", "src_dir_fd"), ("dst", "dst_dir_fd")), destructive=True)
wrap(shutil, "rmtree", (("path", "dir_fd"),), destructive=True)
wrap(sqlite3, "connect", (("database", None),))
original_open, original_close = os.open, os.close
@wraps(original_open)
def guarded_open(path, flags, *args, **kwargs):
self.check(path, dir_fd=kwargs.get("dir_fd"), destructive=bool(flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC)))
fd = original_open(path, flags, *args, **kwargs)
candidate = Path(os.fsdecode(path))
if kwargs.get("dir_fd") is not None and not candidate.is_absolute():
candidate = self.directories[kwargs["dir_fd"]] / candidate
self.directories[fd] = candidate.absolute()
return fd
@wraps(original_close)
def guarded_close(fd):
# Forget the old owner before close releases the number for reuse
# by another thread's open; afterwards we could erase its mapping.
self.directories.pop(fd, None)
return original_close(fd)
monkeypatch.setattr(os, "open", guarded_open)
monkeypatch.setattr(os, "close", guarded_close)