Files
hermes-agent/pm/termux_libs.py
ethernet 4fbec9c442 feat(pm): repair retired termux pool pins from pm update --termux
The termux-main pool deletes a package's previous archive when it rebuilds, so
the runtime-lib pin table and the bionic lock rows rot without warning. The last
rotation broke a build on eight rows at once, and the stager's concurrent
downloads only surfaced whichever 404 won the race.

pm now owns the pin table it repairs: scripts/termux/runtime_libs.json moves to
pm/termux_runtime_libs.json, so pins live in pm/ and scripts consume them — the
direction scripts/ci/archive_inputs.py already reads pm/lock.json in.

`hermes pm update --termux` repins exactly the rows whose archive the pool has
replaced, hashing each replacement against the index SHA256 before writing
url/version/hash together. `--check` reports without writing and exits 1, so a
retired pin can fail a cheap preflight instead of a payload build.

It is a repair, not an update: an alive pin is never moved, because a repin can
land a rebuilt library under a moved soname and the table is the payload's
recursive DT_NEEDED closure. A pin whose package the pool has dropped outright
is reported and left alone. `--termux` runs alone — names/--target/--uv/--npm
are ignored, since repairing foreign-target pins is not a version resolution.

Verified: `pm update --termux --check` against the live pool reports 89 rows
served; a table deliberately pinned to the retired libiconv 1.18-1 repins to
1.19 with the pool's hash through the real network path; 19 new tests; the
tests/pm, tests/ci and tests/scripts suites have the same failure set as the
base commit (91 pre-existing Windows environment failures, none new).
2026-09-16 11:46:30 -04:00

221 lines
7.5 KiB
Python

"""Termux pool pins: the runtime-lib table and lock.json's bionic rows.
``scripts/termux/stage_runtime_libs.py`` merges their .debs into one flat
``lib/`` inside the sealed Termux payload, and ``scripts/ci/archive_inputs.py``
mirrors the same bytes into R2. Both are chained to the pin table that lives
beside this module.
The pool is rolling: rebuilding a package DELETES the previous archive from
``pool/main/``, so a pin rots silently until a payload build downloads it and
gets a 404. The library set is the payload's recursive DT_NEEDED closure, so a
pin may not simply track "newest" — a repin is a repair:
hermes pm update --termux --check # what the pool no longer serves
hermes pm update --termux # repin exactly those rows
Nothing here resolves versions. "Move to the newest" is a different job with a
different blast radius (a repin can land a rebuilt library under a moved
soname), so an alive pin is left alone even when the pool has moved on.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import urlsplit
from pm.store import hash_url
from pm.update import _get_text
POOL = "https://packages.termux.dev/apt/termux-main/"
POOL_HOST = urlsplit(POOL).hostname
INDEX_URL = f"{POOL}dists/stable/main/binary-aarch64/Packages"
POOL_PATH_PREFIX = "/apt/termux-main/pool/main/"
TABLE_NAME = "termux_runtime_libs.json"
class PinMismatch(RuntimeError):
"""The pool serves bytes the package index does not describe."""
def table_path() -> Path:
return Path(__file__).resolve().parent / TABLE_NAME
def load_table(path: Optional[Path] = None) -> dict:
return json.loads(Path(path or table_path()).read_text(encoding="utf-8-sig"))
def save_table(table: dict, path: Optional[Path] = None) -> None:
Path(path or table_path()).write_text(
json.dumps(table, indent=2) + "\n", encoding="utf-8", newline="\n"
)
# ---------------------------------------------------------------------------
# The pool's own index (pure parse + one cached GET)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class PoolPackage:
name: str
version: str
url: str
sha256: str
_FIELD = re.compile(r"^([A-Za-z][A-Za-z0-9-]*):[ ]?(.*)$")
def parse_index(text: str) -> dict[str, PoolPackage]:
"""Package -> the archive the pool currently serves.
RFC822-ish stanzas: one field per line, indented continuation lines
(long descriptions) ignored. A stanza without a filename or hash is not
installable evidence and is dropped.
"""
pool: dict[str, PoolPackage] = {}
fields: dict[str, str] = {}
for line in [*text.splitlines(), ""]:
if not line.strip():
row = _package(fields)
if row is not None:
pool[row.name] = row
fields = {}
elif not line[0].isspace():
match = _FIELD.match(line)
if match:
fields[match.group(1)] = match.group(2).strip()
return pool
def _package(fields: dict[str, str]) -> Optional[PoolPackage]:
name = fields.get("Package")
filename = fields.get("Filename")
sha256 = fields.get("SHA256")
if not name or not filename or not sha256:
return None
return PoolPackage(name, fields.get("Version", ""), POOL + filename, sha256)
def index() -> dict[str, PoolPackage]:
return parse_index(_get_text(INDEX_URL))
def package_of(url: str) -> Optional[str]:
"""The pool package a pinned URL names, or None for any other source."""
parsed = urlsplit(url)
if parsed.scheme != "https" or parsed.netloc != POOL_HOST:
return None
if not parsed.path.startswith(POOL_PATH_PREFIX):
return None
parts = parsed.path[len(POOL_PATH_PREFIX):].split("/")
if len(parts) != 3 or not all(parts):
return None
return parts[1]
def _filename(url: str) -> str:
return url.rsplit("/", 1)[-1]
# ---------------------------------------------------------------------------
# Pins, and which of them the pool has retired
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Pin:
scope: str # "library", "license", or "tool"
name: str
url: str
version: str
@dataclass(frozen=True)
class Retired:
pin: Pin
# None: the pool no longer carries the package at all — a rename or a
# drop, which needs a human, not a repin.
replacement: Optional[PoolPackage]
def pins(table: dict, lockfile) -> list[Pin]:
"""Every pinned URL the Termux pool serves, from both authorities.
The table owns the payload's libraries and its license package; the
lockfile owns the cross-target tools whose bionic arm is a pool .deb
(python, node, uv). Other lock sources — GitHub tarballs, OCI digests —
are not the pool's business.
"""
out = [
Pin("library", name, row["url"], row.get("version", ""))
for name, row in (table.get("libs") or {}).items()
]
licenses = table.get("licenses")
if licenses:
out.append(Pin("license", "termux-licenses", licenses["url"], licenses.get("version", "")))
for name in lockfile.names():
for target, rows in lockfile.pinned_artifacts(name).items():
for row in rows if isinstance(rows, list) else [rows]:
if isinstance(row, dict) and package_of(row.get("url", "")):
out.append(Pin("tool", f"{name}@{target}", row["url"], lockfile.version(name) or ""))
return out
def retired(table: dict, lockfile, pool: dict[str, PoolPackage]) -> list[Retired]:
"""Pins whose archive the pool has replaced (or dropped)."""
out = []
for pin in pins(table, lockfile):
entry = pool.get(package_of(pin.url) or "")
if entry is None or _filename(entry.url) != _filename(pin.url):
out.append(Retired(pin, entry))
return out
def repair(table: dict, lockfile, stale: list[Retired], *, verify=None) -> int:
"""Repin every retired row to the pool's current archive.
The index's SHA256 is not taken on faith: the replacement's bytes are
fetched and hashed first — the same evidence `pm lock` pins with. Rows
the pool no longer carries are left exactly as they are.
"""
verify = verify or hash_url
applied = 0
for entry in stale:
if entry.replacement is None:
continue
served = verify(entry.replacement.url)
if served != entry.replacement.sha256:
raise PinMismatch(
f"{package_of(entry.pin.url)}: the pool serves {served}, "
f"the index declares {entry.replacement.sha256}"
)
_repoint(table, lockfile, entry)
applied += 1
return applied
def _repoint(table: dict, lockfile, entry: Retired) -> None:
pin, replacement = entry.pin, entry.replacement
if pin.scope == "tool":
name, target = pin.name.split("@", 1)
artifacts = lockfile.pinned_artifacts(name)
rows = artifacts[target]
for row in rows if isinstance(rows, list) else [rows]:
if package_of(row.get("url", "")) == package_of(pin.url):
row["url"], row["sha256"] = replacement.url, replacement.sha256
lockfile.set_pin(name, lockfile.version(name), artifacts)
return
row = table["libs"][pin.name] if pin.scope == "library" else table["licenses"]
row["url"], row["sha256"], row["version"] = (
replacement.url,
replacement.sha256,
replacement.version,
)