From 9d5c6e25e7e97db5dd0152650c81d086c465f6da Mon Sep 17 00:00:00 2001 From: Tommy Martin <54511349+tmn73@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:12:13 -0500 Subject: [PATCH] Keep root= in the kernel cmdline when upgrading to Quattro (#6579) * Keep root= in the kernel cmdline when upgrading to Quattro The packaged drop-in /etc/limine-entry-tool.d/omarchy-defaults.conf sets KERNEL_CMDLINE[default] with the += operator. limine-entry-tool.conf documents what that costs: "+= appends parameters to an existing cmdline ... Ignores /etc/kernel/cmdline and /proc/cmdline". As soon as the drop-in lands, the tool stops auto-detecting the cmdline. Fresh installs are unaffected, because the ISO writes /etc/default/limine from default/limine/default.conf with @@CMDLINE@@ substituted. The upgrade path never created that file. A pre-quattro install that relied on the auto-detected root= therefore ends up with a cmdline that has no root= at all, in both limine.conf entries and in the cmdline embedded in the UKIs. The next boot fails with "ERROR: Failed to mount '' on real root" and drops to an emergency shell, where the error gives no hint that the cmdline is the cause. Capture the boot-critical parameters from /proc/cmdline before the reboot, while the still-correct cmdline of the running kernel is readable, and write them to /etc/default/limine, which is loaded last so += keeps the drop-in parameters instead of replacing them. Copy root=, rootflags, rootfstype, resume, resume_offset, the cryptdevice and rd.luks keys and rw/ro verbatim rather than reconstructing them, so LUKS and hibernation setups survive too. Re-running the upgrade on an already-broken system has no root= left to copy, so fall back to deriving it from the mounted root, including the subvolume on btrfs. Any config layer that already pins root= is treated as authoritative and left untouched. * Anchor the cmdline guard and harden the repair path The early-return guard searched for the bare string root=, which matches the commented example limine-entry-tool.conf ships at line 53: #KERNEL_CMDLINE[default]+=rw root=UUID=... That file is present on every stock machine, so the guard always fired and the function never wrote anything. Match assignments instead, and check /etc/kernel/cmdline separately since it holds bare parameters rather than shell assignments. Three fixes on the repair path: Assigning boot_params discarded every parameter the collection loop had just captured, so cryptdevice, cryptkey, resume and ro were dropped, and rw was forced over a captured ro. Prepend the derived root= instead, and only add rw when the booted cmdline stated no mount mode. On an encrypted root, findmnt reports the unlocked mapper device, whose UUID says nothing about which container to unlock. Emitting it produced a cmdline that still could not boot while satisfying the final check, so the user rebooted into the same emergency shell believing it was repaired. Warn and write nothing in that case. The allowlist gained rd.luks.key, rd.luks.crypttab, rd.md.uuid, rd.dm.uuid, rootwait, rootdelay and dm-mod.create. Verification now also reads the .cmdline section of each UKI. With omarchy-uki.conf among the drop-ins that embedded copy is what actually boots, so a green limine.conf alone did not prove the machine would come up. * Filter guard paths and narrow the dm-crypt and UKI checks The guard passed /etc/default/limine to grep unconditionally, and that file is absent on exactly the machines this targets. A missing operand makes grep exit 2 without -q, so a drop-in pinning a real root= went undetected and the function appended a second one, overriding the explicit setup it promises to leave alone. Rather than relying on -q returning 0 despite the error, which is a GNU grep special case and not true of every implementation, filter the paths first and only grep the ones that exist. The exit status is then unambiguous. The dm-crypt check gated on the /dev/mapper/* prefix, which also matches plain LVM, dm-raid and multipath. Those roots need no unlock parameters and were repairable before, so the prefix test denied them a working root=UUID= and told them they were encrypted. Gate on the device mapper target type instead. root_filesystem_encrypted() is not reused here on purpose: it treats every /dev/mapper/* path and any non-empty /etc/crypttab as an encrypted root, which suits its own call site but would reintroduce the same false positive. UKI verification now runs through as_root, since a restrictive ESP fmask would otherwise make find return nothing and the check pass in silence, and is scoped to the omarchy_linux*.efi images limine-entry-tool generates so a shared ESP or a stub without a .cmdline section cannot raise a false "do not reboot" warning. The allowlist gained rd.lvm.lv and rd.lvm.vg. * Strip the subvolume before resolving the root device type findmnt appends the subvolume for btrfs mounts, so the source read back for an encrypted btrfs root is /dev/mapper/cryptroot[/@]. lsblk cannot resolve that path, the device type came back empty, and the crypt gate never fired. The function then wrote root=UUID= with no unlock parameters, limine.conf ended up carrying a root= so the final check stayed quiet, and the machine still booted to an emergency shell. That is the layout Omarchy installs when encryption is picked, so the gate missed exactly the roots it exists for. The previous /dev/mapper/* prefix test matched the bracketed form by accident. Moving to the device mapper target type is still the right call, it just needs the unbracketed source, which findmnt --nofsroot provides. Also give root_type an explicit empty default. It is assigned inside a branch and read outside it, and set -u treats a declared-but-unassigned local as unbound, so a findmnt that cannot answer would abort the upgrade with the quattro packages already installed and everything from configure_snapper_policy onward skipped. * Look for the crypt layer across the whole device stack lsblk -no TYPE reports only the target's own type. On the standard full-disk encryption layout, LUKS container -> LVM PV -> root LV, that type is lvm and the crypt layer sits in the parents, so the gate never fired: the function wrote root=UUID= with no unlock parameters, the final check found a root= and stayed quiet, and the machine still booted to an emergency shell. Walk the parents with lsblk -s and look for a crypt layer anywhere in the chain. That keeps LVM, dm-raid and multipath roots on the repair path, since they carry no crypt layer and root=UUID= is enough once mkinitcpio assembles them. root_type is replaced by root_stacks_crypt, which says what is actually being tested and drops the LVM-versus-crypt caveat the old target-type check needed. Also drop a vacuous test assertion: piping a bracketed literal through grep -qv '\[' selects nothing, so the branch was unreachable and the case passed whatever the script did. The --nofsroot assertion above it is what holds that fix. * Keep the crypt gate off a pipeline exit status Capture the device stack and match it from a here-string rather than piping lsblk into grep -q. Under pipefail a short-circuiting grep can leave the producer with SIGPIPE and turn the pipeline into 141, which reads as "no crypt layer" and disarms the gate silently. lsblk writes its whole table in one go, so this is out of reach in practice, but nothing about the gate should depend on how much output a helper happens to buffer. Also correct a stale test comment that described the target-type check the previous revision used, four lines above the comment explaining why that check was insufficient. * Harden the kernel cmdline preservation against false root= pins The /etc/kernel/cmdline early return trusted a file limine-entry-tool ignores once a += drop-in sets KERNEL_CMDLINE[default], leaving exactly the targeted machines unbootable. The pin guard now reads only the *.conf layers the tool loads, only the default key, and tokenizes the assignment value so quoted decoys and volatile-root= cannot pin. /proc/cmdline is tokenized quote-aware so dm-mod.create="..." survives verbatim, the root= verification is token-anchored, and an unverified cmdline now blocks the reboot instead of only warning. Co-Authored-By: Claude Fable 5 * Ask limine-entry-tool for the effective cmdline instead of parsing its configs --get-cmdline default answers whether root= survives the tool's own config merge, replacing the glob, grep and quote-aware tokenizer walk over the config layers, and the quote-aware /proc/cmdline parsing reverts to plain word splitting. The verification and the reboot gate stay: they are what catches anything the simpler paths miss. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Fable 5 --- bin/omarchy-upgrade-to-quattro | 118 +++++++++++++++++++++++- test/shell.d/upgrade-to-quattro-test.sh | 39 ++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 97aea8e2..974f956f 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -56,6 +56,7 @@ channel_override_cli=0 target_user="${OMARCHY_INSTALL_USER:-}" yes=0 auto_reboot=0 +boot_cmdline_unsafe=0 use_dev_packages=${OMARCHY_UPGRADE_DEV:-0} while (($#)); do @@ -471,6 +472,118 @@ normalize_limine_config() { fi } +preserve_kernel_cmdline_root() { + local default_conf=/etc/default/limine + local cmdline param key root_source root_stack root_uuid subvol uki + local boot_params=() + local missing=() + local have_root=0 have_mount_mode=0 have_unlock=0 + + command -v limine-mkinitcpio >/dev/null 2>&1 || return 0 + as_root test -f /boot/limine.conf || return 0 + + # The omarchy-defaults.conf drop-in appends to KERNEL_CMDLINE[default] with + # +=, which makes limine-entry-tool ignore /etc/kernel/cmdline and + # /proc/cmdline. Fresh installs pin root= in /etc/default/limine via the ISO; + # upgrades never created that file, so root= silently drops out and the next + # boot lands in an emergency shell. Ask the tool for its effective default + # cmdline — the key this function appends to — rather than parsing the config + # layers ourselves. + if as_root limine-entry-tool --get-cmdline default 2>/dev/null | + grep -qE '(^|[[:space:]])root='; then + return 0 + fi + + # Copy the boot-critical parameters of the running kernel verbatim, so a + # PARTUUID, a LABEL or a bare device node all survive. + cmdline=$(cat /proc/cmdline 2>/dev/null || true) + for param in $cmdline; do + key=${param%%=*} + case $key in + root) + have_root=1 + boot_params+=("$param") + ;; + rw | ro) + have_mount_mode=1 + boot_params+=("$param") + ;; + cryptdevice | cryptkey | rd.luks.name | rd.luks.uuid | rd.luks.options | rd.luks.key | rd.luks.crypttab | dm-mod.create) + have_unlock=1 + boot_params+=("$param") + ;; + rootflags | rootfstype | rootwait | rootdelay | resume | resume_offset | rd.lvm.lv | rd.lvm.vg | rd.md.uuid | rd.dm.uuid) + boot_params+=("$param") + ;; + esac + done + + # Already-broken system (booted through the emergency shell): derive root= + # from the mounted root instead. Refuse when the root sits on dm-crypt and + # the cmdline carries no unlock parameters, since root= alone would still not + # boot. lsblk -s walks the parents, where the crypt layer hides on + # LVM-on-LUKS; --nofsroot keeps findmnt from appending a btrfs subvolume + # lsblk cannot resolve; the capture keeps a SIGPIPE under pipefail from + # reading as "no crypt layer". + if ((!have_root)); then + root_source=$(findmnt -no SOURCE --nofsroot / 2>/dev/null || true) + root_stack=$(lsblk -nso TYPE "$root_source" 2>/dev/null || true) + if grep -qx crypt <<<"$root_stack" && ((!have_unlock)); then + warn "The root filesystem is on dm-crypt and the booted kernel cmdline carries no unlock parameters, so they cannot be recovered automatically. Add root= and the matching cryptdevice or rd.luks.* parameters to $default_conf by hand, then run limine-mkinitcpio." + boot_cmdline_unsafe=1 + return 0 + fi + + root_uuid=$(findmnt -no UUID / 2>/dev/null || true) + if [[ -z $root_uuid ]]; then + warn "Could not determine the root filesystem UUID; the kernel cmdline was left untouched." + boot_cmdline_unsafe=1 + return 0 + fi + + boot_params=("root=UUID=$root_uuid" "${boot_params[@]}") + ((have_mount_mode)) || boot_params+=(rw) + + # Btrfs needs its subvolume too, or the initramfs mounts the wrong tree. + subvol=$(findmnt -no FSROOT / 2>/dev/null || true) + if [[ $(findmnt -no FSTYPE / 2>/dev/null) == "btrfs" && -n $subvol && $subvol != "/" ]]; then + boot_params+=("rootflags=subvol=${subvol#/}") + fi + fi + + log "Preserving the kernel cmdline root parameters in $default_conf" + as_root tee -a "$default_conf" >/dev/null </dev/null) + fi + + if ((${#missing[@]})); then + warn "root= is still missing from ${missing[*]}. Do not reboot until the kernel cmdline is repaired, or the system will drop to an emergency shell." + boot_cmdline_unsafe=1 + fi +} + configure_snapper_policy() { local snapper_config_script=/usr/share/omarchy/install/config/snapper.sh @@ -2251,6 +2364,7 @@ remove_conflicting_legacy_packages install_omarchy_quattro_packages install_hardware_transition_packages normalize_limine_config +preserve_kernel_cmdline_root configure_snapper_policy configure_lock_authentication migrate_1password_beta_package @@ -2284,7 +2398,9 @@ WARNING: You must address any errors in the above before rebooting. EOF -if (( auto_reboot )); then +if (( boot_cmdline_unsafe )); then + warn "Skipping reboot: root= could not be confirmed in the boot entries. Repair the kernel cmdline (see the warnings above) before rebooting, or the system will drop to an emergency shell." +elif (( auto_reboot )); then log "Rebooting because --reboot was passed" as_root systemctl reboot elif gum confirm "Reboot to complete Quattro upgrade now?" /dev/null grep -F '/etc/systemd/user.conf.d/99-omarchy-nofile.conf' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade removes stale nofile drop-ins" + +cmdline_line=$(grep -n '^preserve_kernel_cmdline_root$' "$upgrade_to_quattro" | cut -d: -f1) +packages_line=$(grep -n '^install_omarchy_quattro_packages$' "$upgrade_to_quattro" | cut -d: -f1) +[[ -n $cmdline_line && -n $packages_line ]] || fail "kernel cmdline preservation and package install calls exist" +(( packages_line < cmdline_line )) || fail "kernel cmdline preservation runs once limine-mkinitcpio is installed" +grep -F '/etc/default/limine' "$upgrade_to_quattro" >/dev/null +grep -F 'KERNEL_CMDLINE[default]+=" ${boot_params[*]}"' "$upgrade_to_quattro" >/dev/null +grep -F 'cat /proc/cmdline' "$upgrade_to_quattro" >/dev/null +grep -F 'findmnt -no UUID /' "$upgrade_to_quattro" >/dev/null +grep -F 'rootflags=subvol=' "$upgrade_to_quattro" >/dev/null +grep -F 'cryptdevice' "$upgrade_to_quattro" >/dev/null +pass "Omarchy 4 upgrade preserves the kernel cmdline root parameters" + +# The += drop-ins make limine-entry-tool ignore /etc/kernel/cmdline and +# /proc/cmdline, so only the tool's own merge can say whether root= survives. +# Queried for the default key, so a kernel-specific pin cannot cover for the +# entries this repairs. +grep -F 'limine-entry-tool --get-cmdline default' "$upgrade_to_quattro" >/dev/null +grep -F "grep -qE '(^|[[:space:]])root='" "$upgrade_to_quattro" >/dev/null +pass "Omarchy 4 upgrade asks limine-entry-tool whether root= survives" + +# The crypt layer hides in the parents on LVM-on-LUKS, and a partial cmdline +# for an encrypted root must not be written at all. +grep -F 'findmnt -no SOURCE --nofsroot /' "$upgrade_to_quattro" >/dev/null +grep -F 'lsblk -nso TYPE "$root_source"' "$upgrade_to_quattro" >/dev/null +grep -F 'grep -qx crypt' "$upgrade_to_quattro" >/dev/null +grep -F '((have_mount_mode)) || boot_params+=(rw)' "$upgrade_to_quattro" >/dev/null +pass "Omarchy 4 upgrade repair path refuses a partial dm-crypt cmdline" + +# The cmdline that boots is the one embedded in the UKIs, and an unverified +# root= must block the reboot rather than just warn. +grep -F -- '--only-section=.cmdline' "$upgrade_to_quattro" >/dev/null +grep -F "as_root find /boot/EFI/Linux -maxdepth 1 -name 'omarchy_linux*.efi'" "$upgrade_to_quattro" >/dev/null +grep -F 'boot_cmdline_unsafe=1' "$upgrade_to_quattro" >/dev/null +unsafe_line=$(grep -n 'if (( boot_cmdline_unsafe )); then' "$upgrade_to_quattro" | cut -d: -f1) +reboot_line=$(grep -n 'Rebooting because --reboot was passed' "$upgrade_to_quattro" | cut -d: -f1) +[[ -n $unsafe_line && -n $reboot_line ]] || fail "reboot gate and reboot branch exist" +(( unsafe_line < reboot_line )) || fail "an unverified kernel cmdline blocks the reboot" +pass "Omarchy 4 upgrade verifies the UKIs and refuses to reboot unverified"