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.
201 lines
7.4 KiB
Python
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
|