"""Turn a cloned AUR package's git history into an audit `Change`. paru clones each AUR package as a git repository, so "what changed since I last built this" is a git question, not a scraping question. We record the commit we built at in the AUR state file and diff HEAD against it. A package with no recorded commit has no baseline: we have never built it, or we have lost the record. That is reported as such rather than pretending the current PKGBUILD is unchanged. """ from __future__ import annotations import subprocess from dataclasses import dataclass from pathlib import Path from .audit import Change from .srcinfo import parse_pkgbuild, parse_srcinfo FILES = ("PKGBUILD", ".SRCINFO", ".install") @dataclass(frozen=True) class Commit: sha: str summary: str = "" when: str = "" def git(repo: Path, *args: str, timeout: float = 20.0) -> str: """Run git in a clone dir. Returns stdout, or '' if the command fails.""" try: proc = subprocess.run( ["git", "-C", str(repo), *args], capture_output=True, text=True, errors="replace", timeout=timeout, check=False, ) except (OSError, subprocess.SubprocessError): return "" return proc.stdout if proc.returncode == 0 else "" def show(repo: Path, rev: str, path: str) -> str: return git(repo, "show", f"{rev}:{path}") def head(repo: Path, path: str = "PKGBUILD") -> Commit: """The most recent commit that touched `path`.""" out = git(repo, "log", "-1", "--format=%H%x1f%s%x1f%cI", "--", path) if not out.strip(): return Commit(sha="") parts = out.strip().split("\x1f") return Commit( sha=parts[0], summary=parts[1] if len(parts) > 1 else "", when=parts[2] if len(parts) > 2 else "", ) def history(repo: Path, path: str = "PKGBUILD", limit: int = 2) -> list[Commit]: out = git(repo, "log", f"-{limit}", "--format=%H%x1f%s%x1f%cI", "--", path) commits = [] for line in out.splitlines(): if not line.strip(): continue parts = line.split("\x1f") commits.append( Commit( sha=parts[0], summary=parts[1] if len(parts) > 1 else "", when=parts[2] if len(parts) > 2 else "", ) ) return commits def has_commit(repo: Path, sha: str) -> bool: """Whether the clone still contains this commit. paru may re-clone or garbage-collect a clone dir, so a recorded baseline can legitimately have disappeared; that must read as "no baseline", not as "nothing changed". """ if not sha: return False try: proc = subprocess.run( ["git", "-C", str(repo), "cat-file", "-e", f"{sha}^{{commit}}"], capture_output=True, text=True, errors="replace", timeout=10, check=False, ) except (OSError, subprocess.SubprocessError): return False return proc.returncode == 0 def change_from_clone( clone_dir: Path, package: str, *, old_commit: str = "", old_maintainer: str | None = None, new_maintainer: str | None = None, ) -> Change: """A `Change` describing HEAD against `old_commit`. With no usable baseline the old side is left empty, which the audit treats as "nothing to compare" rather than "nothing changed" -- the difference matters, because the second would silently report a rewrite as a no-op. """ repo = clone_dir / package new_pkgbuild = ( (repo / "PKGBUILD").read_text(errors="replace") if (repo / "PKGBUILD").is_file() else "" ) new_srcinfo = ( (repo / ".SRCINFO").read_text(errors="replace") if (repo / ".SRCINFO").is_file() else "" ) old_pkgbuild = old_srcinfo = "" if old_commit and has_commit(repo, old_commit): old_pkgbuild = show(repo, old_commit, "PKGBUILD") old_srcinfo = show(repo, old_commit, ".SRCINFO") # `.install` scriptlets ship beside the PKGBUILD and run as root, so read # them here, where the clone directory is known. The rules never touch the # filesystem themselves. def scriptlets(pkgbuild_text: str, revision: str) -> dict[str, str]: if not pkgbuild_text: return {} out: dict[str, str] = {} for name in parse_pkgbuild(pkgbuild_text).vars.get("install", []): if not name.endswith(".install"): continue if revision: text = show(repo, revision, name) else: path = repo / name text = path.read_text(errors="replace") if path.is_file() else "" if text: out[name] = text return out return Change( package=package, old_srcinfo=parse_srcinfo(old_srcinfo) if old_srcinfo else None, new_srcinfo=parse_srcinfo(new_srcinfo) if new_srcinfo else None, old_pkgbuild=parse_pkgbuild(old_pkgbuild) if old_pkgbuild else None, new_pkgbuild=parse_pkgbuild(new_pkgbuild) if new_pkgbuild else None, old_maintainer=old_maintainer, new_maintainer=new_maintainer, old_install=scriptlets(old_pkgbuild, old_commit) if old_commit else {}, new_install=scriptlets(new_pkgbuild, ""), ) def change_for_audit( clone_dir: Path, package: str, *, network: bool = True, old_commit: str = "", old_maintainer: str | None = None, new_maintainer: str | None = None, ) -> Change: """A `Change` for auditing, falling back to the AUR when nothing is cloned. paru only clones what it has built. A package it has never built had no PKGBUILD on disk, so every rule examined nothing and the package was reported as "not audited" -- for a file that is one HTTP request away. There is still no old side to compare against, so the result is a NEW_ONLY reading. That is a weaker answer than a comparison, but it is a real answer: the patterns that need no baseline still apply. """ change = change_from_clone( clone_dir, package, old_commit=old_commit, old_maintainer=old_maintainer, new_maintainer=new_maintainer, ) if change.new_pkgbuild is not None or not network: return change from . import fetch body, _error = fetch.pkgbuild(package) if not body: return change old_pkgbuild = change.old_pkgbuild scriptlets: dict[str, str] = {} for name in parse_pkgbuild(body).vars.get("install", []): if not name.endswith(".install"): continue text = show(clone_dir / package, old_commit, name) if old_commit else "" if text: scriptlets[name] = text else: remote_text, _ = fetch.pkgbuild_file(package, name) if remote_text: scriptlets[name] = remote_text return Change( package=package, old_srcinfo=change.old_srcinfo, new_srcinfo=parse_srcinfo(fetch.pkgbuild_file(package, ".SRCINFO")[0] or "") or None, old_pkgbuild=old_pkgbuild, new_pkgbuild=parse_pkgbuild(body), old_maintainer=old_maintainer, new_maintainer=new_maintainer, old_install=change.old_install, new_install=scriptlets, ) # -------------------------------------------------------------------------- # baselines # -------------------------------------------------------------------------- def split_version(version: str) -> tuple[str, str, str]: """`1:2.3.4-5` -> (pkgver, pkgrel, epoch).""" epoch = "" rest = version if ":" in rest: epoch, rest = rest.split(":", 1) pkgrel = "" if "-" in rest: rest, pkgrel = rest.rsplit("-", 1) return rest, pkgrel, epoch def commit_for_version(repo: Path, version: str, *, limit: int = 40) -> str: """The most recent commit whose PKGBUILD declares this exact version. This is what makes a diff possible on a machine that has never run an update: the recorded build baseline does not exist yet, but the version sitting in the package database does, and the AUR git history can be searched for it. A pickaxe search (`-S`) does it in one git call. It falls back to walking the log because `-S` is a text match and a PKGBUILD may spell the version through a variable. """ pkgver, _pkgrel, _epoch = split_version(version) if not pkgver: return "" found = git(repo, "log", "--format=%H", f"-S{pkgver}", "--", "PKGBUILD") for sha in found.split(): # the newest commit that introduced it, confirmed by parsing rather than # trusting the text match shown = show(repo, sha, "PKGBUILD") if shown and declared_version(shown) == version: return sha for commit in history(repo, "PKGBUILD", limit=limit): shown = show(repo, commit.sha, "PKGBUILD") if shown and declared_version(shown) == version: return commit.sha return "" def declared_version(pkgbuild: str) -> str: """The full version a PKGBUILD declares, as `[epoch:]pkgver[-pkgrel]`. Matching on `pkgver` alone is not enough: a maintainer bumps `pkgrel` for a dependency rebuild without touching `pkgver`, so `1.0-1` and `1.0-2` both match the commit that first declared `1.0`. The diff is then taken against a revision the machine never had, and everything the packager changed between the two looks like a fresh finding. """ parsed = parse_pkgbuild(pkgbuild) version = parsed.one("pkgver") if not version: return "" if rel := parsed.one("pkgrel"): version += f"-{rel}" if epoch := parsed.one("epoch"): version = f"{epoch}:{version}" return version def baseline( clone_dir: Path, package: str, installed_version: str = "", recorded_commit: str = "" ) -> tuple[str, str]: """Pick something to diff against. Returns (commit, how) where how is one of "recorded", "version", or "" when nothing usable was found. Recorded wins because it is what you actually reviewed and built last time. The version match is the fallback, and the caller is expected to say which one it used rather than presenting them as equivalent. `how` is one of "recorded", "version", "missing" (no local clone) or "" (a clone exists but the installed version is not in its history). """ repo = clone_dir / package if not repo.is_dir(): # distinct from "the version is not in the history": there is no local # clone at all, so there is nothing here to compare against return "", "missing" if recorded_commit and has_commit(repo, recorded_commit): return recorded_commit, "recorded" if installed_version: sha = commit_for_version(repo, installed_version) if sha: return sha, "version" return "", "" def diff_against(clone_dir: Path, package: str, commit: str) -> tuple[str, str]: """(old PKGBUILD, new PKGBUILD) for a baseline. Either may be empty.""" repo = clone_dir / package new = (repo / "PKGBUILD").read_text(errors="replace") if (repo / "PKGBUILD").is_file() else "" old = show(repo, commit, "PKGBUILD") if commit and has_commit(repo, commit) else "" return old, new