From aa3466063b8c5b5cd6b99d625469137d71d660c3 Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Mon, 25 May 2026 17:23:33 +0300 Subject: [PATCH] fix(android): reject unsafe tar members in psutil compatibility installer --- hermes_cli/main.py | 29 +--- hermes_cli/psutil_android.py | 108 +++++++++++++++ scripts/install_psutil_android.py | 41 ++---- .../hermes_cli/test_psutil_android_extract.py | 126 ++++++++++++++++++ 4 files changed, 252 insertions(+), 52 deletions(-) create mode 100644 hermes_cli/psutil_android.py create mode 100644 tests/hermes_cli/test_psutil_android_extract.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 30325a181a8..5d6a9ccac7b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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)], diff --git a/hermes_cli/psutil_android.py b/hermes_cli/psutil_android.py new file mode 100644 index 00000000000..c029324542c --- /dev/null +++ b/hermes_cli/psutil_android.py @@ -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 diff --git a/scripts/install_psutil_android.py b/scripts/install_psutil_android.py index 4e2c49805a6..6423b360ad2 100755 --- a/scripts/install_psutil_android.py +++ b/scripts/install_psutil_android.py @@ -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)}") diff --git a/tests/hermes_cli/test_psutil_android_extract.py b/tests/hermes_cli/test_psutil_android_extract.py new file mode 100644 index 00000000000..86477e427c9 --- /dev/null +++ b/tests/hermes_cli/test_psutil_android_extract.py @@ -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