git/orchard/main
updater/repo.py
407 lines14 KB last change raw · history
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
"""Repo-side updates, read straight out of libalpm.

No parsing. pyalpm hands back versions, sizes, descriptions, install reasons and
dependency lists directly, which is what makes it possible to render a plan that
does not lie about what is about to happen.

The one thing pyalpm does not do for us is register the sync databases, so the
repo list is read from pacman.conf first.
"""

from __future__ import annotations

import contextlib
import fnmatch
import re
import shutil
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path

import pyalpm

from .srcinfo import dep_name

DEFAULT_CONF = "/etc/pacman.conf"
REASON_EXPLICIT = 0
REASON_DEPEND = 1


@dataclass(frozen=True)
class Update:
    """One package moving from an installed version to a sync version."""

    name: str
    old: str
    new: str
    repo: str
    download: int = 0
    installed_old: int = 0
    installed_new: int = 0
    desc: str = ""

    @property
    def delta(self) -> int:
        """Change in on-disk size. Negative when the update shrinks the package."""
        return self.installed_new - self.installed_old

    @property
    def is_downgrade(self) -> bool:
        return pyalpm.vercmp(self.new, self.old) < 0


@dataclass
class Plan:
    """What `pacman -Su` would actually do."""

    updates: list[Update] = field(default_factory=list)
    #: newer in the db but held back by IgnorePkg
    ignored: list[str] = field(default_factory=list)

    # NOTE: there is deliberately no `removals` member. A package with no sync db
    # entry is a *foreign* package (AUR), which `pacman -Su` leaves alone; and a
    # package genuinely dropped from a repo cannot be detected at all, because
    # pacman records only what is in a database now. Reporting either as a
    # removal would be a lie. `foreign()` is the honest accessor.

    def __len__(self) -> int:
        return len(self.updates)

    @property
    def download_total(self) -> int:
        return sum(u.download for u in self.updates)

    @property
    def installed_delta(self) -> int:
        return sum(u.delta for u in self.updates)

    @property
    def repos(self) -> list[str]:
        return sorted({u.repo for u in self.updates})


@dataclass(frozen=True)
class Config:
    repos: tuple[str, ...]
    ignore_pkg: frozenset[str]
    ignore_group: frozenset[str]


def read_conf(path: str | Path = DEFAULT_CONF) -> Config:
    """Parse the bits of pacman.conf we need: repo sections and ignore lists.

    `Include` is deliberately not followed; it points at mirrorlists, which have
    no bearing on which databases to register.
    """
    repos: list[str] = []
    ignore_pkg: set[str] = set()
    ignore_group: set[str] = set()
    try:
        text = Path(path).read_text(errors="replace")
    except OSError:
        return Config(("core", "extra"), frozenset(), frozenset())

    section = None
    for raw in text.splitlines():
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        header = re.match(r"^\[(.+)]$", line)
        if header:
            name = header.group(1)
            section = None if name == "options" else name
            if section and section not in repos:
                repos.append(section)
            continue
        if section is None:
            key, _, value = line.partition("=")
            key = key.strip()
            for part in value.split():
                if key == "IgnorePkg":
                    ignore_pkg.add(part)
                elif key == "IgnoreGroup":
                    ignore_group.add(part)
    return Config(tuple(repos), frozenset(ignore_pkg), frozenset(ignore_group))


@contextlib.contextmanager
def connect(root: str = "/", dbpath: str = "/var/lib/pacman", conf: str | Path = DEFAULT_CONF):
    """An alpm handle with every configured repo registered.

    Without the explicit register_syncdb calls the handle silently reports zero
    sync databases, and therefore zero updates, which looks exactly like a
    system that is fully up to date.
    """
    config = read_conf(conf)
    handle = pyalpm.Handle(root, dbpath)
    for name in config.repos:
        try:
            handle.register_syncdb(name, pyalpm.SIG_DATABASE_OPTIONAL)
        except Exception:  # noqa: BLE001, S112 - a missing optional repo is not fatal
            continue
    for path_ in ("/var/cache/pacman/pkg",):
        with contextlib.suppress(Exception):
            handle.add_cachedir(path_)
    _register_ignores(handle, config)
    try:
        yield handle
    finally:
        del handle


def _register_ignores(handle, config: Config) -> None:
    """One owner for the ignore lists.

    `connect()` registered them and `preview()` did not, and the plan is built
    from the preview handle -- so `_is_ignored` always found an empty list and
    every IgnorePkg package was offered as a pending upgrade while the apply,
    which does use the preview handle, would have held it back.
    """
    for name in config.ignore_pkg:
        with contextlib.suppress(Exception):
            handle.add_ignorepkg(name)
    for name in config.ignore_group:
        with contextlib.suppress(Exception):
            handle.add_ignoregrp(name)


def _best_versions(handle) -> dict[str, object]:
    """Newest version of each package across all sync dbs."""
    best: dict[str, object] = {}
    for db in handle.get_syncdbs():
        for pkg in db.pkgcache:
            cur = best.get(pkg.name)
            if cur is None or pyalpm.vercmp(pkg.version, cur.version) > 0:
                best[pkg.name] = pkg
    return best


def plan(handle) -> Plan:
    """What a system upgrade would do, computed against the *current* sync dbs.

    This must be called after the dbs have been refreshed, otherwise it reports
    the truth about stale data, which is worse than reporting nothing.
    """
    best = _best_versions(handle)
    local = handle.get_localdb()
    result = Plan()

    for lp in local.pkgcache:
        sp = best.get(lp.name)
        if sp is None:
            continue  # foreign (AUR); `pacman -Su` does not touch it
        if pyalpm.vercmp(sp.version, lp.version) <= 0:
            continue
        if _is_ignored(handle, sp):
            result.ignored.append(lp.name)
            continue
        result.updates.append(
            Update(
                name=lp.name,
                old=lp.version,
                new=sp.version,
                repo=sp.db.name,
                download=getattr(sp, "download_size", 0),
                # `isize` is the installed size. `size` is NOT interchangeable:
                # pyalpm reports 0 for it on a local package and the *download*
                # size on a sync one, so using it made every delta look like a
                # full-size increase.
                installed_old=getattr(lp, "isize", 0),
                installed_new=getattr(sp, "isize", 0),
                desc=getattr(sp, "desc", "") or "",
            )
        )

    result.updates.sort(key=lambda u: (u.repo, u.name))
    result.ignored.sort()
    return result


def _is_ignored(handle, pkg) -> bool:
    """`IgnorePkg` and `IgnoreGroup` are shell glob patterns, not literals.

    pacman.conf documents both as accepting globs and hands the patterns to
    libalpm, which fnmatches them. Comparing for equality meant `IgnorePkg =
    linux*` matched nothing here and the plan listed packages pacman would
    refuse to touch.
    """
    if any(fnmatch.fnmatchcase(pkg.name, pat) for pat in (handle.ignorepkgs or [])):
        return True
    groups = getattr(pkg, "groups", []) or []
    return any(
        fnmatch.fnmatchcase(group, pat) for group in groups for pat in (handle.ignoregrps or [])
    )


def foreign(handle) -> list[str]:
    """Installed packages with no sync db entry: the AUR set, in practice."""
    known = set(_best_versions(handle))
    return sorted(p.name for p in handle.get_localdb().pkgcache if p.name not in known)


def explicit(handle) -> list[str]:
    return sorted(p.name for p in handle.get_localdb().pkgcache if p.reason == REASON_EXPLICIT)


def installed(handle) -> dict[str, str]:
    """name -> version for everything installed. Useful for cross-checks."""
    return {p.name: p.version for p in handle.get_localdb().pkgcache}


def _satisfies(pkg, dep: str) -> bool:
    """Whether `pkg` satisfies a dependency string, per libalpm itself.

    Delegated rather than reimplemented. Version constraints on `provides` are
    load-bearing: ffmpeg4.4 provides `libavcodec.so=58-64` while callers require
    `=63-64`, so a name-only comparison wrongly reports it as required.
    """
    try:
        return pyalpm.find_satisfier([pkg], dep) is not None
    except Exception:  # noqa: BLE001 - an unparsable dep string satisfies nothing
        return False


def orphans(handle, *, include_optional: bool = False) -> list[str]:
    """Dependency-installed packages that nothing installed requires.

    Matches pacman's two levels of strictness:

      include_optional=False  == `pacman -Qtdq`   (an *optional* dependency of an
                              installed package counts as a reason to keep it)
      include_optional=True   == `pacman -Qttdq`  (only hard dependencies count)

    The distinction is not cosmetic: on a real system the two answers differ by
    every package that anything merely *suggests*.
    """
    packages = list(handle.get_localdb().pkgcache)

    # Index every package under the names it could satisfy, so a dependency only
    # has to be tested against the handful of packages that could match it.
    by_name: dict[str, list] = {}
    for pkg in packages:
        by_name.setdefault(pkg.name, []).append(pkg)
        for spec in getattr(pkg, "provides", []) or []:
            by_name.setdefault(dep_name(spec), []).append(pkg)

    specs: set[str] = set()
    for pkg in packages:
        specs.update(getattr(pkg, "depends", []) or [])
        if not include_optional:
            specs.update(getattr(pkg, "optdepends", []) or [])

    required: set[str] = set()
    for spec in specs:
        for candidate in by_name.get(dep_name(spec), ()):
            if _satisfies(candidate, spec):
                required.add(candidate.name)

    return sorted(
        pkg.name
        for pkg in packages
        if pkg.reason == pyalpm.PKG_REASON_DEPEND and pkg.name not in required
    )


# --------------------------------------------------------------------------
# refreshing the databases
# --------------------------------------------------------------------------

SYNC_TIMEOUT = 300


def sync_db_age(dbpath: str = "/var/lib/pacman") -> float | None:
    """Seconds since the newest sync database was written, or None."""
    try:
        stamps = [p.stat().st_mtime for p in (Path(dbpath) / "sync").glob("*.db")]
    except OSError:
        return None
    return time.time() - max(stamps) if stamps else None


def _sync_argv(dbdir: str) -> list[str]:
    """`pacman -Sy` into an alternate dbpath, exactly as checkupdates does it.

    `fakeroot` matters: pacman's sandbox refuses files it does not own, and the
    databases it writes would otherwise be owned by the invoking user.
    """
    args = ["pacman", "-Sy", "--noconfirm", "--dbpath", dbdir, "--logfile", "/dev/null"]
    if shutil.which("fakeroot"):
        return ["fakeroot", "--", args[0], "--disable-sandbox-filesystem", *args[1:]]
    return args


@contextlib.contextmanager
def preview(
    conf: str | Path = DEFAULT_CONF,
    dbpath: str = "/var/lib/pacman",
    *,
    sync: bool = True,
    timeout: int = SYNC_TIMEOUT,
):
    """A handle whose sync databases were refreshed into a throwaway directory.

    This is what lets `updater plan` be accurate without asking for a password:
    the real databases are never touched, and the local database is symlinked in
    so installed versions are still visible.

    Yields (handle, refreshed, note).
    """
    config = read_conf(conf)
    refreshed = False
    note = ""

    with tempfile.TemporaryDirectory(prefix="updater-sync-") as dbdir:
        local = Path(dbpath) / "local"
        if local.is_dir():
            with contextlib.suppress(OSError):
                (Path(dbdir) / "local").symlink_to(local, target_is_directory=True)

        if sync:
            try:
                proc = subprocess.run(
                    _sync_argv(dbdir),
                    capture_output=True,
                    text=True,
                    errors="replace",
                    timeout=timeout,
                    check=False,
                )
            except (OSError, subprocess.SubprocessError) as exc:
                note = f"could not refresh databases: {exc}"
            else:
                refreshed = proc.returncode == 0
                if not refreshed:
                    detail = (proc.stderr or proc.stdout).strip().splitlines()
                    note = detail[0] if detail else f"pacman -Sy exited {proc.returncode}"
        else:
            note = "database refresh skipped"

        if not refreshed:
            # fall back to the real databases rather than reporting nothing
            yield from _fallback(config, dbpath, refreshed, note)
            return

        handle = pyalpm.Handle("/", dbdir)
        for name in config.repos:
            with contextlib.suppress(Exception):
                handle.register_syncdb(name, pyalpm.SIG_DATABASE_OPTIONAL)
        _register_ignores(handle, config)
        try:
            yield handle, refreshed, note
        finally:
            del handle


def _fallback(config: Config, dbpath: str, refreshed: bool, note: str):
    handle = pyalpm.Handle("/", dbpath)
    for name in config.repos:
        with contextlib.suppress(Exception):
            handle.register_syncdb(name, pyalpm.SIG_DATABASE_OPTIONAL)
    _register_ignores(handle, config)
    try:
        yield handle, refreshed, note
    finally:
        del handle