Files
hermes-agent/tests/scripts/test_termux_runtime_libs.py
ethernet cb7c688171 test: mirror the source tree for 27 misfiled root tests; one PM home fixture
Root-level tests/ is for root-level modules; 19 files testing scripts/, 3 testing
pm/ and 5 testing hermes_cli/ move to the mirrored directory (workflow file lists
and cross-imports updated; path arithmetic bumped one level).

tests/pm gains a conftest with the isolated_machine_home fixture that seven
modules had copy-pasted verbatim.
2026-09-18 20:13:26 -04:00

201 lines
7.4 KiB
Python

"""Tests for scripts/termux/stage_runtime_libs.py — cache correctness.
No filesystem mocks: a real tiny .deb fixture (ar archive + data.tar) is
served over loopback HTTP and fetched through pm's hardened Download,
then unpacked through pm's DebPackage — the same production path.
"""
from __future__ import annotations
import hashlib
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from tests.termux_fixtures import build_deb
import sys
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT))
sys.path.insert(0, str(REPO_ROOT / "scripts" / "termux"))
import stage_runtime_libs as srl # noqa: E402
PREFIX = srl.PREFIX_REL
# ---------------------------------------------------------------- fixtures
def _build_deb(path: Path, lib_name: str, content: bytes) -> None:
build_deb(path, {"Package": f"pkg-{lib_name}", "Version": "1.0"},
{f"{PREFIX}/lib/{lib_name}": f"FAKE-ELF {lib_name}\n".encode() + content})
class _Server:
"""Serves files from a dir over loopback HTTP."""
def __init__(self, root: Path) -> None:
self.root = root
self.requests = []
self.available = True
owner = self
class H(BaseHTTPRequestHandler):
def do_GET(self):
owner.requests.append(self.path)
if not owner.available:
self.send_error(503)
return
f = self.server.root / self.path.lstrip("/") # type: ignore[attr-defined]
if not f.is_file():
self.send_error(404)
return
body = f.read_bytes()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.send_header("Accept-Ranges", "none")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
self.httpd.root = root # type: ignore[attr-defined]
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
@property
def url(self) -> str:
host, port = self.httpd.server_address[:2]
return f"http://{host}:{port}"
def stop(self):
self.httpd.shutdown()
self.httpd.server_close()
self.thread.join(timeout=5)
@pytest.fixture()
def lib_source(tmp_path: Path):
"""Two packages; libb.so shared by both (identical bytes). Returns
(server, table, files) where files maps staged soname -> bytes."""
src = tmp_path / "debs"
src.mkdir()
shared = b"shared-impl-v1"
_build_deb(src / "liba.deb", "liba.so", b"impl-a")
_build_deb(src / "libb.deb", "libb.so", shared)
_build_deb(src / "libc.deb", "libb.so", shared) # identical collision
server = _Server(src)
table = {
"liba": {"url": f"{server.url}/liba.deb",
"sha256": hashlib.sha256((src / "liba.deb").read_bytes()).hexdigest(),
"version": "1.0"},
"libb": {"url": f"{server.url}/libb.deb",
"sha256": hashlib.sha256((src / "libb.deb").read_bytes()).hexdigest(),
"version": "2.0"},
"libc": {"url": f"{server.url}/libc.deb",
"sha256": hashlib.sha256((src / "libc.deb").read_bytes()).hexdigest(),
"version": "3.0"},
}
yield server, table
server.stop()
def _stage(tmp_path: Path, table: dict) -> Path:
return srl.stage(tmp_path / "payload", table)
# ------------------------------------------------------------------ tests
@pytest.mark.parametrize("corruption", ["missing", "extra", "stale"])
def test_stage_cache_correctness(tmp_path, lib_source, corruption):
"""Initial stage, true cache hit with the source gone, and rebuild on
missing / extra / corrupted output."""
server, table = lib_source
out = _stage(tmp_path, table)
names = {"liba.so", "libb.so"}
assert {p.name for p in out.glob("*.so*")} == names
manifest_path = out.parent / "manifest.json"
manifest = json.loads(manifest_path.read_text())
manifest_path.write_bytes(b"\xef\xbb\xbf" + manifest_path.read_bytes())
# Same URL/table throughout: corruption must invalidate output evidence,
# not accidentally trigger the independent table-identity check.
server.available = False
import shutil
shutil.rmtree(tmp_path / "payload" / ".work")
requests = list(server.requests)
assert srl.stage(tmp_path / "payload", table) == out
assert server.requests == requests
server.available = True
if corruption == "missing":
(out / "liba.so").unlink()
elif corruption == "extra":
(out / "libjunk.so").write_bytes(b"bogus")
else:
(out / "libb.so").write_bytes(b"corrupted bytes")
assert srl.stage(tmp_path / "payload", table) == out
assert len(server.requests) > len(requests)
assert {p.name for p in out.glob("*.so*")} == names
for name in names:
assert hashlib.sha256((out / name).read_bytes()).hexdigest() == \
manifest["files"][name]
def test_collision_identical_ok_conflicting_raises(tmp_path, lib_source):
"""Co-installed packages may share a soname only with identical bytes."""
server, table = lib_source
out = _stage(tmp_path, table)
# libb + libc both ship libb.so with identical bytes: merged once.
assert (out / "libb.so").read_bytes().startswith(b"FAKE-ELF libb.so")
# Now a conflicting rebuild: same soname, different bytes.
_build_deb(tmp_path / "debs" / "libc.deb", "libb.so", b"DIFFERENT-IMPL")
table["libc"]["sha256"] = hashlib.sha256(
(tmp_path / "debs" / "libc.deb").read_bytes()).hexdigest()
# Force a miss: current manifest no longer validates for libc's bytes.
with pytest.raises(srl.StageError, match="soname collision.*libb.so"):
srl.stage(tmp_path / "payload", table)
assert not (out.parent / "manifest.json").exists()
def test_rebuild_removes_superseded_license_files(tmp_path, lib_source):
_, table = lib_source
out = _stage(tmp_path, table)
stale = out.parent / "share/doc/obsolete/copyright"
stale.parent.mkdir(parents=True)
stale.write_text("old package notice", encoding="utf-8")
table["liba"]["version"] = "next"
srl.stage(tmp_path / "payload", table)
assert not stale.exists()
assert (out / "liba.so").is_file()
def test_failed_download_identifies_the_pin_without_publishing(tmp_path, lib_source):
_, table = lib_source
(tmp_path / "debs/libb.deb").unlink()
payload = tmp_path / "payload"
with pytest.raises(srl.StageError) as caught:
srl.stage(payload, table)
message = str(caught.value)
assert "libb" in message and table["libb"]["version"] in message
assert table["libb"]["url"] in message
assert "404" in message
assert not (payload / "runtime-libs/manifest.json").exists()
def test_stale_scratch_cannot_poison_a_rebuilt_cache(tmp_path, lib_source):
_, table = lib_source
out = _stage(tmp_path, table)
expected = (out / "liba.so").read_bytes()
scratch = tmp_path / "payload/.work/runtime-libs/extract/liba" / PREFIX / "lib/liba.so"
scratch.write_bytes(b"corrupted scratch bytes")
(out / "liba.so").unlink()
srl.stage(tmp_path / "payload", table)
assert (out / "liba.so").read_bytes() == expected