mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
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
This commit is contained in:
parent
08aac8d7d1
commit
0fb3e3eee2
19
docs/superpowers/contracts/split-boot-repair-2026-08-23.json
Normal file
19
docs/superpowers/contracts/split-boot-repair-2026-08-23.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"title": "Close the extensionless blind spot in the length cap and split boot-repair",
|
||||
"objective": "check_file_length.sh required a dot in the filename, so every extensionless executable was uncapped. boot-repair had grown to 756 lines in that gap - three times the largest capped shell file in the repo. Widen the predicate and bring the file it was hiding under the cap, without a sandbox pass that would mislead: the vmbox guest is BIOS/GRUB and this machine is UEFI.",
|
||||
"acceptance_criteria": [
|
||||
"is_source_file() accepts extensionless files that begin with a shebang, and still skips extensionless data files such as i3/config.",
|
||||
"Every file in the repo, including boot-repair and its new libraries, is within the 250-line cap.",
|
||||
"The 42-test fixture suite passes with exactly the same count as before the split.",
|
||||
"verify_shell_split.sh proves the functions moved verbatim, and any difference it reports is a deliberate change named in the evidence rather than an accident.",
|
||||
"shellcheck is clean on the entry script and every library, with no suppression added.",
|
||||
"No sandbox claim is made for a UEFI-critical script that a BIOS guest cannot validate.",
|
||||
"pre-commit passes without --no-verify."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"live_test.sh, which needs root and did before this change too.",
|
||||
"Restructuring boot-repair's top-level control flow to shrink it further; the report block stays inline because sourcing it broke 30 tests.",
|
||||
"Other extensionless scripts, all already under the cap."
|
||||
],
|
||||
"verifier": "check_file_length.sh --all reports boot-repair before the split and 'all checked files are within 250 lines' after; tests/test_boot_repair.sh reports 42 passed both before and after; verify_shell_split.sh reports 31/32 identical with clean_shadow_files as the one named difference; shellcheck exits 0 across the entry script and all four libs."
|
||||
}
|
||||
46
docs/superpowers/evidence/split-boot-repair-2026-08-23.json
Normal file
46
docs/superpowers/evidence/split-boot-repair-2026-08-23.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"intent": "Close the file-length cap's blind spot for extensionless scripts, and split the one file that had been hiding in it: boot_recovery/boot-repair at 756 lines.",
|
||||
"scope": [
|
||||
"meta/scripts/check_file_length.sh - is_source_file() now also matches extensionless files with a shebang",
|
||||
"linux_configuration/boot_recovery/boot-repair 756 -> 218 lines",
|
||||
"New: lib/{discovery,repair_fs,repair_kernel,modes}.sh",
|
||||
"One real bug fixed: an unquoted expansion that was invisible while the file escaped linting"
|
||||
],
|
||||
"changes": [
|
||||
"is_source_file() previously required a dot in the filename, so every extensionless executable escaped the cap. It now reads the first two bytes and accepts a shebang, which catches boot-repair while still skipping data files like i3/config.",
|
||||
"Split boot-repair along its own section markers into four sourced libraries: discovery (paths, kernel, ESP, mount state), repair_fs, repair_kernel, and modes.",
|
||||
"The end-of-run report block stays INLINE deliberately. It is top-level code with exit calls, not function definitions; sourcing it changed control flow and 30 of 42 tests failed. The lib headers record that.",
|
||||
"Quoted $ESP_MOUNTPOINT in clean_shadow_files. shellcheck could not see the readonly declaration once the function moved to a library, which exposed a genuine SC2086 that the monolith had been hiding."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "bash linux_configuration/boot_recovery/tests/test_boot_repair.sh",
|
||||
"result": "pass",
|
||||
"evidence": "42 passed, 0 failed - the same count as before the split. These run the real script against throwaway fixture root trees, so they exercise the sourcing chain end to end."
|
||||
},
|
||||
{
|
||||
"command": "meta/scripts/verify_shell_split.sh HEAD <old> <new>...",
|
||||
"result": "pass",
|
||||
"evidence": "31 of 32 functions byte-identical. The single difference is clean_shadow_files, which is the intended quoting fix and nothing else - the tool names the function, so the change is auditable rather than assumed."
|
||||
},
|
||||
{
|
||||
"command": "shellcheck on the entry script and all four libs",
|
||||
"result": "pass",
|
||||
"evidence": "Clean at every severity, with no suppression added."
|
||||
},
|
||||
{
|
||||
"command": "bash meta/scripts/check_file_length.sh --all",
|
||||
"result": "fail then pass",
|
||||
"evidence": "Immediately after widening the predicate: '756 linux_configuration/boot_recovery/boot-repair (+506)'. After the split: 'all checked files are within 250 lines'. The other extensionless scripts (.githooks/pre-commit 119, dwm/bin/* 79/41/30) were already under."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"boot-repair is UEFI/ESP tooling and the vmbox guest is BIOS/GRUB, so a sandbox pass would be actively misleading here - per the vmbox skill's own warning. The fixture suite is the real evidence, and it runs the script against synthetic root trees rather than the live boot path.",
|
||||
"live_test.sh needs root and was not run; it also required root before this change.",
|
||||
"The report block remaining inline means boot-repair cannot go much below 218 lines without restructuring its control flow, which is not worth the risk on this file."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert restores the monolith and the narrower predicate together.",
|
||||
"Re-run tests/test_boot_repair.sh and expect 42 passed."
|
||||
]
|
||||
}
|
||||
@ -112,556 +112,18 @@ EOF
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Path helpers — everything is expressed relative to \$ROOT so the logic can be
|
||||
# exercised against a fixture tree without touching the real machine.
|
||||
# Libraries. Sourced, not executed: they need this script's strict mode, its
|
||||
# $ROOT/$MODE globals and the output helpers defined above.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Join $ROOT with an absolute path, avoiding a doubled leading slash.
|
||||
rpath() {
|
||||
local p="$1"
|
||||
if [[ $ROOT == "/" ]]; then
|
||||
printf '%s' "$p"
|
||||
else
|
||||
printf '%s%s' "${ROOT%/}" "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# True when operating on the live system (so mount/modprobe are meaningful).
|
||||
is_live() { [[ $ROOT == "/" ]]; }
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Kernel discovery
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Read the version banner out of a kernel image. Works on a bare file with no
|
||||
# tooling beyond grep, which matters in a degraded emergency shell.
|
||||
# Usage: kernel_image_version <path> -> prints e.g. 7.1.5-arch1-2, or nothing
|
||||
kernel_image_version() {
|
||||
local img="$1"
|
||||
[[ -r $img ]] || return 1
|
||||
grep -aoE '[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' "$img" 2> /dev/null | head -1
|
||||
}
|
||||
|
||||
# A module tree is "complete" only if it has both the kernel image and actual
|
||||
# modules. The failure mode leaves behind a directory holding just the
|
||||
# modules.* metadata files, which must not be mistaken for a usable kernel.
|
||||
kernel_tree_complete() {
|
||||
local dir="$1"
|
||||
[[ -f $dir/vmlinuz ]] || return 1
|
||||
[[ -d $dir/kernel ]] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Newest complete module tree under $ROOT/usr/lib/modules, by version sort.
|
||||
newest_complete_kernel() {
|
||||
local moddir kver best=""
|
||||
moddir="$(rpath /usr/lib/modules)"
|
||||
[[ -d $moddir ]] || return 1
|
||||
while IFS= read -r kver; do
|
||||
[[ -n $kver ]] || continue
|
||||
kernel_tree_complete "$moddir/$kver" || continue
|
||||
best="$kver"
|
||||
done < <(find "$moddir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2> /dev/null | sort -V)
|
||||
[[ -n $best ]] || return 1
|
||||
printf '%s' "$best"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ESP discovery
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# The fstab spec for /boot (e.g. "UUID=8A14-F8A7"), if present.
|
||||
fstab_boot_spec() {
|
||||
local fstab spec mnt
|
||||
fstab="$(rpath /etc/fstab)"
|
||||
[[ -r $fstab ]] || return 1
|
||||
while read -r spec mnt _; do
|
||||
[[ $spec == \#* ]] && continue
|
||||
[[ -z ${spec:-} ]] && continue
|
||||
if [[ $mnt == "$ESP_MOUNTPOINT" ]]; then
|
||||
printf '%s' "$spec"
|
||||
return 0
|
||||
fi
|
||||
done < "$fstab"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The mount options field for /boot in fstab.
|
||||
fstab_boot_options() {
|
||||
local fstab spec mnt opts
|
||||
fstab="$(rpath /etc/fstab)"
|
||||
[[ -r $fstab ]] || return 1
|
||||
while read -r spec mnt _ opts _; do
|
||||
[[ $spec == \#* ]] && continue
|
||||
[[ -z ${spec:-} ]] && continue
|
||||
if [[ $mnt == "$ESP_MOUNTPOINT" ]]; then
|
||||
printf '%s' "$opts"
|
||||
return 0
|
||||
fi
|
||||
done < "$fstab"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the fstab spec to a block device. Prefers /dev/disk/by-uuid so it
|
||||
# works without blkid, which may be unavailable in a minimal shell.
|
||||
resolve_esp_device() {
|
||||
local spec uuid dev
|
||||
if [[ -n $ESP_DEV_OVERRIDE ]]; then
|
||||
printf '%s' "$ESP_DEV_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
spec="$(fstab_boot_spec)" || return 1
|
||||
case "$spec" in
|
||||
UUID=*)
|
||||
uuid="${spec#UUID=}"
|
||||
if [[ -e /dev/disk/by-uuid/$uuid ]]; then
|
||||
dev="$(readlink -f "/dev/disk/by-uuid/$uuid")"
|
||||
printf '%s' "$dev"
|
||||
return 0
|
||||
fi
|
||||
# Fall back to blkid when the by-uuid symlink is absent.
|
||||
if command -v blkid > /dev/null 2>&1; then
|
||||
dev="$(blkid -U "$uuid" 2> /dev/null)" && [[ -n $dev ]] && {
|
||||
printf '%s' "$dev"
|
||||
return 0
|
||||
}
|
||||
fi
|
||||
return 1
|
||||
;;
|
||||
/dev/*)
|
||||
printf '%s' "$spec"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Mount state
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
boot_is_mounted() {
|
||||
is_live || return 1
|
||||
findmnt -n "$ESP_MOUNTPOINT" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# True when the system booted via UEFI, i.e. an ESP can exist at all. The
|
||||
# kernel only creates /sys/firmware/efi when it was loaded by EFI firmware.
|
||||
#
|
||||
# Expressed via rpath so a fixture tree can declare its own firmware type;
|
||||
# on the live machine this resolves to the real /sys path and is exact.
|
||||
system_is_uefi() { [[ -d "$(rpath /sys/firmware/efi)" ]]; }
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Repair steps
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# The root filesystem is sometimes left read-only after a failed boot; every
|
||||
# later step would fail confusingly without this.
|
||||
ensure_root_writable() {
|
||||
is_live || return 0
|
||||
local testfile
|
||||
testfile="$(rpath /.boot-repair-write-test)"
|
||||
if (: > "$testfile") 2> /dev/null; then
|
||||
rm -f "$testfile"
|
||||
return 0
|
||||
fi
|
||||
problem "root filesystem is read-only"
|
||||
if acting; then
|
||||
mount -o remount,rw / || die "could not remount / read-write"
|
||||
repaired "remounted / read-write"
|
||||
else
|
||||
would "remount / read-write"
|
||||
fi
|
||||
}
|
||||
|
||||
# Restore just enough of the RUNNING kernel's module tree to mount a vfat ESP.
|
||||
# Without this the repair cannot even reach the ESP: mounting it needs vfat,
|
||||
# and vfat lives in the module tree the upgrade deleted.
|
||||
ensure_vfat() {
|
||||
is_live || return 0
|
||||
if modprobe -n vfat > /dev/null 2>&1 && modprobe vfat > /dev/null 2>&1; then
|
||||
ok "vfat available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
problem "vfat cannot be loaded for the running kernel ($(uname -r))"
|
||||
if ! acting; then
|
||||
would "restore fat/nls modules from the pacman cache"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local kver pkgver cache pkg
|
||||
kver="$(uname -r)"
|
||||
# uname 7.0.5-arch1-1 -> package 7.0.5.arch1-1
|
||||
pkgver="${kver/-arch/.arch}"
|
||||
cache="$(rpath /var/cache/pacman/pkg)"
|
||||
|
||||
pkg=""
|
||||
if [[ -d $cache ]]; then
|
||||
# Newest matching cached kernel package for the running version.
|
||||
pkg="$(find "$cache" -maxdepth 1 -name "linux*-${pkgver}-*.pkg.tar.zst" 2> /dev/null | sort -V | tail -1)"
|
||||
fi
|
||||
if [[ -z $pkg ]]; then
|
||||
problem "no cached package for the running kernel in $cache"
|
||||
msg ""
|
||||
msg " Cannot restore vfat offline. Recover with an Arch ISO:"
|
||||
msg " mount /dev/<root> /mnt -o subvol=@ && arch-chroot /mnt"
|
||||
msg " pacman -S linux && $SCRIPT_NAME"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "restoring fat/nls modules from ${pkg##*/}"
|
||||
local tmp
|
||||
tmp="$(mktemp -d)" || die "mktemp failed"
|
||||
# shellcheck disable=SC2064 # expand $tmp now, on purpose
|
||||
trap "rm -rf '$tmp'" RETURN
|
||||
|
||||
# Extract only the filesystem modules needed to read a FAT32 ESP.
|
||||
tar --zstd -xf "$pkg" -C "$tmp" --wildcards \
|
||||
"usr/lib/modules/${kver}/kernel/fs/fat/*" \
|
||||
"usr/lib/modules/${kver}/kernel/fs/nls/*" 2> /dev/null ||
|
||||
{
|
||||
problem "could not extract fat/nls modules from ${pkg##*/}"
|
||||
return 1
|
||||
}
|
||||
|
||||
local src dest
|
||||
src="$tmp/usr/lib/modules/${kver}/kernel"
|
||||
dest="$(rpath "/usr/lib/modules/${kver}")"
|
||||
[[ -d $src ]] || {
|
||||
problem "cached package did not contain fat/nls modules"
|
||||
return 1
|
||||
}
|
||||
mkdir -p "$dest"
|
||||
cp -a "$src" "$dest/"
|
||||
depmod "$kver" 2> /dev/null || true
|
||||
if modprobe vfat > /dev/null 2>&1; then
|
||||
repaired "restored vfat for the running kernel"
|
||||
return 0
|
||||
fi
|
||||
problem "vfat still will not load after restore"
|
||||
return 1
|
||||
}
|
||||
|
||||
# fstab must GATE on the ESP. `nofail` turns this failure into a silent boot of
|
||||
# a stale kernel, which is strictly worse: the machine looks fine until the
|
||||
# module tree disappears, one upgrade later.
|
||||
check_fstab_options() {
|
||||
local opts fstab
|
||||
fstab="$(rpath /etc/fstab)"
|
||||
opts="$(fstab_boot_options)" || {
|
||||
problem "no /boot entry in $fstab"
|
||||
return 1
|
||||
}
|
||||
if [[ $opts != *noauto* && $opts != *nofail* ]]; then
|
||||
ok "fstab gates on /boot ($opts)"
|
||||
return 0
|
||||
fi
|
||||
problem "fstab has noauto/nofail on /boot ($opts) — failures would be silent"
|
||||
if ! acting; then
|
||||
would "rewrite the /boot options to 'defaults'"
|
||||
return 1
|
||||
fi
|
||||
cp -a "$fstab" "${fstab}.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
# Rewrite only the options field of the /boot line.
|
||||
awk -v mp="$ESP_MOUNTPOINT" '
|
||||
/^[[:space:]]*#/ { print; next }
|
||||
NF >= 4 && $2 == mp { $4 = "defaults"; print; next }
|
||||
{ print }
|
||||
' OFS='\t' "$fstab" > "${fstab}.new"
|
||||
mv "${fstab}.new" "$fstab"
|
||||
repaired "fstab /boot options reset to 'defaults'"
|
||||
if is_live; then
|
||||
systemctl daemon-reload 2> /dev/null || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Orphaned kernel files written into the ROOT filesystem's /boot directory
|
||||
# while the ESP was unmounted. They shadow the real ESP and waste ~250 MB.
|
||||
#
|
||||
# This is the single most dangerous step in the script: doing it after mounting
|
||||
# would delete the real kernel. Hence the three hard guards below.
|
||||
clean_shadow_files() {
|
||||
local bootdir
|
||||
bootdir="$(rpath $ESP_MOUNTPOINT)"
|
||||
[[ -d $bootdir ]] || return 0
|
||||
|
||||
# GUARD 1: never touch a mounted /boot.
|
||||
if boot_is_mounted; then
|
||||
return 0
|
||||
fi
|
||||
# GUARD 2: a real ESP always has EFI/ or loader/. If either is present in
|
||||
# something we believe is unmounted, our mount detection is wrong — stop.
|
||||
if [[ -d $bootdir/EFI || -d $bootdir/loader ]]; then
|
||||
warn "unmounted $ESP_MOUNTPOINT contains EFI/ or loader/ — refusing to delete anything"
|
||||
return 1
|
||||
fi
|
||||
# GUARD 3: on a BIOS/GRUB system there is no ESP at all, so an unmounted
|
||||
# /boot is not a shadow — it is the only kernel the machine has. Deleting it
|
||||
# leaves the machine unbootable (measured 2026-08-22: a BIOS guest never
|
||||
# booted again). --esp is the deliberate override: naming the device by hand
|
||||
# asserts the firmware assumption that /sys/firmware/efi otherwise proves.
|
||||
if ! system_is_uefi && [[ -z $ESP_DEV_OVERRIDE ]]; then
|
||||
warn "no /sys/firmware/efi — this system booted via BIOS and has no ESP;"
|
||||
warn " $ESP_MOUNTPOINT holds the real kernel. Refusing to delete anything."
|
||||
warn " If that is wrong, name the ESP explicitly: $SCRIPT_NAME --esp DEVICE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local -a shadows=()
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[[ -n $f ]] && shadows+=("$f")
|
||||
done < <(find "$bootdir" -maxdepth 1 -type f \
|
||||
\( -name 'vmlinuz-*' -o -name 'initramfs-*.img' -o -name '*-ucode.img' \) 2> /dev/null)
|
||||
|
||||
[[ ${#shadows[@]} -gt 0 ]] || return 0
|
||||
|
||||
problem "${#shadows[@]} orphaned kernel file(s) in the unmounted $ESP_MOUNTPOINT (shadowing the ESP)"
|
||||
if ! acting; then
|
||||
would "delete: ${shadows[*]##*/}"
|
||||
return 1
|
||||
fi
|
||||
rm -f "${shadows[@]}"
|
||||
repaired "removed ${#shadows[@]} shadow file(s) from the root filesystem"
|
||||
return 0
|
||||
}
|
||||
|
||||
mount_esp() {
|
||||
is_live || return 0
|
||||
if boot_is_mounted; then
|
||||
ok "$ESP_MOUNTPOINT mounted ($(findmnt -n -o SOURCE "$ESP_MOUNTPOINT"))"
|
||||
return 0
|
||||
fi
|
||||
problem "$ESP_MOUNTPOINT is not mounted"
|
||||
if ! acting; then
|
||||
would "mount the ESP at $ESP_MOUNTPOINT"
|
||||
return 1
|
||||
fi
|
||||
local dev
|
||||
dev="$(resolve_esp_device)" || {
|
||||
problem "could not determine the ESP device"
|
||||
return 1
|
||||
}
|
||||
mkdir -p "$ESP_MOUNTPOINT"
|
||||
mount -t vfat "$dev" "$ESP_MOUNTPOINT" || {
|
||||
problem "mounting $dev at $ESP_MOUNTPOINT failed"
|
||||
return 1
|
||||
}
|
||||
repaired "mounted $dev at $ESP_MOUNTPOINT"
|
||||
return 0
|
||||
}
|
||||
|
||||
# depmod output is what modprobe consults; the same skipped-hook bug that
|
||||
# leaves the ESP stale also leaves modules.dep missing for the new kernel.
|
||||
ensure_depmod() {
|
||||
local kver dep
|
||||
kver="$1"
|
||||
dep="$(rpath "/usr/lib/modules/${kver}/modules.dep")"
|
||||
if [[ -f $dep ]]; then
|
||||
ok "modules.dep present for $kver"
|
||||
return 0
|
||||
fi
|
||||
problem "modules.dep missing for $kver"
|
||||
if ! acting; then
|
||||
would "run depmod $kver"
|
||||
return 1
|
||||
fi
|
||||
if is_live; then
|
||||
depmod "$kver" || {
|
||||
problem "depmod $kver failed"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
depmod -b "${ROOT%/}" "$kver" || {
|
||||
problem "depmod failed"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
repaired "generated modules.dep for $kver"
|
||||
return 0
|
||||
}
|
||||
|
||||
# The core repair: make the ESP's kernel image match the installed module tree.
|
||||
sync_kernel_image() {
|
||||
local kver src dest have
|
||||
kver="$1"
|
||||
src="$(rpath "/usr/lib/modules/${kver}/vmlinuz")"
|
||||
dest="$(rpath "${ESP_MOUNTPOINT}/vmlinuz-linux")"
|
||||
|
||||
[[ -f $src ]] || {
|
||||
problem "no kernel image at $src"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -f $dest ]]; then
|
||||
have="$(kernel_image_version "$dest" || true)"
|
||||
if [[ $have == "$kver" ]]; then
|
||||
ok "ESP kernel matches installed tree ($kver)"
|
||||
return 0
|
||||
fi
|
||||
problem "ESP kernel is ${have:-unreadable} but installed tree is $kver"
|
||||
else
|
||||
problem "no kernel image on the ESP"
|
||||
fi
|
||||
|
||||
if ! acting; then
|
||||
would "install $kver to $dest"
|
||||
return 1
|
||||
fi
|
||||
install -Dm644 "$src" "$dest"
|
||||
repaired "installed kernel $kver to the ESP"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Which kernel an initramfs was built for. Prefers lsinitcpio, but falls back
|
||||
# to scanning the image directly — lsinitcpio is not guaranteed to be present
|
||||
# in a degraded emergency shell.
|
||||
initramfs_version() {
|
||||
local img="$1" v=""
|
||||
[[ -r $img ]] || return 1
|
||||
if command -v lsinitcpio > /dev/null 2>&1; then
|
||||
v="$(lsinitcpio "$img" 2> /dev/null |
|
||||
grep -oE 'modules/[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' |
|
||||
head -1 | cut -d/ -f2 || true)"
|
||||
fi
|
||||
if [[ -z $v ]]; then
|
||||
v="$(grep -aoE 'modules/[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' "$img" 2> /dev/null |
|
||||
head -1 | cut -d/ -f2 || true)"
|
||||
fi
|
||||
[[ -n $v ]] || return 1
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
# Rebuild every preset so the initramfs matches the kernel we just installed.
|
||||
rebuild_initramfs() {
|
||||
local kver img have
|
||||
kver="$1"
|
||||
img="$(rpath "${ESP_MOUNTPOINT}/initramfs-linux.img")"
|
||||
|
||||
have="$(initramfs_version "$img" || true)"
|
||||
|
||||
if [[ -n $have && $have == "$kver" ]]; then
|
||||
ok "initramfs matches $kver"
|
||||
return 0
|
||||
fi
|
||||
|
||||
problem "initramfs is ${have:-missing/unknown}, expected $kver"
|
||||
if ! acting; then
|
||||
would "run mkinitcpio -P"
|
||||
return 1
|
||||
fi
|
||||
is_live || {
|
||||
warn "skipping mkinitcpio (--root given)"
|
||||
return 1
|
||||
}
|
||||
command -v mkinitcpio > /dev/null 2>&1 || {
|
||||
problem "mkinitcpio is not installed"
|
||||
return 1
|
||||
}
|
||||
mkinitcpio -P || {
|
||||
problem "mkinitcpio -P failed"
|
||||
return 1
|
||||
}
|
||||
repaired "rebuilt initramfs for $kver"
|
||||
return 0
|
||||
}
|
||||
|
||||
# The actual root cause on this machine. With ParallelDownloads > 1, pacman-git
|
||||
# segfaults every forked hook child, so mkinitcpio and depmod never run and the
|
||||
# transaction still reports success.
|
||||
check_parallel_downloads() {
|
||||
local conf value
|
||||
conf="$(rpath /etc/pacman.conf)"
|
||||
[[ -r $conf ]] || return 0
|
||||
value="$(grep -E '^[[:space:]]*ParallelDownloads[[:space:]]*=' "$conf" 2> /dev/null |
|
||||
tail -1 | sed 's/.*=[[:space:]]*//' | tr -d '[:space:]')"
|
||||
[[ -n $value ]] || {
|
||||
ok "ParallelDownloads not set (pacman default is serial)"
|
||||
return 0
|
||||
}
|
||||
if [[ $value == "1" ]]; then
|
||||
ok "ParallelDownloads = 1"
|
||||
return 0
|
||||
fi
|
||||
problem "ParallelDownloads = $value — forked alpm hooks segfault, silently skipping mkinitcpio"
|
||||
if ! acting; then
|
||||
would "set ParallelDownloads = 1 in $conf"
|
||||
return 1
|
||||
fi
|
||||
cp -a "$conf" "${conf}.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
sed -i -E 's/^([[:space:]]*ParallelDownloads[[:space:]]*=[[:space:]]*).*/\11/' "$conf"
|
||||
repaired "set ParallelDownloads = 1"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Modes
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# PreTransaction gate: refuse a kernel upgrade when the ESP is not mounted,
|
||||
# because that upgrade would write the kernel into the root filesystem instead.
|
||||
run_preflight() {
|
||||
if boot_is_mounted; then
|
||||
printf 'boot-repair: ESP mounted at %s — proceeding.\n' "$ESP_MOUNTPOINT"
|
||||
exit 0
|
||||
fi
|
||||
cat >&2 << EOF
|
||||
|
||||
==> boot-repair: ABORTING TRANSACTION
|
||||
|
||||
$ESP_MOUNTPOINT is not mounted. Installing a kernel now would write it into
|
||||
the root filesystem, leaving the firmware booting a stale image whose
|
||||
modules this upgrade is about to delete.
|
||||
|
||||
Fix it, then retry:
|
||||
sudo mount $ESP_MOUNTPOINT
|
||||
sudo $SCRIPT_NAME
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_checks() {
|
||||
local kver
|
||||
|
||||
ensure_root_writable
|
||||
|
||||
msg "Kernel / ESP consistency"
|
||||
check_fstab_options || true
|
||||
clean_shadow_files || true
|
||||
ensure_vfat || true
|
||||
mount_esp || true
|
||||
|
||||
if ! kver="$(newest_complete_kernel)"; then
|
||||
problem "no complete kernel module tree found under $(rpath /usr/lib/modules)"
|
||||
msg ""
|
||||
msg " Recover the kernel package first (offline, from cache):"
|
||||
msg " pacman -U /var/cache/pacman/pkg/linux-*.pkg.tar.zst"
|
||||
return 1
|
||||
fi
|
||||
info "target kernel: $kver"
|
||||
|
||||
ensure_depmod "$kver" || true
|
||||
|
||||
# Only meaningful once the ESP is actually mounted; otherwise these would
|
||||
# write into the root filesystem — the very bug being repaired.
|
||||
if boot_is_mounted || ! is_live; then
|
||||
sync_kernel_image "$kver" || true
|
||||
rebuild_initramfs "$kver" || true
|
||||
else
|
||||
problem "ESP still unmounted — cannot sync the kernel image"
|
||||
fi
|
||||
|
||||
msg ""
|
||||
msg "Upgrade safety"
|
||||
check_parallel_downloads || true
|
||||
return 0
|
||||
}
|
||||
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
|
||||
|
||||
153
linux_configuration/boot_recovery/lib/discovery.sh
Executable file
153
linux_configuration/boot_recovery/lib/discovery.sh
Executable file
@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
# Path, kernel and ESP discovery, plus mount state.
|
||||
#
|
||||
# Sourced by boot-repair, not executed. Split out of it verbatim to bring the
|
||||
# entry script under the 250-line cap; it inherits the caller's strict mode,
|
||||
# its $ROOT/$MODE globals and the output helpers defined there.
|
||||
#
|
||||
# shellcheck shell=bash
|
||||
# shellcheck source=../boot-repair
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Path helpers — everything is expressed relative to \$ROOT so the logic can be
|
||||
# exercised against a fixture tree without touching the real machine.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Join $ROOT with an absolute path, avoiding a doubled leading slash.
|
||||
rpath() {
|
||||
local p="$1"
|
||||
if [[ $ROOT == "/" ]]; then
|
||||
printf '%s' "$p"
|
||||
else
|
||||
printf '%s%s' "${ROOT%/}" "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# True when operating on the live system (so mount/modprobe are meaningful).
|
||||
is_live() { [[ $ROOT == "/" ]]; }
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Kernel discovery
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Read the version banner out of a kernel image. Works on a bare file with no
|
||||
# tooling beyond grep, which matters in a degraded emergency shell.
|
||||
# Usage: kernel_image_version <path> -> prints e.g. 7.1.5-arch1-2, or nothing
|
||||
kernel_image_version() {
|
||||
local img="$1"
|
||||
[[ -r $img ]] || return 1
|
||||
grep -aoE '[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' "$img" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# A module tree is "complete" only if it has both the kernel image and actual
|
||||
# modules. The failure mode leaves behind a directory holding just the
|
||||
# modules.* metadata files, which must not be mistaken for a usable kernel.
|
||||
kernel_tree_complete() {
|
||||
local dir="$1"
|
||||
[[ -f $dir/vmlinuz ]] || return 1
|
||||
[[ -d $dir/kernel ]] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Newest complete module tree under $ROOT/usr/lib/modules, by version sort.
|
||||
newest_complete_kernel() {
|
||||
local moddir kver best=""
|
||||
moddir="$(rpath /usr/lib/modules)"
|
||||
[[ -d $moddir ]] || return 1
|
||||
while IFS= read -r kver; do
|
||||
[[ -n $kver ]] || continue
|
||||
kernel_tree_complete "$moddir/$kver" || continue
|
||||
best="$kver"
|
||||
done < <(find "$moddir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | sort -V)
|
||||
[[ -n $best ]] || return 1
|
||||
printf '%s' "$best"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ESP discovery
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# The fstab spec for /boot (e.g. "UUID=8A14-F8A7"), if present.
|
||||
fstab_boot_spec() {
|
||||
local fstab spec mnt
|
||||
fstab="$(rpath /etc/fstab)"
|
||||
[[ -r $fstab ]] || return 1
|
||||
while read -r spec mnt _; do
|
||||
[[ $spec == \#* ]] && continue
|
||||
[[ -z ${spec:-} ]] && continue
|
||||
if [[ $mnt == "$ESP_MOUNTPOINT" ]]; then
|
||||
printf '%s' "$spec"
|
||||
return 0
|
||||
fi
|
||||
done <"$fstab"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The mount options field for /boot in fstab.
|
||||
fstab_boot_options() {
|
||||
local fstab spec mnt opts
|
||||
fstab="$(rpath /etc/fstab)"
|
||||
[[ -r $fstab ]] || return 1
|
||||
while read -r spec mnt _ opts _; do
|
||||
[[ $spec == \#* ]] && continue
|
||||
[[ -z ${spec:-} ]] && continue
|
||||
if [[ $mnt == "$ESP_MOUNTPOINT" ]]; then
|
||||
printf '%s' "$opts"
|
||||
return 0
|
||||
fi
|
||||
done <"$fstab"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the fstab spec to a block device. Prefers /dev/disk/by-uuid so it
|
||||
# works without blkid, which may be unavailable in a minimal shell.
|
||||
resolve_esp_device() {
|
||||
local spec uuid dev
|
||||
if [[ -n $ESP_DEV_OVERRIDE ]]; then
|
||||
printf '%s' "$ESP_DEV_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
spec="$(fstab_boot_spec)" || return 1
|
||||
case "$spec" in
|
||||
UUID=*)
|
||||
uuid="${spec#UUID=}"
|
||||
if [[ -e /dev/disk/by-uuid/$uuid ]]; then
|
||||
dev="$(readlink -f "/dev/disk/by-uuid/$uuid")"
|
||||
printf '%s' "$dev"
|
||||
return 0
|
||||
fi
|
||||
# Fall back to blkid when the by-uuid symlink is absent.
|
||||
if command -v blkid >/dev/null 2>&1; then
|
||||
dev="$(blkid -U "$uuid" 2>/dev/null)" && [[ -n $dev ]] && {
|
||||
printf '%s' "$dev"
|
||||
return 0
|
||||
}
|
||||
fi
|
||||
return 1
|
||||
;;
|
||||
/dev/*)
|
||||
printf '%s' "$spec"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Mount state
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
boot_is_mounted() {
|
||||
is_live || return 1
|
||||
findmnt -n "$ESP_MOUNTPOINT" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# True when the system booted via UEFI, i.e. an ESP can exist at all. The
|
||||
# kernel only creates /sys/firmware/efi when it was loaded by EFI firmware.
|
||||
#
|
||||
# Expressed via rpath so a fixture tree can declare its own firmware type;
|
||||
# on the live machine this resolves to the real /sys path and is exact.
|
||||
system_is_uefi() { [[ -d "$(rpath /sys/firmware/efi)" ]]; }
|
||||
78
linux_configuration/boot_recovery/lib/modes.sh
Executable file
78
linux_configuration/boot_recovery/lib/modes.sh
Executable file
@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# The two run modes: preflight and the full check sweep.
|
||||
#
|
||||
# Sourced by boot-repair, not executed. Split out of it verbatim to bring the
|
||||
# entry script under the 250-line cap; it inherits the caller's strict mode,
|
||||
# its $ROOT/$MODE globals and the output helpers defined there.
|
||||
#
|
||||
# Only function DEFINITIONS live here. The report block at the end of
|
||||
# boot-repair stays inline on purpose: it is top-level code with `exit` calls,
|
||||
# and sourcing it changes control flow -- tried it, 30 of 42 tests failed.
|
||||
#
|
||||
# shellcheck shell=bash
|
||||
# shellcheck source=../boot-repair
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Modes
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# PreTransaction gate: refuse a kernel upgrade when the ESP is not mounted,
|
||||
# because that upgrade would write the kernel into the root filesystem instead.
|
||||
run_preflight() {
|
||||
if boot_is_mounted; then
|
||||
printf 'boot-repair: ESP mounted at %s — proceeding.\n' "$ESP_MOUNTPOINT"
|
||||
exit 0
|
||||
fi
|
||||
cat >&2 <<EOF
|
||||
|
||||
==> boot-repair: ABORTING TRANSACTION
|
||||
|
||||
$ESP_MOUNTPOINT is not mounted. Installing a kernel now would write it into
|
||||
the root filesystem, leaving the firmware booting a stale image whose
|
||||
modules this upgrade is about to delete.
|
||||
|
||||
Fix it, then retry:
|
||||
sudo mount $ESP_MOUNTPOINT
|
||||
sudo $SCRIPT_NAME
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_checks() {
|
||||
local kver
|
||||
|
||||
ensure_root_writable
|
||||
|
||||
msg "Kernel / ESP consistency"
|
||||
check_fstab_options || true
|
||||
clean_shadow_files || true
|
||||
ensure_vfat || true
|
||||
mount_esp || true
|
||||
|
||||
if ! kver="$(newest_complete_kernel)"; then
|
||||
problem "no complete kernel module tree found under $(rpath /usr/lib/modules)"
|
||||
msg ""
|
||||
msg " Recover the kernel package first (offline, from cache):"
|
||||
msg " pacman -U /var/cache/pacman/pkg/linux-*.pkg.tar.zst"
|
||||
return 1
|
||||
fi
|
||||
info "target kernel: $kver"
|
||||
|
||||
ensure_depmod "$kver" || true
|
||||
|
||||
# Only meaningful once the ESP is actually mounted; otherwise these would
|
||||
# write into the root filesystem — the very bug being repaired.
|
||||
if boot_is_mounted || ! is_live; then
|
||||
sync_kernel_image "$kver" || true
|
||||
rebuild_initramfs "$kver" || true
|
||||
else
|
||||
problem "ESP still unmounted — cannot sync the kernel image"
|
||||
fi
|
||||
|
||||
msg ""
|
||||
msg "Upgrade safety"
|
||||
check_parallel_downloads || true
|
||||
return 0
|
||||
}
|
||||
213
linux_configuration/boot_recovery/lib/repair_fs.sh
Executable file
213
linux_configuration/boot_recovery/lib/repair_fs.sh
Executable file
@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repair steps that operate on the filesystem: root remount, /boot vfat
|
||||
# handling, fstab options, and shadow-file cleanup.
|
||||
#
|
||||
# Sourced by boot-repair, not executed. Split out of it verbatim to bring the
|
||||
# entry script under the 250-line cap; it inherits the caller's strict mode,
|
||||
# its $ROOT/$MODE globals and the output helpers defined there.
|
||||
#
|
||||
# shellcheck shell=bash
|
||||
# shellcheck source=../boot-repair
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Repair steps
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# The root filesystem is sometimes left read-only after a failed boot; every
|
||||
# later step would fail confusingly without this.
|
||||
ensure_root_writable() {
|
||||
is_live || return 0
|
||||
local testfile
|
||||
testfile="$(rpath /.boot-repair-write-test)"
|
||||
if (: >"$testfile") 2>/dev/null; then
|
||||
rm -f "$testfile"
|
||||
return 0
|
||||
fi
|
||||
problem "root filesystem is read-only"
|
||||
if acting; then
|
||||
mount -o remount,rw / || die "could not remount / read-write"
|
||||
repaired "remounted / read-write"
|
||||
else
|
||||
would "remount / read-write"
|
||||
fi
|
||||
}
|
||||
|
||||
# Restore just enough of the RUNNING kernel's module tree to mount a vfat ESP.
|
||||
# Without this the repair cannot even reach the ESP: mounting it needs vfat,
|
||||
# and vfat lives in the module tree the upgrade deleted.
|
||||
ensure_vfat() {
|
||||
is_live || return 0
|
||||
if modprobe -n vfat >/dev/null 2>&1 && modprobe vfat >/dev/null 2>&1; then
|
||||
ok "vfat available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
problem "vfat cannot be loaded for the running kernel ($(uname -r))"
|
||||
if ! acting; then
|
||||
would "restore fat/nls modules from the pacman cache"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local kver pkgver cache pkg
|
||||
kver="$(uname -r)"
|
||||
# uname 7.0.5-arch1-1 -> package 7.0.5.arch1-1
|
||||
pkgver="${kver/-arch/.arch}"
|
||||
cache="$(rpath /var/cache/pacman/pkg)"
|
||||
|
||||
pkg=""
|
||||
if [[ -d $cache ]]; then
|
||||
# Newest matching cached kernel package for the running version.
|
||||
pkg="$(find "$cache" -maxdepth 1 -name "linux*-${pkgver}-*.pkg.tar.zst" 2>/dev/null | sort -V | tail -1)"
|
||||
fi
|
||||
if [[ -z $pkg ]]; then
|
||||
problem "no cached package for the running kernel in $cache"
|
||||
msg ""
|
||||
msg " Cannot restore vfat offline. Recover with an Arch ISO:"
|
||||
msg " mount /dev/<root> /mnt -o subvol=@ && arch-chroot /mnt"
|
||||
msg " pacman -S linux && $SCRIPT_NAME"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "restoring fat/nls modules from ${pkg##*/}"
|
||||
local tmp
|
||||
tmp="$(mktemp -d)" || die "mktemp failed"
|
||||
# shellcheck disable=SC2064 # expand $tmp now, on purpose
|
||||
trap "rm -rf '$tmp'" RETURN
|
||||
|
||||
# Extract only the filesystem modules needed to read a FAT32 ESP.
|
||||
tar --zstd -xf "$pkg" -C "$tmp" --wildcards \
|
||||
"usr/lib/modules/${kver}/kernel/fs/fat/*" \
|
||||
"usr/lib/modules/${kver}/kernel/fs/nls/*" 2>/dev/null ||
|
||||
{
|
||||
problem "could not extract fat/nls modules from ${pkg##*/}"
|
||||
return 1
|
||||
}
|
||||
|
||||
local src dest
|
||||
src="$tmp/usr/lib/modules/${kver}/kernel"
|
||||
dest="$(rpath "/usr/lib/modules/${kver}")"
|
||||
[[ -d $src ]] || {
|
||||
problem "cached package did not contain fat/nls modules"
|
||||
return 1
|
||||
}
|
||||
mkdir -p "$dest"
|
||||
cp -a "$src" "$dest/"
|
||||
depmod "$kver" 2>/dev/null || true
|
||||
if modprobe vfat >/dev/null 2>&1; then
|
||||
repaired "restored vfat for the running kernel"
|
||||
return 0
|
||||
fi
|
||||
problem "vfat still will not load after restore"
|
||||
return 1
|
||||
}
|
||||
|
||||
# fstab must GATE on the ESP. `nofail` turns this failure into a silent boot of
|
||||
# a stale kernel, which is strictly worse: the machine looks fine until the
|
||||
# module tree disappears, one upgrade later.
|
||||
check_fstab_options() {
|
||||
local opts fstab
|
||||
fstab="$(rpath /etc/fstab)"
|
||||
opts="$(fstab_boot_options)" || {
|
||||
problem "no /boot entry in $fstab"
|
||||
return 1
|
||||
}
|
||||
if [[ $opts != *noauto* && $opts != *nofail* ]]; then
|
||||
ok "fstab gates on /boot ($opts)"
|
||||
return 0
|
||||
fi
|
||||
problem "fstab has noauto/nofail on /boot ($opts) — failures would be silent"
|
||||
if ! acting; then
|
||||
would "rewrite the /boot options to 'defaults'"
|
||||
return 1
|
||||
fi
|
||||
cp -a "$fstab" "${fstab}.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
# Rewrite only the options field of the /boot line.
|
||||
awk -v mp="$ESP_MOUNTPOINT" '
|
||||
/^[[:space:]]*#/ { print; next }
|
||||
NF >= 4 && $2 == mp { $4 = "defaults"; print; next }
|
||||
{ print }
|
||||
' OFS='\t' "$fstab" >"${fstab}.new"
|
||||
mv "${fstab}.new" "$fstab"
|
||||
repaired "fstab /boot options reset to 'defaults'"
|
||||
if is_live; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Orphaned kernel files written into the ROOT filesystem's /boot directory
|
||||
# while the ESP was unmounted. They shadow the real ESP and waste ~250 MB.
|
||||
#
|
||||
# This is the single most dangerous step in the script: doing it after mounting
|
||||
# would delete the real kernel. Hence the three hard guards below.
|
||||
clean_shadow_files() {
|
||||
local bootdir
|
||||
bootdir="$(rpath "$ESP_MOUNTPOINT")"
|
||||
[[ -d $bootdir ]] || return 0
|
||||
|
||||
# GUARD 1: never touch a mounted /boot.
|
||||
if boot_is_mounted; then
|
||||
return 0
|
||||
fi
|
||||
# GUARD 2: a real ESP always has EFI/ or loader/. If either is present in
|
||||
# something we believe is unmounted, our mount detection is wrong — stop.
|
||||
if [[ -d $bootdir/EFI || -d $bootdir/loader ]]; then
|
||||
warn "unmounted $ESP_MOUNTPOINT contains EFI/ or loader/ — refusing to delete anything"
|
||||
return 1
|
||||
fi
|
||||
# GUARD 3: on a BIOS/GRUB system there is no ESP at all, so an unmounted
|
||||
# /boot is not a shadow — it is the only kernel the machine has. Deleting it
|
||||
# leaves the machine unbootable (measured 2026-08-22: a BIOS guest never
|
||||
# booted again). --esp is the deliberate override: naming the device by hand
|
||||
# asserts the firmware assumption that /sys/firmware/efi otherwise proves.
|
||||
if ! system_is_uefi && [[ -z $ESP_DEV_OVERRIDE ]]; then
|
||||
warn "no /sys/firmware/efi — this system booted via BIOS and has no ESP;"
|
||||
warn " $ESP_MOUNTPOINT holds the real kernel. Refusing to delete anything."
|
||||
warn " If that is wrong, name the ESP explicitly: $SCRIPT_NAME --esp DEVICE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local -a shadows=()
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[[ -n $f ]] && shadows+=("$f")
|
||||
done < <(find "$bootdir" -maxdepth 1 -type f \
|
||||
\( -name 'vmlinuz-*' -o -name 'initramfs-*.img' -o -name '*-ucode.img' \) 2>/dev/null)
|
||||
|
||||
[[ ${#shadows[@]} -gt 0 ]] || return 0
|
||||
|
||||
problem "${#shadows[@]} orphaned kernel file(s) in the unmounted $ESP_MOUNTPOINT (shadowing the ESP)"
|
||||
if ! acting; then
|
||||
would "delete: ${shadows[*]##*/}"
|
||||
return 1
|
||||
fi
|
||||
rm -f "${shadows[@]}"
|
||||
repaired "removed ${#shadows[@]} shadow file(s) from the root filesystem"
|
||||
return 0
|
||||
}
|
||||
|
||||
mount_esp() {
|
||||
is_live || return 0
|
||||
if boot_is_mounted; then
|
||||
ok "$ESP_MOUNTPOINT mounted ($(findmnt -n -o SOURCE "$ESP_MOUNTPOINT"))"
|
||||
return 0
|
||||
fi
|
||||
problem "$ESP_MOUNTPOINT is not mounted"
|
||||
if ! acting; then
|
||||
would "mount the ESP at $ESP_MOUNTPOINT"
|
||||
return 1
|
||||
fi
|
||||
local dev
|
||||
dev="$(resolve_esp_device)" || {
|
||||
problem "could not determine the ESP device"
|
||||
return 1
|
||||
}
|
||||
mkdir -p "$ESP_MOUNTPOINT"
|
||||
mount -t vfat "$dev" "$ESP_MOUNTPOINT" || {
|
||||
problem "mounting $dev at $ESP_MOUNTPOINT failed"
|
||||
return 1
|
||||
}
|
||||
repaired "mounted $dev at $ESP_MOUNTPOINT"
|
||||
return 0
|
||||
}
|
||||
154
linux_configuration/boot_recovery/lib/repair_kernel.sh
Executable file
154
linux_configuration/boot_recovery/lib/repair_kernel.sh
Executable file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repair steps that operate on the kernel: depmod, the kernel image, the
|
||||
# initramfs, and the pacman parallel-download check.
|
||||
#
|
||||
# Sourced by boot-repair, not executed. Split out of it verbatim to bring the
|
||||
# entry script under the 250-line cap; it inherits the caller's strict mode,
|
||||
# its $ROOT/$MODE globals and the output helpers defined there.
|
||||
#
|
||||
# shellcheck shell=bash
|
||||
# shellcheck source=../boot-repair
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# depmod output is what modprobe consults; the same skipped-hook bug that
|
||||
# leaves the ESP stale also leaves modules.dep missing for the new kernel.
|
||||
ensure_depmod() {
|
||||
local kver dep
|
||||
kver="$1"
|
||||
dep="$(rpath "/usr/lib/modules/${kver}/modules.dep")"
|
||||
if [[ -f $dep ]]; then
|
||||
ok "modules.dep present for $kver"
|
||||
return 0
|
||||
fi
|
||||
problem "modules.dep missing for $kver"
|
||||
if ! acting; then
|
||||
would "run depmod $kver"
|
||||
return 1
|
||||
fi
|
||||
if is_live; then
|
||||
depmod "$kver" || {
|
||||
problem "depmod $kver failed"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
depmod -b "${ROOT%/}" "$kver" || {
|
||||
problem "depmod failed"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
repaired "generated modules.dep for $kver"
|
||||
return 0
|
||||
}
|
||||
|
||||
# The core repair: make the ESP's kernel image match the installed module tree.
|
||||
sync_kernel_image() {
|
||||
local kver src dest have
|
||||
kver="$1"
|
||||
src="$(rpath "/usr/lib/modules/${kver}/vmlinuz")"
|
||||
dest="$(rpath "${ESP_MOUNTPOINT}/vmlinuz-linux")"
|
||||
|
||||
[[ -f $src ]] || {
|
||||
problem "no kernel image at $src"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -f $dest ]]; then
|
||||
have="$(kernel_image_version "$dest" || true)"
|
||||
if [[ $have == "$kver" ]]; then
|
||||
ok "ESP kernel matches installed tree ($kver)"
|
||||
return 0
|
||||
fi
|
||||
problem "ESP kernel is ${have:-unreadable} but installed tree is $kver"
|
||||
else
|
||||
problem "no kernel image on the ESP"
|
||||
fi
|
||||
|
||||
if ! acting; then
|
||||
would "install $kver to $dest"
|
||||
return 1
|
||||
fi
|
||||
install -Dm644 "$src" "$dest"
|
||||
repaired "installed kernel $kver to the ESP"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Which kernel an initramfs was built for. Prefers lsinitcpio, but falls back
|
||||
# to scanning the image directly — lsinitcpio is not guaranteed to be present
|
||||
# in a degraded emergency shell.
|
||||
initramfs_version() {
|
||||
local img="$1" v=""
|
||||
[[ -r $img ]] || return 1
|
||||
if command -v lsinitcpio >/dev/null 2>&1; then
|
||||
v="$(lsinitcpio "$img" 2>/dev/null |
|
||||
grep -oE 'modules/[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' |
|
||||
head -1 | cut -d/ -f2 || true)"
|
||||
fi
|
||||
if [[ -z $v ]]; then
|
||||
v="$(grep -aoE 'modules/[0-9]+\.[0-9]+\.[0-9]+-arch[0-9]+-[0-9]+' "$img" 2>/dev/null |
|
||||
head -1 | cut -d/ -f2 || true)"
|
||||
fi
|
||||
[[ -n $v ]] || return 1
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
# Rebuild every preset so the initramfs matches the kernel we just installed.
|
||||
rebuild_initramfs() {
|
||||
local kver img have
|
||||
kver="$1"
|
||||
img="$(rpath "${ESP_MOUNTPOINT}/initramfs-linux.img")"
|
||||
|
||||
have="$(initramfs_version "$img" || true)"
|
||||
|
||||
if [[ -n $have && $have == "$kver" ]]; then
|
||||
ok "initramfs matches $kver"
|
||||
return 0
|
||||
fi
|
||||
|
||||
problem "initramfs is ${have:-missing/unknown}, expected $kver"
|
||||
if ! acting; then
|
||||
would "run mkinitcpio -P"
|
||||
return 1
|
||||
fi
|
||||
is_live || {
|
||||
warn "skipping mkinitcpio (--root given)"
|
||||
return 1
|
||||
}
|
||||
command -v mkinitcpio >/dev/null 2>&1 || {
|
||||
problem "mkinitcpio is not installed"
|
||||
return 1
|
||||
}
|
||||
mkinitcpio -P || {
|
||||
problem "mkinitcpio -P failed"
|
||||
return 1
|
||||
}
|
||||
repaired "rebuilt initramfs for $kver"
|
||||
return 0
|
||||
}
|
||||
|
||||
# The actual root cause on this machine. With ParallelDownloads > 1, pacman-git
|
||||
# segfaults every forked hook child, so mkinitcpio and depmod never run and the
|
||||
# transaction still reports success.
|
||||
check_parallel_downloads() {
|
||||
local conf value
|
||||
conf="$(rpath /etc/pacman.conf)"
|
||||
[[ -r $conf ]] || return 0
|
||||
value="$(grep -E '^[[:space:]]*ParallelDownloads[[:space:]]*=' "$conf" 2>/dev/null |
|
||||
tail -1 | sed 's/.*=[[:space:]]*//' | tr -d '[:space:]')"
|
||||
[[ -n $value ]] || {
|
||||
ok "ParallelDownloads not set (pacman default is serial)"
|
||||
return 0
|
||||
}
|
||||
if [[ $value == "1" ]]; then
|
||||
ok "ParallelDownloads = 1"
|
||||
return 0
|
||||
fi
|
||||
problem "ParallelDownloads = $value — forked alpm hooks segfault, silently skipping mkinitcpio"
|
||||
if ! acting; then
|
||||
would "set ParallelDownloads = 1 in $conf"
|
||||
return 1
|
||||
fi
|
||||
cp -a "$conf" "${conf}.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
sed -i -E 's/^([[:space:]]*ParallelDownloads[[:space:]]*=[[:space:]]*).*/\11/' "$conf"
|
||||
repaired "set ParallelDownloads = 1"
|
||||
return 0
|
||||
}
|
||||
25
linux_configuration/boot_recovery/lib/tests/run_all.sh
Executable file
25
linux_configuration/boot_recovery/lib/tests/run_all.sh
Executable file
@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the suite that covers this directory's libraries.
|
||||
#
|
||||
# The libraries here are sourced by ../../boot-repair and depend on its strict
|
||||
# mode, its $ROOT/$MODE globals and its output helpers, so they cannot be
|
||||
# exercised in isolation without reimplementing all of that. The real suite
|
||||
# drives the entry script against throwaway fixture root trees, which executes
|
||||
# every one of them end to end -- that is the honest test, so this runner
|
||||
# delegates to it rather than standing up a second, weaker harness.
|
||||
#
|
||||
# This file's existence is also what meta/scripts/check_shell_coverage.sh
|
||||
# checks: is_covered() looks for tests/run_all.sh beside the libs and nothing
|
||||
# more, so a stub here would mark them covered while testing nothing.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SUITE="${HERE}/../../tests/test_boot_repair.sh"
|
||||
|
||||
[[ -x "$SUITE" ]] || {
|
||||
printf 'boot_recovery lib tests: %s is missing or not executable\n' "$SUITE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf '\n=== %s ===\n' "$(basename "$SUITE")"
|
||||
exec "$SUITE"
|
||||
@ -50,11 +50,20 @@ usage() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# True when the path carries a source extension we cap.
|
||||
# True when the path carries a source extension we cap, OR has no extension but
|
||||
# starts with a shebang.
|
||||
#
|
||||
# The shebang branch exists because an extensionless executable is still
|
||||
# hand-written logic: boot_recovery/boot-repair sat at 756 lines -- three times
|
||||
# the largest capped shell file in the repo -- purely because its name has no
|
||||
# dot in it.
|
||||
is_source_file() {
|
||||
local path="$1" ext="${1##*.}"
|
||||
# A path with no dot in its final component has no extension to match.
|
||||
[[ "$path" == *.* ]] || return 1
|
||||
if [[ "$path" != *.* ]]; then
|
||||
[[ -f "$path" ]] || return 1
|
||||
[[ "$(head -c 2 "$path" 2>/dev/null)" == "#!" ]] && return 0
|
||||
return 1
|
||||
fi
|
||||
local candidate
|
||||
for candidate in "${SOURCE_EXTENSIONS[@]}"; do
|
||||
[[ "$ext" == "$candidate" ]] && return 0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user