Files
hermes-agent/tests/pm/test_store_extract.py
kshitijk4poor 084645fba4 fix(pm): PortableGit unpack raises InstallError and never pipes the extractor
- Refusal, non-zero exit and timeout now raise InstallError (pm/client.py
  catches only InstallError; TimeoutExpired escaped entirely). The off-Windows
  refusal names a real remedy instead of the generic 'retry'.
- stdin/stdout/stderr -> DEVNULL: with capture_output the stub's RunProgram
  children inherit the pipes and hold run() open past the stub's exit (and
  CPython re-communicates without a timeout after a kill). The stub prints
  nothing under -y, so the output tail was dead code.
- TemporaryDirectory(ignore_cleanup_errors) as in Npm.unpack; unpack empties
  its destination like extract(); drop redundant local imports.
- Tests: CompletedProcess over hand-rolled stubs, no raising=False, timeout
  case added, change-detector pin-suffix test removed.
2026-09-28 02:04:08 +05:30

138 lines
5.0 KiB
Python

"""Regression for the Ubuntu 22.04 bootstrap interpreter: python-build-standalone
tarballs carry relative terminfo symlinks (``share/terminfo/1/1178 -> ../a/adm1178``)."""
import io
import os
import tarfile
import pytest
from pm.store import extract
def _tar(tmp_path, members):
archive = tmp_path / "pkg.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
for name, linkname in members:
info = tarfile.TarInfo(name)
if linkname is None:
data = b"x"
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
else:
info.type = tarfile.SYMTYPE
info.linkname = linkname
tf.addfile(info)
return archive
def test_relative_symlinks_resolve_from_their_own_directory(tmp_path):
archive = _tar(tmp_path, [("python/share/terminfo/a/adm1178", None), ("python/share/terminfo/1/1178", "../a/adm1178")])
dest = tmp_path / "out"
extract(archive, dest)
link = dest / "python/share/terminfo/1/1178"
assert os.readlink(link) == "../a/adm1178"
assert link.resolve() == (dest / "python/share/terminfo/a/adm1178").resolve()
@pytest.mark.parametrize("linkname", ["../../../etc/passwd", "/etc/passwd"])
def test_symlinks_escaping_the_destination_are_rejected(tmp_path, linkname):
archive = _tar(tmp_path, [("python/bin/evil", linkname)])
with pytest.raises(tarfile.FilterError):
extract(archive, tmp_path / "out")
assert not (tmp_path / "out" / "python/bin/evil").is_symlink()
def _portable_git(tmp_path):
archive = tmp_path / "fetch" / "PortableGit-2.53.0.3-64-bit.7z.exe"
archive.parent.mkdir()
archive.write_bytes(b"pinned bytes; the fake run never executes them")
return archive
def _fake_run(monkeypatch, returncode=0, calls=None):
import subprocess
def run(argv, **kwargs):
if calls is not None:
calls.append((argv, kwargs))
return subprocess.CompletedProcess(argv, returncode)
monkeypatch.setattr(subprocess, "run", run)
def test_git_unpack_executes_a_scratch_copy_never_the_cached_bytes(tmp_path, monkeypatch):
"""An executed PE can stay handle-held past pm's download-cleanup retry
(WinError 32), so the extractor runs from a disposable copy beside the
staging tree, never from the cached fetch-<sha> entry."""
import subprocess
from pathlib import Path
import pm.packages
from pm.packages import Git
monkeypatch.setattr(pm.packages, "_HOST_IS_WINDOWS", True)
calls = []
_fake_run(monkeypatch, calls=calls)
archive = _portable_git(tmp_path)
staged = tmp_path / "scratch" / "tree"
Git().unpack(archive, staged, "win32-x64")
(argv, kwargs), = calls
assert argv[1:] == [f"-o{staged}", "-y"]
exe = Path(argv[0])
assert archive.parent not in exe.parents
assert staged.parent in exe.parents
assert not exe.exists(), "the scratch copy is removed after the run"
# Inherited pipes would let the RunProgram children hold run() open.
assert all(kwargs[k] is subprocess.DEVNULL for k in ("stdin", "stdout", "stderr"))
def test_git_unpack_names_the_extractor_exit_code(tmp_path, monkeypatch):
"""Under -y the stub reports nothing, so the error carries the exit code."""
import pm.packages
from pm.package import InstallError
from pm.packages import Git
monkeypatch.setattr(pm.packages, "_HOST_IS_WINDOWS", True)
_fake_run(monkeypatch, returncode=7)
with pytest.raises(InstallError, match="exited 7"):
Git().unpack(_portable_git(tmp_path), tmp_path / "scratch" / "tree", "win32-x64")
def test_git_unpack_timeout_is_a_package_error(tmp_path, monkeypatch):
import subprocess
import pm.packages
from pm.package import InstallError
from pm.packages import Git
def hang(argv, **kwargs):
raise subprocess.TimeoutExpired(argv, kwargs["timeout"])
monkeypatch.setattr(pm.packages, "_HOST_IS_WINDOWS", True)
monkeypatch.setattr(subprocess, "run", hang)
with pytest.raises(InstallError, match="did not finish"):
Git().unpack(_portable_git(tmp_path), tmp_path / "scratch" / "tree", "win32-x64")
def test_git_unpack_requires_a_windows_host(tmp_path, monkeypatch):
"""Off Windows the PE extractor cannot run: refuse before executing
anything, with a remedy that is not "retry"."""
import pm.packages
from pm.package import InstallError
from pm.packages import Git
calls = []
_fake_run(monkeypatch, calls=calls)
monkeypatch.setattr(pm.packages, "_HOST_IS_WINDOWS", False)
with pytest.raises(InstallError, match="Windows host") as excinfo:
Git().unpack(_portable_git(tmp_path), tmp_path / "out", "win32-x64")
assert "retry" not in str(excinfo.value)
assert not calls, "the guard must refuse before any execution"
def test_target_uses_shared_native_arch(monkeypatch):
from hermes_platform.host import facts
from pm import store
monkeypatch.setattr(facts, "native_arch", lambda: "arm64")
assert store._native_machine() == "arm64"