fix(pm): preserve cross-target stages and recorded build inputs

Cross-target Node verification attempts to execute foreign bytes before
and after publication. Check the native target before smoke probes, while
retaining file and architecture checks for every target.

Repair must retain a plugin's build directory when it contains the declared
PEP 517 backend. Use the same copy exclusions as the initial snapshot.

The foreign-ELF execution trap and offline real-uv replay test fail before
the fixes and pass after them. Native smoke probes and bionic no-execution
checks also pass. The focused PM run reports nine unrelated failures,
all reproduced at the starting commit. No full suite or native Windows
validation was run.
This commit is contained in:
ethernet
2026-09-12 19:04:13 -04:00
parent 4e198ec6f8
commit b1cad3aa24
5 changed files with 181 additions and 14 deletions

View File

@@ -19,7 +19,7 @@ from pm.package import (
_probe_reason,
)
from pm.registry import register
from pm.store import ALL_TARGETS, Store, flatten_single_dir, merge_tree
from pm.store import ALL_TARGETS, Store, current_target, flatten_single_dir, merge_tree
from pm.update import (
btbn_index,
btbn_versions,
@@ -80,16 +80,15 @@ class BinaryPackage(Package):
return entry / rel if rel else None
def verify(self, entry: Path, target: str) -> str:
"""Return '' when the entry is usable on target, else why not:
a missing binary, a wrong-arch binary, or a --version probe that
fails to exec, times out, or exits nonzero."""
"""Check file/architecture evidence for every target, plus a smoke
probe only on the native target. Never execute cross-staged bytes."""
binary = self.binary(entry, target)
if binary is None:
return "no binary_rel for this target"
reason = self._binary_reason(binary, entry, target)
if reason:
return reason
if not self.probe_version:
if not self.probe_version or target != current_target():
return ""
try:
proc = subprocess.run(

View File

@@ -331,7 +331,7 @@ def lock_and_sync(
"""Prepare a fresh generation using explicit inputs and a prepared engine.
The caller selects the seed; uv retains its compatible versions. Repair
copies the recorded workspace verbatim and never reads current manifests.
copies the recorded build inputs and never reads current manifests.
Resolver conflicts remain distinct from download/build failures.
"""
if root.exists() or root.is_symlink():
@@ -345,7 +345,8 @@ def lock_and_sync(
raise InstallError("venv", f"recorded workspace is missing: {replay}")
# Sibling generations keep external relative paths at the same depth;
# snapshotted members and their exact lock travel with the workspace.
shutil.copytree(replay, root, ignore=shutil.ignore_patterns("__pycache__", ".venv", "build", "*.egg-info"))
# Use the snapshot's exclusions: build/ may hold an in-tree backend.
shutil.copytree(replay, root, symlinks=True, ignore=_member_ignored)
frozen = True
environment.sync(root, extras=extras, frozen=frozen)

View File

@@ -3,8 +3,8 @@ deleted (verify() returns '' on success), and stage_only must honor a
same-version hash repin (the entry marker design, like facts' identity).
Everything runs inside a temp HERMES_RUNTIME_DIR sandbox: the store and
facts live under tmp_path, and the lockfile + package registry are faked,
so no network and no real install state is touched.
facts live under tmp_path. Most tests use fake package definitions. The
Node tests use the real package with local archives, without network access.
"""
from __future__ import annotations
@@ -86,6 +86,84 @@ TARGET = "linux-arm64-bionic"
ENTRY = "stage-test-1.0-linux-arm64-bionic"
@pytest.mark.platforms("linux", arch="x86_64")
def test_real_node_foreign_stage_checks_bytes_without_exec(tmp_path, monkeypatch):
import io
import zipfile
from pm import paths
from pm.package import machine_matches_binary
from pm.registry import get_package
from pm.store import current_target
assert current_target() == "linux-x64"
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "runtime"))
store = Store(paths.store_root())
# Real Node package/unpacker/verifier, with a hash-verified offline archive.
elf = bytearray(b"\x7fELF" + b"\0" * 60)
elf[4:7] = b"\x02\x01\x01" # ELF64, little endian, current ELF version
elf[18:20] = (0xB7).to_bytes(2, "little") # AArch64
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w") as payload:
payload.writestr("node-v1.0-linux-arm64/bin/node", elf)
data = archive.getvalue()
_arm_lock(monkeypatch, [{"url": "https://example.test/node.zip", "sha256": _sha(data)}])
cached = store.entry(f"fetch-{_sha(data)}")
cached.mkdir(parents=True)
(cached / "node.zip").write_bytes(data)
def refuse_exec(*args, **kwargs):
pytest.fail(f"cross-target stage attempted execution: {args}")
monkeypatch.setattr("pm.packages.subprocess.run", refuse_exec)
entry = ensure_mod.stage_only("node", "linux-arm64")
node = entry / "bin/node"
assert node.read_bytes() == elf
assert machine_matches_binary(node, "linux-arm64") is True
assert ensure_mod.stage_only("node", "linux-arm64") == entry
assert not paths.facts_path().exists()
assert not cached.exists()
# The no-exec path must still diagnose wrong-architecture and missing bytes.
elf[18:20] = (0x3E).to_bytes(2, "little") # x86-64
node.write_bytes(elf)
assert "not a linux-arm64 binary" in get_package("node").verify(entry, "linux-arm64")
node.unlink()
assert get_package("node").verify(entry, "linux-arm64")
@pytest.mark.platforms("posix")
def test_real_node_native_install_keeps_smoke_validation(tmp_path, sandbox, monkeypatch):
import io
import tarfile
from pm.packages import Nodejs
from pm.store import current_target
# An executable fixture makes native verification observable without a Node download.
probe = tmp_path / "native-probes"
script = f'#!/bin/sh\nprintf "%s\\n" "$1" >> "{probe}"\nexit 0\n'.encode()
archive = io.BytesIO()
with tarfile.open(fileobj=archive, mode="w:gz") as payload:
member = tarfile.TarInfo("node-v1.0/bin/node")
member.mode = 0o755
member.size = len(script)
payload.addfile(member, io.BytesIO(script))
data = archive.getvalue()
_arm_lock(monkeypatch, [{"url": "https://example.test/node.tar.gz", "sha256": _sha(data)}])
cached = sandbox.entry(f"fetch-{_sha(data)}")
cached.mkdir(parents=True)
(cached / "node.tar.gz").write_bytes(data)
package, facts = Nodejs(), ensure_mod._facts()
entry = ensure_mod._install(package, ensure_mod._lockfile(), facts, sandbox, current_target())
assert probe.read_text().splitlines() == ["--version", "--version"]
assert facts.get("node")["entry"] == entry.name
(entry / "bin/node").write_text("#!/bin/sh\nexit 23\n")
assert "23" in package.verify(entry, current_target())
def test_stage_only_keeps_valid_entry(tmp_path, sandbox, monkeypatch):
"""A published entry that verifies must be returned as-is: the
verify contract is '' on success, so an inverted predicate here would

View File

@@ -49,6 +49,90 @@ def test_real_build_inputs_stay_in_generated_root(tmp_path, monkeypatch):
assert not (core / "uv.lock").exists()
def test_repair_replays_saved_in_tree_build_backend(tmp_path):
import os
import tomllib
from pm.environment import PythonEnvironment
core, plugin = tmp_path / "core", tmp_path / "plugin"
core.mkdir()
(plugin / "build").mkdir(parents=True)
(core / "pyproject.toml").write_text(
'[project]\nname="replay-core"\nversion="1"\nrequires-python=">=3.11"\n'
'[tool.uv]\npackage=false\nno-index=true\n', encoding="utf-8",
)
(plugin / "pyproject.toml").write_text(
'[project]\nname="replay-plugin"\nversion="1.0"\nrequires-python=">=3.11"\n'
'[build-system]\nrequires=[]\nbuild-backend="backend"\nbackend-path=["build"]\n',
encoding="utf-8",
)
(plugin / "plugin.yaml").write_text("name: replay-plugin\n", encoding="utf-8")
(plugin / "replay_plugin.py").write_text("VALUE = 'recorded plugin bytes'\n", encoding="utf-8")
# A real, dependency-free PEP 517/660 backend. Its directory is source, not output.
(plugin / "build/backend.py").write_text('''
from pathlib import Path
from zipfile import ZipFile
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
name = "replay_plugin-1.0-py3-none-any.whl"
dist = "replay_plugin-1.0.dist-info"
entries = {
"replay_plugin.py": Path("replay_plugin.py").read_bytes(),
dist + "/METADATA": "Metadata-Version: 2.1\\nName: replay-plugin\\nVersion: 1.0\\n",
dist + "/WHEEL": "Wheel-Version: 1.0\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n",
}
entries[dist + "/RECORD"] = "".join(path + ",,\\n" for path in entries)
with ZipFile(Path(wheel_directory) / name, "w") as wheel:
for path, body in entries.items():
wheel.writestr(path, body)
return name
build_editable = build_wheel
''', encoding="utf-8")
inputs = {p.relative_to(plugin): p.read_bytes() for p in plugin.rglob("*") if p.is_file()}
uv = shutil.which("uv")
assert uv, "saved backend replay test requires real uv"
saved, repaired = tmp_path / "saved", tmp_path / "repaired"
initial = PythonEnvironment(
uv=Path(uv), python=Path(sys.executable), destination=tmp_path / "initial-env",
cache=tmp_path / "initial-cache", env=dict(os.environ), offline=True,
)
workspace.lock_and_sync([plugin], [], root=saved, source=core, seed_lock=None,
environment=initial)
[relative] = tomllib.loads((saved / "pyproject.toml").read_text())["tool"]["uv"]["workspace"]["members"]
assert all((saved / relative / path).read_bytes() == data for path, data in inputs.items())
saved_lock = (saved / "uv.lock").read_bytes()
# Neither live manifests nor the live backend can provide repair's build inputs.
(core / "pyproject.toml").write_text("damaged [", encoding="utf-8")
(plugin / "pyproject.toml").write_text("damaged [", encoding="utf-8")
(plugin / "plugin.yaml").write_text("damaged [", encoding="utf-8")
(plugin / "build/backend.py").unlink()
(plugin / "replay_plugin.py").write_text("raise RuntimeError('damaged live source')\n", encoding="utf-8")
repair = PythonEnvironment(
uv=Path(uv), python=Path(sys.executable), destination=tmp_path / "repair-env",
# A fresh cache forces uv to invoke the saved backend again, not reuse a wheel.
cache=tmp_path / "repair-cache", env=dict(os.environ), offline=True,
)
workspace.lock_and_sync([plugin], [], root=repaired, source=core, seed_lock=None,
replay=saved, environment=repair)
assert (repaired / "uv.lock").read_bytes() == saved_lock
assert (saved / "uv.lock").read_bytes() == saved_lock
for root in (saved, repaired):
assert all((root / relative / path).read_bytes() == data for path, data in inputs.items())
for environment in (initial, repair):
probe = subprocess.run(
[str(environment.executable), "-I", "-c", "import replay_plugin; print(replay_plugin.VALUE)"],
cwd=tmp_path, text=True, capture_output=True, check=True, timeout=30,
)
assert probe.stdout.strip() == "recorded plugin bytes"
assert (core / "pyproject.toml").read_text() == "damaged ["
assert (plugin / "pyproject.toml").read_text() == "damaged ["
assert (plugin / "plugin.yaml").read_text() == "damaged ["
assert not (plugin / "build/backend.py").exists()
def test_source_refresh_does_not_need_metadata_change_and_refuses_live_root(tmp_path, monkeypatch):
core = tmp_path / "core"
core.mkdir()

View File

@@ -152,20 +152,25 @@ def test_debpackage_unpack_hardened(tmp_path: Path):
_P().unpack(evil, tmp_path / "staged2", "linux-arm64-bionic")
def test_python_bionic_verify_is_file_evidence(tmp_path: Path):
@pytest.mark.parametrize("name", ["python", "uv", "node"])
def test_bionic_verify_is_file_evidence(tmp_path: Path, monkeypatch, name):
"""bionic verify never executes the staged binary; presence is the
contract (the digest already proved the bytes)."""
from pm.registry import get_package
py = get_package("python")
bin_rel = Path(py.prefix_rel) / py.main_rel("linux-arm64-bionic")
def refuse_exec(*args, **kwargs):
pytest.fail(f"bionic verification attempted execution: {args}")
monkeypatch.setattr("pm.packages.subprocess.run", refuse_exec)
package = get_package(name)
bin_rel = Path(package.prefix_rel) / package.main_rel("linux-arm64-bionic")
entry = tmp_path / "entry"
(entry / bin_rel).parent.mkdir(parents=True)
(entry / bin_rel).write_bytes(b"bionic-elf-bytes")
assert py.verify(entry, "linux-arm64-bionic") == ""
assert package.verify(entry, "linux-arm64-bionic") == ""
empty = tmp_path / "empty"
empty.mkdir()
assert "missing" in py.verify(empty, "linux-arm64-bionic")
assert "missing" in package.verify(empty, "linux-arm64-bionic")
def test_bionic_binary_and_env_contract(tmp_path: Path):