"""Hygiene: the things that rot quietly between updates.
Everything here is readable without root, so the tool can report honestly before
it ever asks for a password. Where a check would need elevation it degrades to
"unknown" rather than guessing.
"""
from __future__ import annotations
import os
import platform
import time
from dataclasses import dataclass, field
from pathlib import Path
CACHE_DIR = Path("/var/cache/pacman/pkg")
ETC = Path("/etc")
MODULES = Path("/usr/lib/modules")
CONFIG_SUFFIXES = (".pacnew", ".pacsave", ".pacorig")
#: package name prefixes whose update means the running kernel or its modules no
#: longer match what is on disk
REBOOT_PREFIXES = ("linux", "nvidia", "virtualbox", "zfs", "openzfs", "bbswitch")
def parse_cache_name(filename: str) -> tuple[str, str] | None:
"""`(name, version)` for a cached package file, or None if unrecognised.
`<name>-<pkgver>-<pkgrel>-<arch>.pkg.tar.<ext>`, split from the right. A
pkgver containing a dash would defeat this, which is why pacman discourages
them.
"""
stem = filename
for marker in (".pkg.tar.zst", ".pkg.tar.xz", ".pkg.tar.gz", ".pkg.tar"):
if stem.endswith(marker):
stem = stem[: -len(marker)]
break
else:
if not stem.endswith(".part"):
return None
stem = stem[: -len(".part")]
parts = stem.rsplit("-", 3)
if len(parts) != 4:
return None
name, version, _rel, _arch = parts
return (name, f"{version}-{_rel}") if name else None
@dataclass
class CacheUsage:
total: int = 0
reclaimable: int = 0
files: int = 0
reclaimable_files: int = 0
keep: int = 3
@property
def has_reclaimable(self) -> bool:
return self.reclaimable > 0
def incomplete_downloads(cache_dir: Path = CACHE_DIR) -> Leftovers:
"""Count pacman's abandoned `download-*` scratch files.
Never raises: an unreadable cache directory is a normal state.
"""
try:
entries = list(cache_dir.iterdir())
except OSError:
return Leftovers()
result = Leftovers()
for entry in entries:
if not entry.name.startswith("download-"):
continue
try:
info = entry.stat()
except OSError:
continue
result.count += 1
result.bytes += info.st_size
if result.oldest == 0.0 or info.st_mtime < result.oldest:
result.oldest = info.st_mtime
result.newest = max(result.newest, info.st_mtime)
return result
def cache_usage(
cache_dir: Path = CACHE_DIR, *, keep: int = 3, installed: dict[str, str] | None = None
) -> CacheUsage:
"""How much of the package cache could be pruned.
Keeps the newest `keep` versions of every package plus whatever is currently
installed, which is the same rule `paccache -rk3` uses. The installed version
is kept even if it is older than the newest: downgrading is a legitimate
thing to want to do.
"""
usage = CacheUsage(keep=keep)
try:
entries = list(os.scandir(cache_dir))
except OSError:
return usage
by_name: dict[str, list[tuple[float, int, str]]] = {}
for entry in entries:
parsed = parse_cache_name(entry.name)
if parsed is None:
continue
name, version = parsed
try:
stat = entry.stat()
except OSError:
continue
usage.total += stat.st_size
usage.files += 1
by_name.setdefault(name, []).append((stat.st_mtime, stat.st_size, version))
for name, versions in by_name.items():
versions.sort(key=lambda row: row[0], reverse=True)
keep_versions = {v for _m, _s, v in versions[:keep]}
if installed and name in installed:
keep_versions.add(installed[name])
for _mtime, size, version in versions:
if version not in keep_versions:
usage.reclaimable += size
usage.reclaimable_files += 1
return usage
@dataclass
class Drift:
pacnew: list[Path] = field(default_factory=list)
pacsave: list[Path] = field(default_factory=list)
pacorig: list[Path] = field(default_factory=list)
@property
def total(self) -> int:
return len(self.pacnew) + len(self.pacsave) + len(self.pacorig)
@property
def actionable(self) -> list[Path]:
"""`.pacsave` and `.pacorig` are informational; `.pacnew` needs merging."""
return list(self.pacnew)
def config_drift(root: Path = ETC, *, max_depth: int = 6) -> Drift:
"""Unmerged configuration files left behind by package upgrades."""
drift = Drift()
root = root.resolve()
base_depth = len(root.parts)
for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None):
current = Path(dirpath)
if len(current.parts) - base_depth >= max_depth:
dirnames[:] = []
continue
# don't descend into the places pacnew files are never left
dirnames[:] = [
d for d in dirnames if d not in ("proc", "sys", "dev", ".git", "node_modules")
]
for filename in filenames:
if filename.endswith(".pacnew"):
drift.pacnew.append(current / filename)
elif filename.endswith(".pacsave"):
drift.pacsave.append(current / filename)
elif filename.endswith(".pacorig"):
drift.pacorig.append(current / filename)
for bucket in (drift.pacnew, drift.pacsave, drift.pacorig):
bucket.sort()
return drift
def running_kernel() -> str:
return platform.release()
def installed_kernels(modules: Path = MODULES) -> list[str]:
try:
return sorted(p.name for p in modules.iterdir() if p.is_dir())
except OSError:
return []
def reboot_reason(updated: set[str] | None = None, modules: Path = MODULES) -> str | None:
"""Why a reboot is needed, or None.
The authoritative signal is that the running kernel has no module tree on
disk any more, which means its modules were replaced underneath it.
"""
running = running_kernel()
kernels = installed_kernels(modules)
if kernels and running not in kernels:
return f"running kernel {running} is no longer installed"
if updated:
touched = sorted(n for n in updated if n.startswith(REBOOT_PREFIXES))
if touched:
return f"updated {', '.join(touched[:3])}"
return None
#: the kernel's marker for an executable whose file has been unlinked
DELETED_SUFFIX = " (deleted)"
@dataclass(frozen=True)
class StaleProcess:
pid: int
name: str
path: str
def stale_processes(*, limit: int = 400) -> list[StaleProcess]:
"""Processes still running a binary that has been replaced on disk.
The kernel marks an executable whose file was unlinked with a ` (deleted)`
suffix on `/proc/<pid>/exe`. That is the *usual* result of a package
upgrade, because the package manager replaces the file rather than editing
it -- and this check used to skip exactly those, so it reported almost
nothing. A deleted executable is the strongest possible signal that the
process is out of date, not a reason to look away.
Where the file still exists, the inode the process is running is compared
with the inode at that path: a mismatch means the same thing.
Only readable processes are inspected, so this needs no privileges and simply
sees less when run as a normal user.
"""
out: list[StaleProcess] = []
seen: set[tuple[int, str]] = set()
checked = 0
proc = Path("/proc")
try:
entries = [e for e in os.listdir(proc) if e.isdigit()]
except OSError:
return out
for entry in entries:
if checked >= limit:
break
pid = int(entry)
try:
exe = os.readlink(proc / entry / "exe")
except OSError:
continue
if not exe.startswith("/"):
continue
deleted = exe.endswith(DELETED_SUFFIX)
if deleted:
exe = exe[: -len(DELETED_SUFFIX)]
checked += 1
if not deleted:
try:
running_inode = os.stat(proc / entry / "exe").st_ino
disk_inode = os.stat(exe).st_ino
except OSError:
# the path is gone or unreadable, so the process is running
# something that is no longer there
pass
else:
if running_inode == disk_inode:
continue
key = (pid, exe)
if key in seen:
continue
seen.add(key)
try:
name = (proc / entry / "comm").read_text().strip()
except OSError:
name = os.path.basename(exe)
out.append(StaleProcess(pid=pid, name=name, path=exe))
out.sort(key=lambda s: s.name)
return out
@dataclass
class Leftovers:
"""Incomplete downloads pacman abandoned in the cache.
pacman downloads to `download-XXXXXX` and renames on success, so anything
left behind is a transfer that failed. They are usually empty, which is why
this reports a count rather than a size -- the useful signal is that
downloads keep failing, not that any disk can be recovered.
"""
count: int = 0
bytes: int = 0
oldest: float = 0.0
newest: float = 0.0
@property
def worth_mentioning(self) -> bool:
# a handful of failures is normal; a pile of them is a symptom
return self.count >= 10
@property
def all_empty(self) -> bool:
"""Every leftover is zero bytes, which points at a write failure.
A transfer that ran out of network leaves partial data; one that could
not create or rename its file leaves nothing at all.
"""
return self.count > 0 and self.bytes == 0
def explain(self, cache_dir) -> list[str]:
"""What these are, why they are probably there, and how to clear them."""
def age(stamp: float) -> str:
if not stamp:
return "?"
seconds = max(0.0, time.time() - stamp)
if seconds < 5400:
return f"{int(seconds // 60)}m"
if seconds < 172800:
return f"{seconds / 3600:.0f}h"
return f"{seconds / 86400:.0f}d"
lines = [
f"{self.count} file(s), {self.bytes:,} bytes total",
f"oldest {age(self.oldest)} old, newest {age(self.newest)} old",
"",
"pacman downloads each package to a scratch name and renames it",
"on success, so anything still called download-* is a transfer",
"that never finished.",
]
if self.all_empty:
lines += [
"",
"They are all zero bytes, which is not what running out of",
"network looks like: a partial download has partial data.",
"Empty ones mean pacman could not create or rename the file,",
"which is the same failure the databases check reports above.",
]
lines += [
"",
"clear them with:",
f" sudo find {cache_dir} -maxdepth 1 -name 'download-*' -delete",
]
return lines
@dataclass
class Hygiene:
cache: CacheUsage = field(default_factory=CacheUsage)
leftovers: Leftovers = field(default_factory=Leftovers)
drift: Drift = field(default_factory=Drift)
orphans: list[str] = field(default_factory=list)
reboot: str | None = None
reboot_after: set[str] = field(default_factory=set)
stale: list[StaleProcess] = field(default_factory=list)
removed_from_aur: list[str] = field(default_factory=list)
orphaned_aur: list[str] = field(default_factory=list)
stale_aur: list[tuple[str, float]] = field(default_factory=list)
stale_scan_seconds: float = 0.0
@property
def has_anything(self) -> bool:
return bool(
self.cache.has_reclaimable
or self.drift.total
or self.orphans
or self.reboot
or self.stale
or self.removed_from_aur
or self.orphaned_aur
or self.leftovers.worth_mentioning
)
def collect(
*,
cache_dir: Path = CACHE_DIR,
etc: Path = ETC,
modules: Path = MODULES,
installed: dict[str, str] | None = None,
orphans: list[str] | None = None,
updated: set[str] | None = None,
include_processes: bool = True,
) -> Hygiene:
"""Gather everything that does not need a network or a password."""
report = Hygiene(
cache=cache_usage(cache_dir, installed=installed),
leftovers=incomplete_downloads(cache_dir),
drift=config_drift(etc),
orphans=sorted(orphans or []),
)
report.reboot_after = set(updated or ())
report.reboot = reboot_reason(report.reboot_after, modules=modules)
if include_processes:
started = time.monotonic()
report.stale = stale_processes()
report.stale_scan_seconds = time.monotonic() - started
return report