git/orchard/main
updater/hooks.py
215 lines8 KB last change raw · history
"""pacman's alpm hooks: the work that happens around a transaction.

A hook is an INI-ish file with one or more `[Trigger]` sections describing when
it fires and an `[Action]` section describing what it runs. They are how a
kernel upgrade rebuilds its initramfs, how systemd notices a unit changed, and
how snapper takes its snapshots -- the things a plan is supposed to tell you
about and currently does not.

Parsed, never executed.
"""

from __future__ import annotations

import fnmatch
from dataclasses import dataclass, field
from pathlib import Path

#: system hooks ship here; local ones override by filename in /etc
HOOK_DIRS = (Path("/usr/share/libalpm/hooks"), Path("/etc/pacman.d/hooks"))

#: `snap-pac` names its snapshots, so a snapshot stage can be recognised
SNAPSHOT_MARKER = "snap-pac"


@dataclass(frozen=True)
class Trigger:
    kind: str  # "Package" or "Path"
    operations: tuple[str, ...]  # Upgrade, Install, Remove
    targets: tuple[str, ...]  # glob patterns

    def matches_name(self, name: str, operation: str = "Upgrade") -> bool:
        if self.kind != "Package":
            return False
        if self.operations and operation not in self.operations:
            return False
        return any(fnmatch.fnmatchcase(name, pat) for pat in self.targets)

    def matches_path(self, path: str, operation: str = "Upgrade") -> bool:
        if self.kind != "Path":
            return False
        if self.operations and operation not in self.operations:
            return False
        # hook paths are relative to the filesystem root, without a leading /
        cleaned = str(path).lstrip("/")
        return any(fnmatch.fnmatchcase(cleaned, pat) for pat in self.targets)


@dataclass
class Hook:
    path: Path
    description: str = ""
    when: str = "PostTransaction"
    exec_: str = ""
    depends: tuple[str, ...] = ()
    triggers: list[Trigger] = field(default_factory=list)
    needs_targets: bool = False
    abort_on_fail: bool = False

    @property
    def is_snapshot(self) -> bool:
        return SNAPSHOT_MARKER in self.exec_.lower() or "snapper" in self.exec_.lower()

    @property
    def label(self) -> str:
        """What to show a person. pacman's own words where it gave any."""
        if self.description:
            return self.description.rstrip(".").strip()
        return self.exec_.split()[0].rsplit("/", 1)[-1] if self.exec_ else self.path.name

    def fires_for(
        self, names: set[str], paths_by_package: dict[str, list[str]], operation: str = "Upgrade"
    ) -> bool:
        """Whether this hook will run for any of the given packages.

        Package triggers are exact. Path triggers are matched against the file
        list of the *installed* version, which is an approximation: a package
        that ships a systemd unit today almost certainly still will, but it is
        the old file list and not the new one.
        """
        for trigger in self.triggers:
            if trigger.kind == "Package":
                if any(trigger.matches_name(n, operation) for n in names):
                    return True
            elif trigger.kind == "Path":
                for name in names:
                    for path in paths_by_package.get(name, ()):
                        if trigger.matches_path(path, operation):
                            return True
        return False


def _sections(path: Path) -> list[tuple[str, dict[str, list[str]]]]:
    """Every section in a hook file, in order, with repeated keys kept.

    Hand-written rather than ConfigParser, for three reasons: a hook may declare
    several `[Trigger]` sections and ConfigParser keeps only one; the flags
    `NeedsTargets` and `AbortOnFail` are bare words with no `=` at all, which
    ConfigParser treats as a parse error and drops the whole file; and keys
    repeat -- a trigger lists `Operation = Upgrade`, `Install` and `Remove` on
    three lines, and keeping only the last one silently changed which
    transactions a hook fires for.
    """
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return []
    out: list[tuple[str, dict[str, list[str]]]] = []
    current: dict[str, list[str]] | None = None
    for raw in text.splitlines():
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        if line.startswith("[") and line.endswith("]"):
            current = {}
            out.append((line[1:-1].strip().lower(), current))
            continue
        if current is None:
            continue
        if "=" in line:
            key, _, value = line.partition("=")
            current.setdefault(key.strip().lower(), []).append(value.strip())
        else:
            current.setdefault(line.lower(), []).append("")
    return out


def _parse(path: Path) -> Hook | None:
    """One hook file, or None if it declares no usable trigger."""
    sections = _sections(path)
    action: dict[str, list[str]] = {}
    for name, body in sections:
        if name == "action":
            action.update(body)
    if not action:
        return None

    hook = Hook(
        path=path,
        description=_first(action, "description") or "",
        when=_first(action, "when") or "PostTransaction",
        exec_=_first(action, "exec") or "",
        depends=tuple(_split(_first(action, "depends"))),
        needs_targets="needstargets" in action,
        abort_on_fail="abortonfail" in action,
    )

    for name, block in sections:
        if name != "trigger":
            continue
        kind = _first(block, "type")
        targets = tuple(_split(_first(block, "target")))
        operations = tuple(_split_all(block.get("operation"))) or ("Upgrade",)
        if kind and targets:
            hook.triggers.append(Trigger(kind=kind, operations=operations, targets=targets))
    if not hook.triggers:
        return None
    return hook


def _first(section: dict[str, list[str]], key: str) -> str | None:
    """The first value for a key. Keys are already lowercased by the reader."""
    values = section.get(key.lower())
    return values[0] if values else None


def _split_all(values: list[str] | None) -> list[str]:
    out: list[str] = []
    for value in values or ():
        out.extend(_split(value))
    return out


def _split(value: str | None) -> list[str]:
    if not value:
        return []
    return [part for part in value.replace(",", " ").split() if part]


def load(dirs: tuple[Path, ...] = HOOK_DIRS) -> list[Hook]:
    """Every readable hook, later directories overriding earlier ones by name.

    That is what pacman does: a hook in /etc/pacman.d/hooks with the same
    filename replaces the one the package shipped.
    """
    seen: dict[str, Hook] = {}
    order: list[str] = []
    for directory in dirs:
        try:
            entries = sorted(directory.glob("*.hook"))
        except OSError:
            continue
        for path in entries:
            hook = _parse(path)
            if hook is None:
                continue
            if path.name not in seen:
                order.append(path.name)
            seen[path.name] = hook
    return [seen[name] for name in order]


def scheduled(
    hooks: list[Hook],
    names: set[str],
    paths_by_package: dict[str, list[str]] | None = None,
    *,
    operation: str = "Upgrade",
) -> tuple[list[Hook], list[Hook]]:
    """Split the hooks that will run into (before the transaction, after it)."""
    paths = paths_by_package or {}
    firing = [h for h in hooks if h.fires_for(names, paths, operation)]
    before = [h for h in firing if h.when.lower().startswith("pre")]
    after = [h for h in firing if not h.when.lower().startswith("pre")]
    return before, after