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):