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.
Stopgap: remove this once https://github.com/giampaolo/psutil/pull/2762
merges and ships in a release. ``scripts/install_psutil_android.py``
contains the same logic for ``scripts/install.sh`` (fresh installs).
Both copies should be removed together.
merges and ships in a release. The standalone installer script uses the
same shared helper and should be removed together.
"""
import tarfile
import tempfile
import urllib.request
psutil_url = (
"https://files.pythonhosted.org/packages/aa/c6/"
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
"psutil-7.2.2.tar.gz"
)
from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
archive = tmp_path / "psutil.tar.gz"
urllib.request.urlretrieve(psutil_url, archive)
with tarfile.open(archive) as tar:
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")
urllib.request.urlretrieve(PSUTIL_URL, archive)
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
_run_install_with_heartbeat(
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 subprocess
import sys
import tarfile
import tempfile
import urllib.request
from pathlib import Path
# 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"
# Keep sibling imports working when invoked as
# ``python scripts/install_psutil_android.py`` from the repo checkout.
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
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]:
@ -82,26 +83,10 @@ def main() -> int:
tmp_path = Path(tmp)
archive = tmp_path / "psutil.tar.gz"
urllib.request.urlretrieve(PSUTIL_URL, archive)
with tarfile.open(archive) as tar:
tar.extractall(tmp_path)
try:
src_root = next(
p for p in tmp_path.iterdir()
if p.is_dir() and p.name.startswith("psutil-")
)
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")
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
except PsutilAndroidInstallError as exc:
sys.exit(str(exc))
cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)]
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