"""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)