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