From b1cad3aa242ebe8a1de4ec07ef1404affed0bcc3 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:04:13 -0400 Subject: [PATCH] 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. --- pm/packages.py | 9 ++- pm/workspace.py | 5 +- tests/pm/test_stage_only.py | 82 +++++++++++++++++++++++- tests/pm/test_workspace_build_inputs.py | 84 +++++++++++++++++++++++++ tests/test_pm_bionic.py | 15 +++-- 5 files changed, 181 insertions(+), 14 deletions(-) diff --git a/pm/packages.py b/pm/packages.py index 247308f323..04c444c745 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -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( diff --git a/pm/workspace.py b/pm/workspace.py index 5d73d143b4..5a29aed04e 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -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) diff --git a/tests/pm/test_stage_only.py b/tests/pm/test_stage_only.py index e8145bfc1d..4d27499c55 100644 --- a/tests/pm/test_stage_only.py +++ b/tests/pm/test_stage_only.py @@ -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 diff --git a/tests/pm/test_workspace_build_inputs.py b/tests/pm/test_workspace_build_inputs.py index 99071a176c..d2b094f630 100644 --- a/tests/pm/test_workspace_build_inputs.py +++ b/tests/pm/test_workspace_build_inputs.py @@ -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() diff --git a/tests/test_pm_bionic.py b/tests/test_pm_bionic.py index 444d15a63b..85599a36dd 100644 --- a/tests/test_pm_bionic.py +++ b/tests/test_pm_bionic.py @@ -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):