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