git/orchard/main
updater/detail.py
345 lines12 KB last change raw · history
"""Assemble the payload behind a drill-down.

Fetched lazily, one package at a time, rather than folded into the plan: a diff
is large, and shipping every diff with every plan would make the common case --
just looking at what is pending -- pay for the rare case.

Everything here is read-only and static. Nothing sources a PKGBUILD; see srcinfo.
"""

from __future__ import annotations

import difflib
import time
from pathlib import Path

from . import changes, fetch
from .audit import audit
from .aur import AurPackage
from .render.base import human_bytes
from .security import Advisory

#: enough context to be readable without dumping the whole file
DIFF_CONTEXT = 3
#: refuse to send an enormous diff; the caller shows a reference listing instead
MAX_DIFF_LINES = 400


def diff_lines(old: str, new: str, *, context: int = DIFF_CONTEXT) -> list[dict]:
    """Structured hunks: kind, old line number, new line number, text."""
    old_lines = old.splitlines()
    new_lines = new.splitlines()
    out: list[dict] = []
    matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
    for group in matcher.get_grouped_opcodes(context):
        if out:
            out.append({"kind": "gap", "old": None, "new": None, "text": ""})
        for tag, i1, i2, j1, j2 in group:
            if tag == "equal":
                for offset, text in enumerate(old_lines[i1:i2]):
                    out.append(
                        {
                            "kind": "ctx",
                            "old": i1 + offset + 1,
                            "new": j1 + offset + 1,
                            "text": text,
                        }
                    )
            else:
                for offset, text in enumerate(old_lines[i1:i2]):
                    out.append({"kind": "del", "old": i1 + offset + 1, "new": None, "text": text})
                for offset, text in enumerate(new_lines[j1:j2]):
                    out.append({"kind": "add", "old": None, "new": j1 + offset + 1, "text": text})
    return out


def _counts(lines: list[dict]) -> tuple[int, int]:
    added = sum(1 for line in lines if line["kind"] == "add")
    removed = sum(1 for line in lines if line["kind"] == "del")
    return added, removed


def _meta_dict(meta: AurPackage | None) -> dict:
    if meta is None:
        return {}
    return {
        "maintainer": meta.maintainer or "",
        "orphaned": meta.is_orphaned,
        "out_of_date": meta.out_of_date,
        "votes": meta.votes,
        "popularity": round(meta.popularity, 2),
        "first_submitted": meta.first_submitted,
        "last_modified": meta.last_modified,
        "url": meta.url,
        "package_base": meta.package_base,
        "depends": list(meta.depends),
        "make_depends": list(meta.make_depends),
    }


def repo_package_detail(
    package: str,
    *,
    installed: str = "",
    meta: dict | None = None,
    plan=None,
    advisories: list | None = None,
    dependents: list[str] | None = None,
    hooks: list | None = None,
) -> dict:
    """The drill-down for a package that comes from a repository, not the AUR.

    There is no PKGBUILD to diff -- a repository package is a built artefact.
    What is worth knowing instead is what it is, who built it, what it needs,
    what needs it, and what will run when it is installed.
    """
    m = meta or {}
    old = installed or ""
    new = m.get("version", "")
    size_now = int(m.get("isize") or 0)
    download = int(m.get("download_size") or 0)

    facts: list[tuple[str, str]] = []
    if m.get("repo"):
        facts.append(("repository", m["repo"]))
    if m.get("desc"):
        facts.append(("summary", m["desc"]))
    if m.get("base") and m["base"] != package:
        facts.append(("built from", m["base"]))
    if m.get("packager"):
        facts.append(("packager", m["packager"]))
    if m.get("builddate"):
        facts.append(("built", time.strftime("%Y-%m-%d", time.localtime(m["builddate"]))))
    if m.get("url"):
        facts.append(("upstream", m["url"]))
    if m.get("licenses"):
        facts.append(("licence", ", ".join(m["licenses"])))
    if m.get("groups"):
        facts.append(("group", ", ".join(m["groups"])))
    if new and old and new != old:
        facts.append(("version", f"{old} -> {new}"))
    elif new:
        facts.append(("version", new))
    if download:
        facts.append(("download", human_bytes(download)))
    if size_now:
        facts.append(("installed", human_bytes(size_now)))

    sections: list[dict] = [{"title": "PACKAGE", "fields": facts}]

    def add(title: str, items: list[str], *, limit: int = 0) -> None:
        if not items:
            return
        if limit and len(items) > limit:
            shown = items[:limit]
            shown.append(f"... and {len(items) - limit} more ({len(items)} in total)")
            items = shown
        sections.append({"title": title, "items": items})

    add("DEPENDS ON", list(m.get("depends") or []))
    add("OPTIONAL", list(m.get("optdepends") or []))
    # glibc is required by most of the system; a wall of names helps nobody
    add("REQUIRED BY", list(dependents or []), limit=14)
    add("PROVIDES", list(m.get("provides") or []))
    add("CONFLICTS", list(m.get("conflicts") or []))
    add("REPLACES", list(m.get("replaces") or []))
    if m.get("sha256sum"):
        sections.append({"title": "INTEGRITY", "fields": [("sha256", m["sha256sum"])]})

    if hooks:
        sections.append(
            {
                "title": "HOOKS THAT WILL RUN",
                "items": [f"{h.label}   ({h.path.name})" for h in hooks],
            }
        )

    if advisories:
        sections.append(
            {
                "title": "ADVISORIES",
                "items": [
                    f"{a.id}  {a.severity}  {a.fixed or 'no fix available'}" for a in advisories
                ],
            }
        )

    in_plan = False
    if plan is not None:
        in_plan = any(u.name == package for u in getattr(plan, "updates", []))

    notes: list[str] = []
    if not new:
        notes.append(
            f"{package} is installed but is not in any repository "
            "database. It may have been dropped, or built locally."
        )
    elif not old:
        notes.append(f"{package} is not currently installed.")
    if in_plan:
        notes.append("This package is in the plan below and will be upgraded.")

    return {
        "package": package,
        "kind": "repo",
        "installed": old,
        "available": new,
        "verdict": "repo",
        "baseline_note": "",
        "fetch_note": "",
        "diff": {
            "lines": [],
            "added": 0,
            "removed": 0,
            "truncated": False,
            "available": False,
            "note": "a repository package is built, not scripted: there is no PKGBUILD to diff",
        },
        "findings": [],
        "meta": {},
        "sections": sections,
        "notes": notes,
        # objects, not ids: the overlay decodes the same shape for a
        # repository package as for an AUR one, and a list of strings made
        # every repository drill-down fail to parse.
        "advisories": [advisory_detail(a) for a in (advisories or [])],
        "in_plan": in_plan,
    }


def package_detail(
    clone_dir: Path,
    package: str,
    *,
    installed: str = "",
    available: str = "",
    meta: AurPackage | None = None,
    previous_maintainer: str | None = None,
    recorded_commit: str = "",
    advisories: list[Advisory] | None = None,
    kind: str = "aur",
    network: bool = True,
) -> dict:
    """Everything the drill-down overlay needs for one package."""
    state_baseline, how = changes.baseline(clone_dir, package, installed, recorded_commit)
    old_text, new_text = changes.diff_against(clone_dir, package, state_baseline)
    baseline = changes.head(clone_dir / package)

    lines: list[dict] = []
    added = removed = 0
    truncated = False
    if old_text and new_text:
        lines = diff_lines(old_text, new_text)
        # Count the whole change before trimming: a truncated payload reports
        # "+N -M" for the change, and counting the surviving slice instead
        # understates it -- "40 lines changed" reading as "+0 -4".
        added, removed = _counts(lines)
        if len(lines) > MAX_DIFF_LINES:
            lines = lines[:MAX_DIFF_LINES]
            truncated = True

    # the audit compares the same two revisions the diff does, so the findings
    # and the diff cannot disagree about what changed
    change = changes.change_from_clone(
        clone_dir,
        package,
        old_commit=state_baseline,
        old_maintainer=previous_maintainer,
        new_maintainer=meta.maintainer if meta else None,
    )
    report = audit(change)

    notes = {
        "recorded": "compared against the revision this tool last built",
        "version": f"compared against the revision matching installed {installed}"
        " (reconstructed from AUR git history)",
        "missing": "paru has not downloaded this package",
        "": f"installed {installed or 'version'} is not in this package's history",
    }

    fetched = ""
    fetch_note = ""
    if not new_text and network:
        # paru has no clone for this package, so there is no history to diff.
        # Showing the file that will actually be built is still worth doing.
        body, error = fetch.pkgbuild(package)
        if body:
            fetched = body
            fetch_note = "paru has not downloaded this package"
        elif error:
            fetch_note = f"paru has not downloaded this package — {error}"

    payload = {
        "package": package,
        "kind": kind,
        "installed": installed,
        "available": available,
        "current_commit": baseline.sha,
        "current_commit_summary": baseline.summary,
        "current_commit_when": baseline.when,
        "baseline_commit": state_baseline,
        "baseline_how": how,
        "baseline_note": fetch_note or notes.get(how, ""),
        "fetch_note": fetch_note,
        "diff": {"lines": lines, "added": added, "removed": removed, "truncated": truncated},
        "findings": [
            {
                "rule": f.rule,
                "severity": f.severity.name,
                "title": f.title,
                "evidence": f.evidence,
                "where": f.where,
            }
            for f in report.findings
        ],
        "verdict": report.verdict.name,
        "meta": _meta_dict(meta),
        "advisories": [
            {
                "id": a.id,
                "severity": a.severity,
                "fixed": a.fixed or "",
                "status": a.status,
                "kind": a.kind,
                "url": a.url,
                "issues": list(a.issues),
            }
            for a in (advisories or [])
        ],
    }
    if not lines:
        # no baseline: hand over a file as a reference listing so the overlay
        # shows something useful rather than an empty diff
        reference = new_text or fetched
        if reference:
            payload["pkgbuild"] = reference.splitlines()
    return payload


def advisory_detail(advisory: Advisory) -> dict:
    """A security advisory on its own, for the CVE overlay."""
    return {
        "id": advisory.id,
        "severity": advisory.severity,
        "status": advisory.status,
        "kind": advisory.kind,
        "fixed": advisory.fixed or "",
        "url": advisory.url,
        "packages": list(advisory.packages),
        "issues": list(advisory.issues),
    }


def summary_line(payload: dict) -> str:
    """One line describing a package's change, used as a list subtitle."""
    parts = []
    diff = payload.get("diff") or {}
    if diff.get("added") or diff.get("removed"):
        parts.append(f"+{diff.get('added', 0)} -{diff.get('removed', 0)}")
    loud = [f for f in payload.get("findings", []) if f["severity"] != "INFO"]
    if loud:
        parts.append(f"{len(loud)} finding(s)")
    if payload.get("advisories"):
        parts.append(f"{len(payload['advisories'])} advisory(ies)")
    return " · ".join(parts)