- voice_mode: `_import_audio` asks `pm.ensure_import("audio-io")` before importing
sounddevice, so `/voice on` turns the feature on (or reports exactly why PM
refused: lazy installs off, platform gate, restart needed) instead of printing a
`python -c "from pm import sync_venv…"` one-liner for the user to run.
- pm.plugins_state: `read_home_selection` and `dependency_homes` are the public
readers; memory_provider_migration and plugins_cmd stop importing underscored names.
- pm.store: the `sha256_file` shim is gone; the authority test asserts the Store has no
file hash of its own rather than patching one.
- tests/pm/conftest: `isolated_machine_home` is autouse (Path.home, HOME, USERPROFILE,
HERMES_HOME all under tmp_path); `@pytest.mark.real_machine_home` opts a module out
— the four suites that spawn children with a home they build themselves.
- activate / activate.ps1 / Dockerfile followed hermes_cli.runtime_paths to
pm.environments (the earlier sweep only covered .py).
- pm.registry loads builtins through importlib so a test patching `pm.packages` in
sys.modules still registers the security packages.
453 lines
19 KiB
Python
453 lines
19 KiB
Python
"""pm authority spine: digest-bound facts, tree_digest + doctor re-hash,
|
|
repair logging.
|
|
|
|
Same conventions as test_pm_core: real loopback server, real archives,
|
|
real store — no mocked stores. Assertions are relationships (identity
|
|
matches lock, digest matches bytes), not snapshots."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import pm.paths as paths
|
|
import pm.registry as registry
|
|
from pm.lock import Facts, Lockfile
|
|
from pm.store import Store, current_target, tree_digest
|
|
from tests.pm._fixtures import make_tar, served as served
|
|
from tests.pm.test_pm_core import FakeTool, pm_env as core_env # noqa: F401
|
|
|
|
|
|
@pytest.fixture
|
|
def pm_env(core_env, tmp_path, monkeypatch):
|
|
lock, runtime, docroot, base_url = core_env
|
|
return {"lockfile_path": lock, "runtime": runtime, "docroot": docroot,
|
|
"base_url": base_url, "tmp_path": tmp_path, "monkeypatch": monkeypatch,
|
|
"digest": Lockfile(lock).artifacts("faketool", current_target())[0]["sha256"]}
|
|
|
|
|
|
@pytest.mark.parametrize("route", ["install", "stage"])
|
|
def test_same_version_different_sha_is_not_installed_and_repaired(pm_env, caplog, route):
|
|
"""The witness: same version, different artifact sha. Version/path
|
|
matching cannot see this; identity matching must — check() reports
|
|
it and ensure() replaces the entry bytes."""
|
|
from pm.install import check, ensure, is_installed
|
|
|
|
env = pm_env
|
|
from pm.install import stage_only
|
|
realize = (lambda: ensure("faketool", explicit=True, base_env={})) if route == "install" else (
|
|
lambda: stage_only("faketool", "linux-arm64-bionic"))
|
|
realize()
|
|
if route == "install":
|
|
assert is_installed("faketool")
|
|
else:
|
|
entry = realize()
|
|
(entry / "sentinel").write_text("published")
|
|
assert realize() == entry
|
|
assert (entry / "sentinel").read_text() == "published"
|
|
|
|
_, digest_b = make_tar(env["docroot"], "faketool-1.0-b.tar.gz", {"bin/faketool": "#!B"})
|
|
# Same VERSION, different archive (different url + sha) — the
|
|
# version-only check would accept this re-pin.
|
|
lockfile = Lockfile(env["lockfile_path"])
|
|
lockfile.set_pin(
|
|
"faketool", "1.0",
|
|
{"any": {"url": f"{env['base_url']}/faketool-1.0-b.tar.gz", "sha256": digest_b}},
|
|
)
|
|
lockfile.save()
|
|
if route == "stage":
|
|
assert realize() == entry
|
|
assert (entry / "bin/faketool").read_bytes() == b"#!B"
|
|
assert not (entry / "sentinel").exists()
|
|
assert realize() == entry
|
|
assert not paths.facts_path().exists()
|
|
return
|
|
assert not is_installed("faketool")
|
|
assert check() == ["faketool: not installed or outdated"]
|
|
|
|
with caplog.at_level(logging.INFO, logger="pm.install"):
|
|
realize()
|
|
assert any("1.0" in r.message and env["digest"][:12] in r.message for r in caplog.records)
|
|
assert is_installed("faketool")
|
|
fact = Facts(paths.facts_path()).get("faketool")
|
|
entry_bin = paths.store_root() / fact["entry"] / "bin" / "faketool"
|
|
with open(entry_bin, "rb") as f:
|
|
assert f.read() == b"#!B"
|
|
|
|
# The fact now binds the new identity, not the old one.
|
|
fact = Facts(paths.facts_path()).get("faketool")
|
|
assert fact["target"] == current_target()
|
|
assert fact["artifacts"] == [digest_b]
|
|
|
|
|
|
@pytest.mark.parametrize("matching_fact", [False, True])
|
|
@pytest.mark.parametrize("damage", ["missing-binary", "changed-bytes", "entry-is-file", "missing-entry"])
|
|
def test_install_repairs_corrupt_entry_from_verified_archive(pm_env, matching_fact, damage, caplog):
|
|
from pm.cli import cmd_doctor
|
|
from pm.install import ensure
|
|
|
|
ensure("faketool", base_env={})
|
|
facts_path = paths.facts_path()
|
|
fact = Facts(facts_path).get("faketool")
|
|
entry = paths.store_root() / fact["entry"]
|
|
binary = entry / "bin" / "faketool"
|
|
if damage == "missing-binary":
|
|
binary.unlink()
|
|
elif damage == "changed-bytes":
|
|
binary.write_bytes(b"corrupted")
|
|
else:
|
|
shutil.rmtree(entry)
|
|
if damage == "entry-is-file":
|
|
entry.write_bytes(b"not a directory")
|
|
if not matching_fact:
|
|
data = json.loads(facts_path.read_text())
|
|
data["packages"]["faketool"].pop("artifacts")
|
|
facts_path.write_text(json.dumps(data))
|
|
|
|
with caplog.at_level(logging.INFO, logger="pm.install"):
|
|
ensure("faketool", explicit=True, base_env={})
|
|
assert any("repair: faketool re-realized" in r.message for r in caplog.records)
|
|
assert binary.read_bytes() == b"#!x"
|
|
assert cmd_doctor(None) == 0
|
|
assert Facts(facts_path).get("faketool")["digest"] == tree_digest(binary.parent.parent)
|
|
|
|
|
|
@pytest.mark.parametrize("route,failure", [
|
|
(route, failure) for route in ("install", "stage")
|
|
for failure in ("invalid", "fetch", "unpack", "verify", "publish", "published-verify", "interrupt", "facts")
|
|
if route == "install" or failure != "facts"
|
|
])
|
|
@pytest.mark.parametrize("initial", [False, True], ids=["replacement", "first-install"])
|
|
def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, route, initial, failure):
|
|
from functools import partial
|
|
from pm.install import ensure, stage_only
|
|
from pm.package import InstallError
|
|
|
|
env = pm_env
|
|
target = current_target() if route == "install" else "linux-arm64-bionic"
|
|
realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial(
|
|
stage_only, "faketool", target)
|
|
if not initial:
|
|
realize()
|
|
facts_path = paths.facts_path()
|
|
old_facts = facts_path.read_bytes() if facts_path.exists() else None
|
|
entry = paths.store_root() / FakeTool().store_entry("1.0", target)
|
|
marker = entry / ".pm-stage-pin.json"
|
|
old_marker = marker.read_bytes() if marker.exists() else None
|
|
files = {"bin/unrelated": "bad layout"} if failure == "invalid" else {"bin/faketool": "#!new"}
|
|
_, digest = make_tar(env["docroot"], "replacement.tar.gz", files)
|
|
_, extra_digest = make_tar(env["docroot"], "data.tar.gz", {"share/data": "second archive"})
|
|
lockfile = Lockfile(env["lockfile_path"])
|
|
lockfile.set_pin("faketool", "1.0", {"any": [
|
|
{"url": f"{env['base_url']}/replacement.tar.gz", "sha256": digest},
|
|
{"url": f"{env['base_url']}/data.tar.gz", "sha256": extra_digest},
|
|
]})
|
|
lockfile.save()
|
|
package = registry._packages["faketool"]
|
|
|
|
def fail(*args, **kwargs):
|
|
if failure == "interrupt":
|
|
raise KeyboardInterrupt()
|
|
if failure in ("publish", "facts"):
|
|
raise OSError("injected failure")
|
|
raise InstallError("faketool", "injected failure")
|
|
|
|
with monkeypatch.context() as fault:
|
|
if failure in ("publish", "published-verify", "interrupt"):
|
|
original = Store.publish
|
|
def publish(self, staged, name):
|
|
if name != entry.name:
|
|
return original(self, staged, name)
|
|
if failure == "published-verify":
|
|
published = original(self, staged, name)
|
|
(published / "bin/faketool").unlink()
|
|
return published
|
|
fail()
|
|
fault.setattr(Store, "publish", publish)
|
|
elif failure == "facts":
|
|
fault.setattr(Facts, "record", fail)
|
|
elif failure == "fetch":
|
|
fault.setattr(Store, "fetch_many", fail)
|
|
elif failure == "verify":
|
|
original_verify = package.verify
|
|
def verify(path, target):
|
|
if path != entry:
|
|
fail()
|
|
return original_verify(path, target)
|
|
fault.setattr(package, "verify", verify)
|
|
elif failure == "unpack":
|
|
original_unpack = package.unpack
|
|
def unpack(archive, staged, target):
|
|
original_unpack(archive, staged, target)
|
|
if (staged / "share/data").exists():
|
|
fail()
|
|
fault.setattr(package, "unpack", unpack)
|
|
with pytest.raises(KeyboardInterrupt if failure == "interrupt" else InstallError):
|
|
realize()
|
|
if not initial:
|
|
assert (entry / "bin/faketool").read_bytes() == b"#!x"
|
|
assert (facts_path.read_bytes() if facts_path.exists() else None) == old_facts
|
|
if not initial:
|
|
assert (marker.read_bytes() if marker.exists() else None) == old_marker
|
|
archives = [paths.store_root() / f"fetch-{sha}" for sha in (digest, extra_digest)]
|
|
if failure != "fetch":
|
|
assert all(archive.is_dir() for archive in archives)
|
|
if failure not in ("invalid", "fetch"):
|
|
for filename in ("replacement.tar.gz", "data.tar.gz"):
|
|
(env["docroot"] / filename).unlink()
|
|
realize() # Publication failures must be retryable from verified cached bytes.
|
|
assert (entry / "bin/faketool").read_bytes() == b"#!new"
|
|
assert (entry / "share/data").read_bytes() == b"second archive"
|
|
assert not any(archive.exists() for archive in archives)
|
|
|
|
|
|
@pytest.mark.parametrize("route", ["install", "stage"])
|
|
@pytest.mark.parametrize("interruption", ["before-publish", "after-publish"])
|
|
def test_killed_replacement_recovers_on_next_install(pm_env, route, interruption):
|
|
"""A killed publisher retains old bytes outside scratch until recovery."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from functools import partial
|
|
|
|
from pm.install import ensure, stage_only
|
|
|
|
env = pm_env
|
|
target = current_target() if route == "install" else "linux-arm64-bionic"
|
|
realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial(
|
|
stage_only, "faketool", target)
|
|
realize()
|
|
old_fact = Facts(paths.facts_path()).get("faketool")
|
|
entry = paths.store_root() / FakeTool().store_entry("1.0", target)
|
|
old_digest = tree_digest(entry)
|
|
_, digest = make_tar(env["docroot"], "replacement.tar.gz", {"bin/faketool": "#!new"})
|
|
lockfile = Lockfile(env["lockfile_path"])
|
|
lockfile.set_pin("faketool", "1.0", {"any": {
|
|
"url": f"{env['base_url']}/replacement.tar.gz", "sha256": digest,
|
|
}})
|
|
lockfile.save()
|
|
code = textwrap.dedent("""
|
|
import os, sys
|
|
from pathlib import Path
|
|
import pm.paths as paths
|
|
import pm.registry as registry
|
|
from pm.store import Store
|
|
from tests.pm.test_pm_authority import FakeTool
|
|
from pm.install import ensure, stage_only
|
|
paths.lockfile_path = lambda: Path(sys.argv[1])
|
|
registry._packages[FakeTool.name] = FakeTool()
|
|
publish = Store.publish
|
|
def crash(self, staged, name):
|
|
if not name.startswith('faketool-'):
|
|
return publish(self, staged, name)
|
|
if sys.argv[3] == 'after-publish':
|
|
publish(self, staged, name)
|
|
os._exit(17)
|
|
Store.publish = crash
|
|
if sys.argv[2] == 'install':
|
|
ensure('faketool', explicit=True, base_env={})
|
|
else:
|
|
stage_only('faketool', 'linux-arm64-bionic')
|
|
""")
|
|
child = subprocess.run(
|
|
[sys.executable, "-c", code, str(env["lockfile_path"]), route, interruption],
|
|
env=dict(os.environ), capture_output=True, text=True, timeout=30,
|
|
)
|
|
assert child.returncode == 17, child.stderr
|
|
assert Facts(paths.facts_path()).get("faketool") == old_fact
|
|
previous = entry.with_name(f".previous-{'stage-' if route == 'stage' else ''}{entry.name}")
|
|
assert tree_digest(previous) == old_digest
|
|
realize()
|
|
assert (entry / "bin/faketool").read_bytes() == b"#!new"
|
|
assert not previous.exists()
|
|
if route == "install":
|
|
fact = Facts(paths.facts_path()).get("faketool")
|
|
assert fact["digest"] == tree_digest(entry)
|
|
else:
|
|
assert not paths.facts_path().exists()
|
|
assert json.loads((entry / ".pm-stage-pin.json").read_text())["sha256"] == [digest]
|
|
|
|
|
|
def test_failed_restore_preserves_both_interrupted_versions(pm_env, monkeypatch):
|
|
from pm.install import ensure
|
|
from pm.package import InstallError
|
|
|
|
ensure("faketool", base_env={})
|
|
fact = Facts(paths.facts_path()).get("faketool")
|
|
entry = paths.store_root() / fact["entry"]
|
|
previous = entry.with_name(".previous-" + entry.name)
|
|
previous.mkdir()
|
|
(previous / "saved").write_text("previous")
|
|
(entry / "bin" / "faketool").write_text("uncommitted replacement")
|
|
real_rename = Path.rename
|
|
def fail_restore(self, target):
|
|
if self == previous:
|
|
raise PermissionError("restore refused")
|
|
return real_rename(self, target)
|
|
monkeypatch.setattr(Path, "rename", fail_restore)
|
|
with pytest.raises((PermissionError, InstallError), match="restore refused"):
|
|
ensure("faketool", explicit=True, base_env={})
|
|
assert (entry / "bin" / "faketool").read_text() == "uncommitted replacement"
|
|
assert (previous / "saved").read_text() == "previous"
|
|
|
|
|
|
@pytest.mark.parametrize("poisoned", [False, True], ids=["warm", "corrupt"])
|
|
def test_fetch_cache_verification_is_owned_by_downloader(pm_env, monkeypatch, poisoned):
|
|
"""Real cached bytes are checked in planning and under the partial lock;
|
|
corrupt bytes must be replaced, without an extra Store-layer hash."""
|
|
import pm.store as store_mod
|
|
|
|
env = pm_env
|
|
store = Store(env["runtime"] / "scratch-store")
|
|
url = f"{env['base_url']}/faketool-1.0.tar.gz"
|
|
digest = env["digest"]
|
|
with store.scratch() as scratch:
|
|
first = store.fetch(url, digest, scratch)
|
|
assert sha_of(first) == digest
|
|
|
|
if poisoned:
|
|
first.write_bytes(b"poisoned")
|
|
|
|
# Verification is the downloader's alone: the Store carries no file-hash helper to reach for.
|
|
assert not any(name.startswith("sha256") for name in vars(store_mod))
|
|
with store.scratch() as scratch:
|
|
second = store.fetch(url, digest, scratch)
|
|
assert second == first
|
|
assert sha_of(second) == digest
|
|
|
|
|
|
def sha_of(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def test_legacy_fact_without_identity_is_not_installed(pm_env, capsys):
|
|
"""Facts written before identity existed read back fine but are NOT
|
|
vouchable: installed() with identity returns False and forces one
|
|
reinstall."""
|
|
from pm.install import ensure, is_installed
|
|
|
|
env = pm_env
|
|
ensure("faketool", base_env={})
|
|
|
|
facts_path = paths.facts_path()
|
|
data = json.loads(facts_path.read_text(encoding="utf-8"))
|
|
data["packages"]["faketool"] = {
|
|
"entry": data["packages"]["faketool"]["entry"],
|
|
"version": "1.0",
|
|
"env": data["packages"]["faketool"]["env"],
|
|
}
|
|
facts_path.write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
assert not is_installed("faketool")
|
|
from pm.cli import cmd_doctor
|
|
assert cmd_doctor(None) == 1
|
|
assert "legacy fact: no recorded identity" in capsys.readouterr().out
|
|
# ensure() repairs it: reinstalls and records the full identity.
|
|
ensure("faketool", base_env={})
|
|
assert is_installed("faketool")
|
|
fact = Facts(paths.facts_path()).get("faketool")
|
|
assert fact["artifacts"] == [env["digest"]]
|
|
|
|
|
|
def test_unproven_entry_without_facts_is_rebuilt(pm_env):
|
|
from pm.install import ensure
|
|
entry = paths.store_root() / FakeTool().store_entry("1.0", current_target())
|
|
(entry / "bin").mkdir(parents=True)
|
|
(entry / "bin/faketool").write_bytes(b"unproven")
|
|
(entry / "sentinel").touch()
|
|
ensure("faketool", explicit=True)
|
|
assert (entry / "bin/faketool").read_bytes() == b"#!x"
|
|
assert not (entry / "sentinel").exists()
|
|
assert Facts(paths.facts_path()).get("faketool")["digest"] == tree_digest(entry)
|
|
|
|
|
|
def test_tree_digest_is_content_bound(pm_env):
|
|
"""Same content in different creation order digests identically; one
|
|
changed byte digests differently; a symlink contributes its link
|
|
TARGET TEXT, not the target's bytes."""
|
|
|
|
a = pm_env["tmp_path"] / "tree-a"
|
|
b = pm_env["tmp_path"] / "tree-b"
|
|
(a / "sub").mkdir(parents=True)
|
|
(b / "sub").mkdir(parents=True)
|
|
(a / "sub" / "f.txt").write_text("hello")
|
|
(b / "sub" / "f.txt").write_text("hello")
|
|
(a / "top.txt").write_text("t")
|
|
(b / "top.txt").write_text("t")
|
|
assert tree_digest(a) == tree_digest(b)
|
|
|
|
(b / "top.txt").write_text("u")
|
|
assert tree_digest(a) != tree_digest(b)
|
|
|
|
# Symlink: link text is the data — retargeting changes the digest
|
|
# even though the pointed-at bytes never change.
|
|
(a / "sub" / "f.txt").write_text("hello")
|
|
try:
|
|
(b / "link").symlink_to("sub/f.txt")
|
|
d1 = tree_digest(b)
|
|
(b / "link").unlink()
|
|
(b / "link").symlink_to("top.txt")
|
|
assert tree_digest(b) != d1
|
|
except OSError:
|
|
pytest.skip("symlinks unavailable on this host")
|
|
|
|
|
|
def test_doctor_flags_tampered_entry_bytes(pm_env, capsys):
|
|
"""Post-install tampering: doctor re-hashes the realized tree against
|
|
the recorded digest and flags it; restoring the bytes clears it."""
|
|
from pm.cli import cmd_doctor
|
|
from pm.install import ensure
|
|
|
|
ensure("faketool", base_env={})
|
|
assert cmd_doctor(None) == 0
|
|
|
|
fact = Facts(paths.facts_path()).get("faketool")
|
|
binary = paths.store_root() / fact["entry"] / "bin" / "faketool"
|
|
original = binary.read_bytes()
|
|
binary.write_bytes(original + b"tampered")
|
|
assert cmd_doctor(None) == 1
|
|
assert "realized bytes do not match recorded digest" in capsys.readouterr().out
|
|
|
|
binary.write_bytes(original)
|
|
assert cmd_doctor(None) == 0
|
|
|
|
|
|
def test_tree_digest_ignores_pycache(pm_env, monkeypatch):
|
|
"""Bytecode caches are runtime state, not package bytes: the staged
|
|
python entry runs (uv venv/uv sync in a bundle build, first boot of a
|
|
shipped app) and CPython writes __pycache__/*.pyc into it AFTER the
|
|
digest was recorded. The digest must stay stable across that, while
|
|
tampering a REAL file inside a dir that merely sits beside a
|
|
__pycache__ is still caught. (Live field failure: '✗ python: realized
|
|
bytes do not match recorded digest' on every bundled-release smoke.)"""
|
|
tree = pm_env["tmp_path"] / "py-entry"
|
|
(tree / "Lib").mkdir(parents=True)
|
|
(tree / "Lib" / "os.py").write_text("print('stdlib')")
|
|
|
|
before = tree_digest(tree)
|
|
|
|
# The interpreter "ran": pyc caches appeared deep in the tree.
|
|
cache = tree / "Lib" / "__pycache__"
|
|
cache.mkdir()
|
|
(cache / "os.cpython-311.pyc").write_bytes(b"\x00compiled-bytes\x00")
|
|
nested = tree / "Lib" / "json" / "__pycache__"
|
|
nested.mkdir(parents=True)
|
|
(nested / "tool.cpython-311.pyc").write_bytes(b"\x00more\x00")
|
|
|
|
assert tree_digest(tree) == before
|
|
|
|
# A cache write is not a mask: changing a real .py still trips the digest.
|
|
(tree / "Lib" / "os.py").write_text("print('tampered')")
|
|
assert tree_digest(tree) != before
|
|
|
|
# Restoring the .py (leaving the caches) restores the digest: caches
|
|
# contribute nothing in either direction.
|
|
(tree / "Lib" / "os.py").write_text("print('stdlib')")
|
|
assert tree_digest(tree) == before
|