Files
hermes-agent/tests/test_pm_bionic.py
ethernet 3f43d634d2 fix(termux): replace the retired libffi runtime archive
Termux removed libffi 3.5.2 from its rolling package pool. The pinned URL
returns 404 and stops runtime-library staging. Pin the available 3.8.0
archive with its downloaded SHA-256, which matches the package index.

Name the package, version and URL when a download fails. Flush staging
progress so piped build logs preserve the failure order. Update the
bionic Python tests to follow the current supplier and binary layout.

Verified all 83 library/license archives and 265 staged shared libraries.
The complete staging step and its verified cache hit both returned 0.
The pinned Python ctypes extension finds its required libffi symbols.
Focused tests: 19 passed, 2 skipped. Bionic execution remains a CI gate.
2026-09-09 00:01:51 -04:00

214 lines
7.8 KiB
Python

"""Behavior contracts for pm's bionic target + DebPackage + stage_only.
The lock VALUES are bumped by review; these tests pin the relationships:
the linux-arm64-bionic rows exist and agree with their suppliers' shapes,
DebPackage extraction is hardened, and cross-target staging never touches
this host's installed facts.
"""
from __future__ import annotations
import json
import tarfile
import io
import zipfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
def _pm():
import sys
sys.path.insert(0, str(REPO_ROOT))
import pm
return pm
@pytest.fixture(scope="module")
def lock():
return json.loads((REPO_ROOT / "pm" / "lock.json").read_text(encoding="utf-8"))
def test_all_targets_includes_bionic():
from pm.store import ALL_TARGETS
assert "linux-arm64-bionic" in ALL_TARGETS
assert ALL_TARGETS.count("linux-arm64-bionic") == 1
def _assert_pinned_bionic_row(lock, pkg, url_suffix_re):
"""The bionic rows are DELIBERATE explicit pins: the version axis follows
main (the desktop artifacts), while the termux/TUR suppliers rotate or
lag, so the bionic row pins whatever the supplier actually ships today.
The stage path consumes the row's url+sha256 directly. The contract:
the row exists, is https, matches the supplier's filename shape, and
fetch_url AGREES with the pinned row for this target."""
import re as _re
row = lock["packages"][pkg]["artifacts"].get("linux-arm64-bionic")
assert row, f"{pkg} has no linux-arm64-bionic artifact"
assert row["url"].startswith("https://"), f"{pkg} bionic row not https"
m = _re.search(url_suffix_re, row["url"])
assert m, f"{pkg} bionic url shape: {row['url']}"
assert _re.fullmatch(r"[0-9a-f]{64}", row["sha256"])
from pm.registry import get_package
cls = get_package(pkg)
# fetch_url must reproduce the pinned row exactly for this target
# (the pinned version is recovered from the URL itself).
assert cls.fetch_url(m.group("ver"), "linux-arm64-bionic") == row["url"]
def test_python_bionic_row_matches_supplier(lock):
"""The Python package definition and lock must agree on the termux-main artifact."""
_assert_pinned_bionic_row(lock, "python", r"/p/python/python_(?P<ver>[0-9.]+(?:-[0-9]+)?)_aarch64\.deb$")
def test_node_bionic_row_matches_supplier(lock):
"""The node bionic row is an explicit pin of the termux-main nodejs .deb;
the row and Nodejs.fetch_url(bionic arm) must agree."""
_assert_pinned_bionic_row(lock, "node", r"nodejs_(?P<ver>[0-9.]+)-1_aarch64\.deb$")
def test_termux_docker_row_pins_digest(lock):
row = lock["packages"]["termux-docker"]["artifacts"]["linux-arm64-bionic"]
version = lock["packages"]["termux-docker"]["version"]
assert version.startswith("sha256:")
assert len(version) == 7 + 64
assert row["url"] == f"docker://termux/termux-docker@{version}"
def test_python_bionic_pin_is_independent_of_desktop_build_version(lock):
from pm.registry import get_package
py = get_package("python")
package = lock["packages"]["python"]
assert py.fetch_url(package["version"], "linux-arm64-bionic") == package["artifacts"]["linux-arm64-bionic"]["url"]
assert py.deb_package == "python"
def test_uv_bionic_row_matches_supplier(lock):
"""The uv bionic row is an explicit pin of the termux-main pool .deb;
the row and Uv.fetch_url(bionic arm) must agree."""
_assert_pinned_bionic_row(lock, "uv", r"/u/uv/uv_(?P<ver>[0-9.]+)_aarch64\.deb$")
def _build_fake_deb(path: Path, control: dict[str, str], files: dict[str, bytes]) -> None:
def ar_member(name: str, data: bytes) -> bytes:
hdr = (
name.ljust(16).encode()
+ b"0".ljust(12)
+ b"0".ljust(6)
+ b"0".ljust(6)
+ b"100644".ljust(8)
+ str(len(data)).encode().ljust(10)
+ b"`\n"
)
pad = b"\n" if len(data) % 2 else b""
return hdr + data + pad
ctrl_buf = io.BytesIO()
with tarfile.open(fileobj=ctrl_buf, mode="w:gz") as tf:
body = "".join(f"{k}: {v}\n" for k, v in control.items()).encode()
info = tarfile.TarInfo("control")
info.size = len(body)
tf.addfile(info, io.BytesIO(body))
data_buf = io.BytesIO()
with tarfile.open(fileobj=data_buf, mode="w:gz") as tf:
for name, content in files.items():
info = tarfile.TarInfo(name)
info.size = len(content)
tf.addfile(info, io.BytesIO(content))
path.write_bytes(
b"!<arch>\n"
+ ar_member("debian-binary", b"2.0\n")
+ ar_member("control.tar.gz", ctrl_buf.getvalue())
+ ar_member("data.tar.gz", data_buf.getvalue())
)
def test_debpackage_unpack_hardened(tmp_path: Path):
"""DebPackage.unpack extracts data members and refuses traversal."""
from pm.package import DebPackage
class _P(DebPackage):
name = "test-deb"
deb = tmp_path / "test.deb"
_build_fake_deb(
deb,
{"Package": "test-deb", "Version": "1.0"},
{"data/data/com.termux/files/usr/bin/tool": b"\x7fELF"},
)
staged = tmp_path / "staged"
staged.mkdir()
_P().unpack(deb, staged, "linux-arm64-bionic")
assert (staged / "data/data/com.termux/files/usr/bin/tool").read_bytes() == b"\x7fELF"
# traversal member must be refused
evil = tmp_path / "evil.deb"
_build_fake_deb(
evil, {"Package": "evil", "Version": "1.0"}, {"../escape": b"x"}
)
with pytest.raises(Exception):
_P().unpack(evil, tmp_path / "staged2", "linux-arm64-bionic")
def test_python_bionic_verify_is_file_evidence(tmp_path: Path):
"""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")
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") == ""
empty = tmp_path / "empty"
empty.mkdir()
assert "missing" in py.verify(empty, "linux-arm64-bionic")
def test_bionic_binary_and_env_contract(tmp_path: Path):
"""On bionic, _BionicDebArm.binary() must return the staged deb's main
binary path (file evidence, no exec), so the base Package.env contract
exposes the tool through PATH like every other pm package."""
from pm.registry import get_package
for name in ("uv", "python", "node"):
pkg = get_package(name)
entry = tmp_path / name
main = entry / pkg.prefix_rel / pkg.main_rel("linux-arm64-bionic")
main.parent.mkdir(parents=True)
main.write_bytes(b"bionic-elf")
binary = pkg.binary(entry, "linux-arm64-bionic")
assert binary == main, f"{name}.binary() on bionic: {binary}"
env = pkg.env(entry, "linux-arm64-bionic")
assert env.get("PATH") == [str(main.parent)], (
f"{name}.env() on bionic does not follow the Package.env PATH contract"
)
def test_stage_only_does_not_record_host_facts(tmp_path, monkeypatch):
"""stage_only publishes the entry but must not touch this machine's
installed facts -- the fact slot belongs to the HOST target."""
pm = _pm()
from pm.ensure import stage_only
from pm.lock import Facts
from pm.paths import facts_path
def snapshot() -> dict:
path = facts_path()
if not path.is_file():
return {}
return Facts(path)._packages
before = snapshot()
entry = stage_only("termux-docker", "linux-arm64-bionic")
after = snapshot()
assert before == after
# termux-docker is a pin_only package: stage_only returns the would-be
# entry path (store root + entry name) without staging bytes.
assert "termux-docker" in str(entry)