testsAndMisc/linux_configuration/boot_recovery/boot-repair
Krzysztof kuhy Rudnicki 0fb3e3eee2 Cap extensionless scripts, and split the one that was hiding
check_file_length.sh required a dot in the filename, so every
extensionless executable was silently uncapped. is_source_file() now
reads the first two bytes and accepts a shebang, which catches real
scripts while still skipping data files like i3/config.

That immediately surfaced boot_recovery/boot-repair at 756 lines --
three times the largest capped shell file in the repo, uncapped purely
because its name has no dot in it. Split along its own section markers
into lib/{discovery,repair_fs,repair_kernel,modes}.sh, leaving the entry
script at 218.

The report block stays inline on purpose. It is top-level code with exit
calls rather than function definitions, and sourcing it changed control
flow: 30 of 42 tests failed. That is recorded in the lib headers so the
next reader does not retry it.

No vmbox pass for this one, deliberately. boot-repair is UEFI/ESP tooling
and the guest is BIOS/GRUB, so a sandbox green would be actively
misleading -- the vmbox skill says so itself. The evidence is the fixture
suite, which drives the real script against synthetic root trees: 42
passed before, 42 passed after.

verify_shell_split.sh reports 31 of 32 functions byte-identical. The one
difference is clean_shadow_files, where quoting $ESP_MOUNTPOINT fixed a
genuine SC2086 that only became visible once the function left the
monolith -- shellcheck could no longer see the readonly declaration and
stopped assuming the expansion was safe.

lib/tests/run_all.sh delegates to that same suite rather than standing up
a second, weaker harness: these libraries need the entry script's strict
mode and globals, so testing them in isolation would test a fiction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
2026-08-23 19:13:40 +02:00

219 lines
7.0 KiB
Bash
Executable File

#!/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/<old> with <new>, 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