#!/bin/bash # ============================================================================ # boot-repair — offline repair for a stale/unwritten EFI System Partition # ============================================================================ # # The failure this fixes (seen 2026-07-30): # A kernel upgrade replaced /usr/lib/modules/ with , but the new # kernel image never reached the ESP. The firmware kept booting the OLD # vmlinuz, whose module tree no longer existed — so nothing modular could # load after pivot-root. In particular `vfat` was gone, /boot itself could # not be mounted, local-fs.target failed and systemd dropped to emergency # mode. The upgrade was reported as successful. # # Root cause on this machine: pacman-git with ParallelDownloads > 1 makes # every forked alpm hook child segfault, silently skipping the # 90-mkinitcpio-install and depmod hooks. # # Design constraints (deliberate): # * SELF-CONTAINED — no sourcing of repo libs. It must run from the systemd # emergency shell where only the root filesystem is mounted. # * FULLY OFFLINE — never touches the network. Missing kernel modules are # recovered from /var/cache/pacman/pkg, nothing else. # * Repairs by default. Use --dry-run to inspect without changing anything. # # Safety invariants: # * Files under /boot are ONLY deleted while /boot is not a mountpoint AND # contains no EFI/ or loader/ directory AND the system booted via UEFI. # Deleting them after mounting, or on a BIOS box that has no ESP at all, # would destroy the real kernel and leave the machine unbootable. # * EFI/ and loader/ on the ESP are never written or removed. # * The running kernel's module tree is never deleted. # ============================================================================ set -euo pipefail # Emergency shells can have a threadbare PATH. PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export PATH readonly SCRIPT_NAME="${0##*/}" readonly ESP_MOUNTPOINT="/boot" # Defaults ROOT="/" MODE="fix" # fix | dry-run | preflight | auto ESP_DEV_OVERRIDE="" PROBLEMS=0 REPAIRS=0 QUIET=0 # ---------------------------------------------------------------------------- # Output helpers # ---------------------------------------------------------------------------- msg() { [[ $QUIET -eq 1 ]] || printf '%s\n' "$*"; } ok() { [[ $QUIET -eq 1 ]] || printf ' [ OK ] %s\n' "$*"; } info() { [[ $QUIET -eq 1 ]] || printf ' [INFO] %s\n' "$*"; } # A problem that was found. Counted so the exit status can reflect reality. problem() { PROBLEMS=$((PROBLEMS + 1)) printf ' [FAIL] %s\n' "$*" >&2 } warn() { printf ' [WARN] %s\n' "$*" >&2; } # A repair that was actually carried out. repaired() { REPAIRS=$((REPAIRS + 1)) printf ' [FIXED] %s\n' "$*" } die() { printf '%s: error: %s\n' "$SCRIPT_NAME" "$*" >&2 exit 2 } # True when we are allowed to modify the system. acting() { [[ $MODE == "fix" || $MODE == "auto" ]]; } # Describe an action that dry-run would skip. would() { msg " [DRY ] would $*"; } usage() { cat << EOF Usage: $SCRIPT_NAME [OPTIONS] Repairs a stale EFI System Partition after a kernel upgrade failed to write it. Repairs by default; runs fully offline. Options: --dry-run Report problems, change nothing. --preflight Exit non-zero unless the ESP is mounted. For a pacman PreTransaction hook that gates kernel upgrades. --auto Quiet repair for a pacman PostTransaction hook. Prints only when it acts or fails. --root DIR Operate on an alternate root (testing, or an Arch ISO chroot). Live-only steps are skipped. --esp DEVICE Use DEVICE as the ESP instead of auto-detecting it. Also overrides the BIOS guard: on a system with no /sys/firmware/efi, shadow-file cleanup is refused unless the ESP device is named explicitly here. -h, --help Show this help. Exit status: 0 system consistent (or fully repaired) 1 problems remain / preflight gate failed 2 usage or environment error EOF exit 0 } # ---------------------------------------------------------------------------- # Libraries. Sourced, not executed: they need this script's strict mode, its # $ROOT/$MODE globals and the output helpers defined above. # ---------------------------------------------------------------------------- LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib" && pwd)" # shellcheck source=lib/discovery.sh source "$LIB_DIR/discovery.sh" # shellcheck source=lib/repair_fs.sh source "$LIB_DIR/repair_fs.sh" # shellcheck source=lib/repair_kernel.sh source "$LIB_DIR/repair_kernel.sh" # shellcheck source=lib/modes.sh source "$LIB_DIR/modes.sh" # ---------------------------------------------------------------------------- # Argument parsing # ---------------------------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in --dry-run) MODE="dry-run" shift ;; --preflight) MODE="preflight" shift ;; --auto) MODE="auto" QUIET=1 shift ;; --root) [[ $# -ge 2 ]] || die "--root requires a directory" ROOT="$2" shift 2 ;; --esp) [[ $# -ge 2 ]] || die "--esp requires a device" ESP_DEV_OVERRIDE="$2" shift 2 ;; -h | --help) usage ;; *) die "unknown option: $1 (try --help)" ;; esac done [[ -d $ROOT ]] || die "root directory does not exist: $ROOT" if [[ $MODE == "preflight" ]]; then run_preflight fi # Root is required to modify the live system, but not to repair a tree handed # to us via --root (a fixture, or a chroot where we are already root). if acting && is_live && [[ $EUID -ne 0 ]]; then die "must run as root (try: sudo $SCRIPT_NAME)" fi [[ $QUIET -eq 1 ]] || { msg "=== boot-repair (${MODE}${ROOT:+, root=$ROOT}) ===" msg "" } run_checks || true # ---------------------------------------------------------------------------- # Report # ---------------------------------------------------------------------------- # In --auto (post-transaction hook) stay silent unless something happened. if [[ $MODE == "auto" ]]; then if [[ $REPAIRS -gt 0 ]]; then printf 'boot-repair: repaired %d boot inconsistency/ies after this transaction.\n' "$REPAIRS" fi if [[ $PROBLEMS -gt $REPAIRS ]]; then printf 'boot-repair: UNRESOLVED boot problems remain — do NOT reboot. Run: sudo %s\n' "$SCRIPT_NAME" >&2 exit 1 fi exit 0 fi msg "" if [[ $PROBLEMS -eq 0 ]]; then msg "=== consistent: nothing to repair ===" exit 0 fi if [[ $MODE == "dry-run" ]]; then msg "=== $PROBLEMS problem(s) found — rerun without --dry-run to repair ===" exit 1 fi if [[ $REPAIRS -ge $PROBLEMS ]]; then msg "=== repaired $REPAIRS problem(s) ===" msg "" msg "Verify before rebooting:" msg " findmnt $ESP_MOUNTPOINT" msg " sudo $SCRIPT_NAME --dry-run" exit 0 fi msg "=== repaired $REPAIRS of $PROBLEMS problem(s) — UNRESOLVED issues remain ===" exit 1