221 lines
8.2 KiB
Python
221 lines
8.2 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")
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["python", "uv", "node"])
|
|
def test_bionic_verify_is_file_evidence(tmp_path: Path, monkeypatch, name):
|
|
"""bionic verify never executes the staged binary; presence is the
|
|
contract (the digest already proved the bytes)."""
|
|
from pm.registry import get_package
|
|
|
|
def refuse_exec(*args, **kwargs):
|
|
pytest.fail(f"bionic verification attempted execution: {args}")
|
|
|
|
monkeypatch.setattr("pm.packages.subprocess.run", refuse_exec)
|
|
package = get_package(name)
|
|
bin_rel = Path(package.prefix_rel) / package.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 package.verify(entry, "linux-arm64-bionic") == ""
|
|
empty = tmp_path / "empty"
|
|
empty.mkdir()
|
|
assert "missing" in package.verify(empty, "linux-arm64-bionic")
|
|
|
|
|
|
def test_bionic_binary_and_env_contract(tmp_path: Path):
|
|
"""Bionic binaries retain their staged paths, but only on_path packages
|
|
expose them in the environment; internal uv stays private to PM."""
|
|
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")
|
|
expected_path = [str(main.parent)] if pkg.on_path else None
|
|
assert env.get("PATH") == expected_path, (
|
|
f"{name}.env() on bionic does not follow its on_path declaration"
|
|
)
|
|
if pkg.internal:
|
|
assert "PATH" not in env, f"internal {name} must not leak into public PATH"
|
|
|
|
|
|
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)
|