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).
221 lines
7.5 KiB
Python
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,
|
|
)
|