git/orchard/main
updater/fetch.py
187 lines7 KB last change raw · history
"""Network fetches, with an on-disk cache.

Two things the front end wants that are not on the machine: an advisory's full
record from the security tracker, and the current PKGBUILD of a package paru has
never downloaded.

Both are cached under `~/.cache/updater`. Advisory records change rarely and the
tracker is a small volunteer service, so hammering it on every keypress would be
rude; the PKGBUILD cache is short-lived because it is expected to change.

Nothing here raises. A fetch that fails is a normal state for a tool that has to
work on a train, so every function returns a reason instead.
"""

from __future__ import annotations

import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

TRACKER = "https://security.archlinux.org"
AUR = "https://aur.archlinux.org"

#: advisories are effectively immutable once published
ADVISORY_TTL = 7 * 24 * 3600
#: a PKGBUILD can change at any moment, so keep it only briefly
PKGBUILD_TTL = 15 * 60

USER_AGENT = "updater/0.1 (+https://aur.archlinux.org)"


def cache_dir() -> Path:
    base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
    return Path(base) / "updater"


def _cached(path: Path, ttl: int) -> str | None:
    try:
        if time.time() - path.stat().st_mtime > ttl:
            return None
        return path.read_text()
    except OSError:
        return None


def _store(path: Path, body: str) -> None:
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        tmp.write_text(body)
        tmp.replace(path)
    except OSError:
        pass  # a cache that cannot be written is not a failure


def get(
    url: str, *, timeout: float = 20, cache: Path | None = None, ttl: int = 0
) -> tuple[str, str]:
    """Fetch a URL. Returns (body, error). Exactly one of the two is empty."""
    if cache is not None and ttl:
        hit = _cached(cache, ttl)
        if hit is not None:
            return hit, ""

    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:  # noqa: S310
            body = response.read().decode("utf-8", errors="replace")
    except urllib.error.HTTPError as exc:
        return "", f"{url} returned HTTP {exc.code}"
    except urllib.error.URLError as exc:
        return "", f"could not reach {urllib.parse.urlsplit(url).netloc}: {exc.reason}"
    except (TimeoutError, OSError) as exc:
        return "", f"could not fetch {url}: {exc}"

    if cache is not None:
        _store(cache, body)
    return body, ""


# --------------------------------------------------------------------------
# security tracker
# --------------------------------------------------------------------------


def advisory_detail(advisory_id: str, *, timeout: float = 20) -> dict:
    """The tracker's own record for one advisory.

    `AVG-1234.json` exists, so this is structured data rather than a page to
    scrape. It carries `affected` and `fixed` versions, the CVE list, any
    references the tracker recorded, and its notes.
    """
    if not advisory_id or "/" in advisory_id:
        return {"id": advisory_id, "available": False, "reason": "not a usable advisory id"}

    path = cache_dir() / "advisories" / f"{advisory_id}.json"
    body, error = get(
        f"{TRACKER}/{urllib.parse.quote(advisory_id)}.json",
        timeout=timeout,
        cache=path,
        ttl=ADVISORY_TTL,
    )
    if error:
        # fall back to a stale copy rather than showing nothing
        if path.is_file():
            try:
                body = path.read_text()
            except OSError:
                return {"id": advisory_id, "available": False, "reason": error}
        else:
            return {"id": advisory_id, "available": False, "reason": error}

    try:
        raw = json.loads(body)
    except ValueError as exc:
        return {
            "id": advisory_id,
            "available": False,
            "reason": f"the tracker returned something unreadable: {exc}",
        }

    if not isinstance(raw, dict):
        return {"id": advisory_id, "available": False, "reason": "unexpected advisory shape"}

    return {
        "id": raw.get("name") or advisory_id,
        "available": True,
        "reason": "",
        "packages": raw.get("packages") or [],
        "status": raw.get("status") or "",
        "severity": raw.get("severity") or "Unknown",
        "kind": raw.get("type") or "",
        "affected": raw.get("affected") or "",
        "fixed": raw.get("fixed") or "",
        "ticket": raw.get("ticket") or "",
        "issues": raw.get("issues") or [],
        "advisories": raw.get("advisories") or [],
        "references": raw.get("references") or [],
        "notes": raw.get("notes") or "",
        "url": f"{TRACKER}/{raw.get('name') or advisory_id}",
    }


# --------------------------------------------------------------------------
# AUR
# --------------------------------------------------------------------------


def pkgbuild_file(package: str, filename: str, *, timeout: float = 20.0) -> tuple[str, str]:
    """One file from an AUR repository, e.g. a `.install` scriptlet.

    cgit serves any tracked path, so the same endpoint that returns the
    PKGBUILD returns everything beside it.
    """
    # Both parts come from the AUR, and the filename comes from the PKGBUILD's
    # own `install=` value. Joined raw, an absolute path resets the join and
    # `..` walks out of the cache directory -- and the fetched body is then
    # written to whatever path that produced.
    name = Path(filename).name
    base = Path(package).name
    if not name or not base or name in {".", ".."} or base in {".", ".."}:
        return "", "the AUR repository named a file this cannot safely cache"
    url = (
        f"https://aur.archlinux.org/cgit/aur.git/plain/"
        f"{urllib.parse.quote(name)}?h={urllib.parse.quote(base)}"
    )
    cache = cache_dir() / "aur" / base / name
    body, error = get(url, timeout=timeout, cache=cache, ttl=PKGBUILD_TTL)
    return (body or "", error or "")


def pkgbuild(package: str, *, timeout: float = 20) -> tuple[str, str]:
    """The current PKGBUILD for a package, straight from the AUR.

    Used when paru has no clone to read: there is no history to diff against,
    but showing the file that will actually be built is still worth doing.
    """
    safe = urllib.parse.quote(package, safe="")
    path = cache_dir() / "pkgbuilds" / f"{safe}.txt"
    return get(
        f"{AUR}/cgit/aur.git/plain/PKGBUILD?h={safe}", timeout=timeout, cache=path, ttl=PKGBUILD_TTL
    )