fix(android): reject unsafe tar members in psutil compatibility installer

This commit is contained in:
Dusk1e
2026-05-25 17:23:33 +03:00
committed by Teknium
parent bb0ac5ced2
commit aa3466063b
4 changed files with 252 additions and 52 deletions

View File

@ -8157,37 +8157,18 @@ def _install_psutil_android_compat(
nothing is persisted in the repository. nothing is persisted in the repository.
Stopgap: remove this once https://github.com/giampaolo/psutil/pull/2762 Stopgap: remove this once https://github.com/giampaolo/psutil/pull/2762
merges and ships in a release. ``scripts/install_psutil_android.py`` merges and ships in a release. The standalone installer script uses the
contains the same logic for ``scripts/install.sh`` (fresh installs). same shared helper and should be removed together.
Both copies should be removed together.
""" """
import tarfile
import tempfile import tempfile
import urllib.request import urllib.request
from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist
psutil_url = (
"https://files.pythonhosted.org/packages/aa/c6/"
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
"psutil-7.2.2.tar.gz"
)
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp) tmp_path = Path(tmp)
archive = tmp_path / "psutil.tar.gz" archive = tmp_path / "psutil.tar.gz"
urllib.request.urlretrieve(psutil_url, archive) urllib.request.urlretrieve(PSUTIL_URL, archive)
with tarfile.open(archive) as tar: src_root = prepare_patched_psutil_sdist(archive, tmp_path)
tar.extractall(tmp_path)
src_root = next(
p for p in tmp_path.iterdir() if p.is_dir() and p.name.startswith("psutil-")
)
common_py = src_root / "psutil" / "_common.py"
content = common_py.read_text(encoding="utf-8")
marker = 'LINUX = sys.platform.startswith("linux")'
replacement = 'LINUX = sys.platform.startswith(("linux", "android"))'
if marker not in content:
raise RuntimeError("psutil Android compatibility patch marker not found")
common_py.write_text(content.replace(marker, replacement), encoding="utf-8")
_run_install_with_heartbeat( _run_install_with_heartbeat(
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)],

View File

@ -0,0 +1,108 @@
"""Helpers for the temporary psutil-on-Android compatibility installer."""
from __future__ import annotations
import shutil
import tarfile
from pathlib import Path, PurePosixPath
# Pin a version we know patches cleanly. Update when a newer psutil
# changes the marker line shape and we need to follow upstream.
PSUTIL_URL = (
"https://files.pythonhosted.org/packages/aa/c6/"
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
"psutil-7.2.2.tar.gz"
)
MARKER = 'LINUX = sys.platform.startswith("linux")'
REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))'
class PsutilAndroidInstallError(RuntimeError):
"""Raised when the pinned psutil sdist is missing or unsafe."""
def _normalize_member_parts(member_name: str) -> tuple[str, ...]:
path = PurePosixPath(member_name)
parts = tuple(part for part in path.parts if part not in ("", "."))
if path.is_absolute() or ".." in parts or not parts:
raise PsutilAndroidInstallError(
f"Unsafe archive member path: {member_name!r}"
)
return parts
def _safe_extract_tar_gz(archive: Path, destination: Path) -> None:
"""Extract a tar.gz without allowing traversal or link members."""
with tarfile.open(archive, "r:gz") as tf:
for member in tf.getmembers():
parts = _normalize_member_parts(member.name)
target = destination.joinpath(*parts)
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
if not member.isfile():
raise PsutilAndroidInstallError(
f"Unsupported archive member type: {member.name}"
)
target.parent.mkdir(parents=True, exist_ok=True)
extracted = tf.extractfile(member)
if extracted is None:
raise PsutilAndroidInstallError(
f"Cannot read archive member: {member.name}"
)
with extracted, open(target, "wb") as dst:
shutil.copyfileobj(extracted, dst)
try:
target.chmod(member.mode & 0o777)
except OSError:
pass
def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path:
"""Safely extract the pinned psutil sdist and patch it for Android."""
_safe_extract_tar_gz(archive, destination)
src_roots = sorted(
(
path for path in destination.iterdir()
if path.is_dir() and path.name.startswith("psutil-")
),
key=lambda path: path.name,
)
if not src_roots:
raise PsutilAndroidInstallError(
"psutil sdist did not contain a psutil-* directory"
)
src_root = src_roots[0]
common_py = src_root / "psutil" / "_common.py"
if not common_py.is_file():
raise PsutilAndroidInstallError(
f"psutil sdist did not contain {common_py.relative_to(src_root)!s}"
)
try:
content = common_py.read_text(encoding="utf-8")
except OSError as exc:
raise PsutilAndroidInstallError(
f"Failed to read {common_py.relative_to(src_root)!s}"
) from exc
if MARKER not in content:
raise PsutilAndroidInstallError(
"psutil Android compatibility patch marker not found"
)
try:
common_py.write_text(
content.replace(MARKER, REPLACEMENT),
encoding="utf-8",
)
except OSError as exc:
raise PsutilAndroidInstallError(
f"Failed to write {common_py.relative_to(src_root)!s}"
) from exc
return src_root

View File

@ -27,21 +27,22 @@ import argparse
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tarfile
import tempfile import tempfile
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
# Pin a version we know patches cleanly. Update when a newer psutil # Keep sibling imports working when invoked as
# changes the marker line shape and we need to follow upstream. # ``python scripts/install_psutil_android.py`` from the repo checkout.
PSUTIL_URL = ( REPO_ROOT = Path(__file__).resolve().parents[1]
"https://files.pythonhosted.org/packages/aa/c6/" if str(REPO_ROOT) not in sys.path:
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/" sys.path.insert(0, str(REPO_ROOT))
"psutil-7.2.2.tar.gz"
from hermes_cli.psutil_android import (
PSUTIL_URL,
PsutilAndroidInstallError,
prepare_patched_psutil_sdist,
) )
MARKER = 'LINUX = sys.platform.startswith("linux")'
REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))'
def _resolve_install_cmd(pip_arg: str | None, prefer_uv: bool) -> list[str]: def _resolve_install_cmd(pip_arg: str | None, prefer_uv: bool) -> list[str]:
@ -82,26 +83,10 @@ def main() -> int:
tmp_path = Path(tmp) tmp_path = Path(tmp)
archive = tmp_path / "psutil.tar.gz" archive = tmp_path / "psutil.tar.gz"
urllib.request.urlretrieve(PSUTIL_URL, archive) urllib.request.urlretrieve(PSUTIL_URL, archive)
with tarfile.open(archive) as tar:
tar.extractall(tmp_path)
try: try:
src_root = next( src_root = prepare_patched_psutil_sdist(archive, tmp_path)
p for p in tmp_path.iterdir() except PsutilAndroidInstallError as exc:
if p.is_dir() and p.name.startswith("psutil-") sys.exit(str(exc))
)
except StopIteration:
sys.exit("psutil sdist did not contain a psutil-* directory")
common_py = src_root / "psutil" / "_common.py"
content = common_py.read_text(encoding="utf-8")
if MARKER not in content:
sys.exit(
"psutil Android compatibility patch marker not found — "
"upstream may have changed the LINUX detection line. "
"Update MARKER/REPLACEMENT in this script."
)
common_py.write_text(content.replace(MARKER, REPLACEMENT), encoding="utf-8")
cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)] cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)]
print(f" $ {' '.join(cmd)}") print(f" $ {' '.join(cmd)}")

View File

@ -0,0 +1,126 @@
"""Regression tests for the Android psutil compatibility installer."""
from __future__ import annotations
import io
import shutil
import tarfile
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli.psutil_android import (
MARKER,
REPLACEMENT,
PSUTIL_URL,
PsutilAndroidInstallError,
prepare_patched_psutil_sdist,
)
def _add_dir(tf: tarfile.TarFile, name: str) -> None:
info = tarfile.TarInfo(name)
info.type = tarfile.DIRTYPE
info.mode = 0o755
tf.addfile(info)
def _add_file(tf: tarfile.TarFile, name: str, content: str) -> None:
payload = content.encode("utf-8")
info = tarfile.TarInfo(name)
info.size = len(payload)
info.mode = 0o644
tf.addfile(info, io.BytesIO(payload))
def _build_psutil_archive(archive: Path, *, malicious_symlink: bool) -> None:
with tarfile.open(archive, "w:gz") as tf:
_add_dir(tf, "psutil-7.2.2")
if malicious_symlink:
link = tarfile.TarInfo("psutil-7.2.2/psutil")
link.type = tarfile.SYMTYPE
link.linkname = "../../outside"
tf.addfile(link)
else:
_add_dir(tf, "psutil-7.2.2/psutil")
_add_file(
tf,
"psutil-7.2.2/psutil/_common.py",
f"{MARKER}\n",
)
def test_prepare_patched_psutil_sdist_rejects_symlink_member(tmp_path):
"""A symlink member must be rejected before any file payload is written."""
archive = tmp_path / "evil.tar.gz"
_build_psutil_archive(archive, malicious_symlink=True)
destination = tmp_path / "extract"
with pytest.raises(PsutilAndroidInstallError, match="Unsupported archive member type"):
prepare_patched_psutil_sdist(archive, destination)
assert not (tmp_path / "outside" / "_common.py").exists()
def test_install_psutil_android_compat_uses_patched_tree(tmp_path):
"""Updater path should install from the patched temporary sdist tree."""
archive = tmp_path / "psutil.tar.gz"
_build_psutil_archive(archive, malicious_symlink=False)
from hermes_cli import main as hermes_main
captured: dict[str, object] = {}
def fake_urlretrieve(url: str, dest: Path):
assert url == PSUTIL_URL
shutil.copyfile(archive, dest)
return str(dest), None
def fake_run_install(cmd: list[str], *, env=None):
src_root = Path(cmd[-1])
captured["cmd"] = cmd
captured["env"] = env
captured["common_py"] = (src_root / "psutil" / "_common.py").read_text(
encoding="utf-8"
)
with patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve), \
patch.object(hermes_main, "_run_install_with_heartbeat", side_effect=fake_run_install):
hermes_main._install_psutil_android_compat(
["uv", "pip"],
env={"HERMES_TEST": "1"},
)
assert captured["cmd"][:4] == ["uv", "pip", "install", "--no-build-isolation"]
assert captured["env"] == {"HERMES_TEST": "1"}
assert REPLACEMENT in str(captured["common_py"])
def test_install_psutil_android_script_uses_patched_tree(tmp_path, monkeypatch, capsys):
"""Standalone installer script should reuse the same safe patched tree."""
archive = tmp_path / "psutil.tar.gz"
_build_psutil_archive(archive, malicious_symlink=False)
import scripts.install_psutil_android as installer
def fake_urlretrieve(url: str, dest: Path):
assert url == PSUTIL_URL
shutil.copyfile(archive, dest)
return str(dest), None
def fake_subprocess_run(cmd: list[str]):
src_root = Path(cmd[-1])
patched = (src_root / "psutil" / "_common.py").read_text(encoding="utf-8")
assert REPLACEMENT in patched
return type("RunResult", (), {"returncode": 0})()
monkeypatch.setattr(installer.sys, "argv", ["install_psutil_android.py"])
monkeypatch.setattr(installer, "_resolve_install_cmd", lambda *_args: ["python", "-m", "pip"])
with patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve), \
patch.object(installer.subprocess, "run", side_effect=fake_subprocess_run):
assert installer.main() == 0
captured = capsys.readouterr()
assert "psutil installed via Android compatibility shim" in captured.out