138 lines
4.7 KiB
Python
138 lines
4.7 KiB
Python
"""Venv ownership preflight for ``hermes update`` (#83529).
|
|
|
|
A venv touched by ``sudo pip`` / ``sudo hermes`` contains root-owned files
|
|
(e.g. ``site-packages/hermes_agent-*.dist-info/INSTALLER``). A later normal
|
|
``hermes update`` then dies mid-mutation inside ``uv pip install -e .``
|
|
("Permission denied (os error 13)") with ``venv/bin/hermes`` already deleted,
|
|
bricking the CLI. The preflight refuses BEFORE the first venv mutation and
|
|
prints the exact chown recovery command.
|
|
|
|
The helper must be pure ``os.stat``/``os.scandir`` — no subprocess calls —
|
|
because update-path tests mock ``subprocess.run`` with sequenced side effects.
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import update_cmd
|
|
|
|
|
|
def _make_fake_venv(tmp_path):
|
|
"""Minimal POSIX venv layout with a dist-info directory."""
|
|
venv = tmp_path / "venv"
|
|
(venv / "bin").mkdir(parents=True)
|
|
(venv / "bin" / "hermes").write_text("#!stub\n")
|
|
(venv / "bin" / "python").write_text("#!stub\n")
|
|
sp = venv / "lib" / "python3.12" / "site-packages"
|
|
dist_info = sp / "hermes_agent-1.0.0.dist-info"
|
|
dist_info.mkdir(parents=True)
|
|
(dist_info / "INSTALLER").write_text("pip\n")
|
|
(dist_info / "RECORD").write_text("\n")
|
|
(sp / "some_pkg").mkdir()
|
|
return venv
|
|
|
|
|
|
def test_all_owned_returns_empty(tmp_path):
|
|
venv = _make_fake_venv(tmp_path)
|
|
assert update_cmd._venv_foreign_owned_paths(venv) == []
|
|
|
|
|
|
def test_all_owned_preflight_proceeds(tmp_path, monkeypatch, capsys):
|
|
"""Gate is a no-op (no exit, no output) when everything is user-owned."""
|
|
_make_fake_venv(tmp_path)
|
|
update_cmd._refuse_update_if_venv_foreign_owned(tmp_path)
|
|
assert capsys.readouterr().out == ""
|
|
|
|
|
|
def test_foreign_owned_dist_info_child_detected(tmp_path, monkeypatch):
|
|
venv = _make_fake_venv(tmp_path)
|
|
installer = str(
|
|
venv / "lib" / "python3.12" / "site-packages"
|
|
/ "hermes_agent-1.0.0.dist-info" / "INSTALLER"
|
|
)
|
|
real_uid = update_cmd._path_uid
|
|
|
|
def fake_uid(path):
|
|
if str(path) == installer:
|
|
return 0 # simulate root-owned sudo-pip residue
|
|
return real_uid(path)
|
|
|
|
monkeypatch.setattr(update_cmd, "_path_uid", fake_uid)
|
|
foreign = update_cmd._venv_foreign_owned_paths(venv)
|
|
assert foreign == [(installer, 0)]
|
|
|
|
|
|
def test_foreign_owned_refuses_with_chown_hint(tmp_path, monkeypatch, capsys):
|
|
venv = _make_fake_venv(tmp_path)
|
|
hermes_bin = str(venv / "bin" / "hermes")
|
|
real_uid = update_cmd._path_uid
|
|
monkeypatch.setattr(
|
|
update_cmd,
|
|
"_path_uid",
|
|
lambda p: 0 if str(p) == hermes_bin else real_uid(p),
|
|
)
|
|
with pytest.raises(SystemExit) as exc:
|
|
update_cmd._refuse_update_if_venv_foreign_owned(tmp_path)
|
|
assert exc.value.code == 1
|
|
out = capsys.readouterr().out
|
|
assert hermes_bin in out
|
|
assert "owner uid 0" in out
|
|
assert f"sudo chown -R $(id -un): {tmp_path}" in out
|
|
assert "Nothing in the venv was modified." in out
|
|
|
|
|
|
def test_limit_caps_reported_paths(tmp_path, monkeypatch):
|
|
venv = _make_fake_venv(tmp_path)
|
|
bin_dir = venv / "bin"
|
|
for i in range(10):
|
|
(bin_dir / f"tool{i}").write_text("x")
|
|
monkeypatch.setattr(
|
|
update_cmd,
|
|
"_path_uid",
|
|
lambda p: 0 if str(p).startswith(str(bin_dir) + os.sep) else 12345,
|
|
)
|
|
monkeypatch.setattr(update_cmd.os, "geteuid", lambda: 12345, raising=False)
|
|
foreign = update_cmd._venv_foreign_owned_paths(venv, limit=3)
|
|
assert len(foreign) == 3
|
|
|
|
|
|
def test_no_geteuid_returns_empty(tmp_path, monkeypatch):
|
|
"""Windows (no os.geteuid) skips the preflight entirely."""
|
|
venv = _make_fake_venv(tmp_path)
|
|
|
|
class _NoGeteuidOS:
|
|
def __getattr__(self, name):
|
|
if name == "geteuid":
|
|
raise AttributeError(name)
|
|
return getattr(os, name)
|
|
|
|
monkeypatch.setattr(update_cmd, "os", _NoGeteuidOS())
|
|
assert update_cmd._venv_foreign_owned_paths(venv) == []
|
|
|
|
|
|
def test_running_as_root_returns_empty(tmp_path, monkeypatch):
|
|
venv = _make_fake_venv(tmp_path)
|
|
monkeypatch.setattr(update_cmd.os, "geteuid", lambda: 0, raising=False)
|
|
# Even with foreign uids everywhere, root skips the gate.
|
|
monkeypatch.setattr(update_cmd, "_path_uid", lambda p: 4242)
|
|
assert update_cmd._venv_foreign_owned_paths(venv) == []
|
|
|
|
|
|
def test_never_raises_on_scandir_permission_error(tmp_path, monkeypatch):
|
|
venv = _make_fake_venv(tmp_path)
|
|
|
|
def boom(path):
|
|
raise PermissionError(13, "Permission denied", str(path))
|
|
|
|
monkeypatch.setattr(update_cmd.os, "scandir", boom)
|
|
assert update_cmd._venv_foreign_owned_paths(venv) == []
|
|
|
|
|
|
def test_never_raises_on_missing_venv(tmp_path):
|
|
assert update_cmd._venv_foreign_owned_paths(tmp_path / "no-venv") == []
|
|
|
|
|
|
def test_path_uid_none_on_oserror(tmp_path):
|
|
assert update_cmd._path_uid(tmp_path / "does-not-exist") is None
|