diff --git a/omarchy-mac-boot/bin/omarchy-mac-boot-update b/omarchy-mac-boot/bin/omarchy-mac-boot-update index d844794..bd96d95 100755 --- a/omarchy-mac-boot/bin/omarchy-mac-boot-update +++ b/omarchy-mac-boot/bin/omarchy-mac-boot-update @@ -12,17 +12,22 @@ set -uo pipefail +# Installed next to /usr/lib/omarchy-mac/boot; in the source tree, in ../lib. +grub_keep=$(dirname -- "$(realpath -- "$0")")/../lib/omarchy-mac/boot/grub-keep.sh +[[ -r $grub_keep ]] || grub_keep=$(dirname -- "$(realpath -- "$0")")/../lib/grub-keep.sh +# shellcheck source=../lib/grub-keep.sh +source "$grub_keep" || { echo "omarchy-mac-boot-update: cannot load $grub_keep" >&2; exit 1; } + if ! omarchy-mac-limine-active; then update-grub || { echo "omarchy-mac-boot-update: update-grub failed" >&2; exit 1; } exit 0 fi # A Limine Mac that still carries GRUB keeps its (unused) image current, so a # rollback to GRUB has something to boot; an image without GRUB has none. -# update-grub ships with asahi-scripts even where GRUB itself does not, and -# fails without grub-probe, so GRUB's own tools are the test. A fresh image's -# first boot (OMARCHY_MAC_BOOT_UPDATE_GRUB=0) never booted GRUB and skips it. -if [[ ${OMARCHY_MAC_BOOT_UPDATE_GRUB:-1} != 0 ]] && - omarchy-cmd-present grub-probe && omarchy-cmd-present grub-mkconfig; then +# grub_tools_present is that one test, shared with lib/provision.sh. A fresh +# image's first boot (OMARCHY_MAC_BOOT_UPDATE_GRUB=0) never booted GRUB and +# skips it. +if [[ ${OMARCHY_MAC_BOOT_UPDATE_GRUB:-1} != 0 ]] && grub_tools_present; then update-grub >/dev/null || echo "omarchy-mac-boot-update: update-grub failed; Limine is unaffected" >&2 fi omarchy-mac-limine-cmdline || { echo "omarchy-mac-boot-update: could not derive the Limine command line" >&2; exit 1; } diff --git a/omarchy-mac-boot/lib/grub-keep.sh b/omarchy-mac-boot/lib/grub-keep.sh new file mode 100644 index 0000000..276d501 --- /dev/null +++ b/omarchy-mac-boot/lib/grub-keep.sh @@ -0,0 +1,8 @@ +# Whether GRUB is kept: omarchy-mac-boot-update regenerates GRUB's image only +# where GRUB's own tools exist, because update-grub fails without grub-probe. +# omarchy-mac-boot-update and provision.sh decide this one thing here, and +# nowhere else. OMARCHY_MAC_BOOT_UPDATE_GRUB is not part of it: it suppresses +# a rebuild, it does not stop GRUB from booting the image already there. +grub_tools_present() { + omarchy-cmd-present grub-probe && omarchy-cmd-present grub-mkconfig +} diff --git a/omarchy-mac-boot/lib/provision.sh b/omarchy-mac-boot/lib/provision.sh index b47bd1b..b7e757b 100644 --- a/omarchy-mac-boot/lib/provision.sh +++ b/omarchy-mac-boot/lib/provision.sh @@ -24,6 +24,8 @@ BOOT_UUID=4f4d5801-424f-4f54-8000-000000000001 # shellcheck source=boot-image-layout.sh source "$MAC_BOOT_ROOT/usr/lib/omarchy-mac/boot/boot-image-layout.sh" +# shellcheck source=grub-keep.sh +source "$MAC_BOOT_ROOT/usr/lib/omarchy-mac/boot/grub-keep.sh" log_step() { printf '%s\n' "$*" >&2; } @@ -90,10 +92,16 @@ apple_rekey_boot() { if grep -q 'rd.luks.key=' "$GRUB_DEFAULT"; then grub_drop_rd_luks_key "$GRUB_DEFAULT" || return 1 fi - if ! mkinitcpio -P &2 || ! omarchy-mac-boot-update >&2; then - log_step "mkinitcpio or omarchy-mac-boot-update failed while dropping the staged key" - return 1 + # A Limine Mac without GRUB boots only the UKI, and omarchy-mac-boot-update + # builds it: that is the one initramfs build the re-key needs, as in + # omarchy-mac-encrypt. /boot's image is rebuilt only where GRUB can boot it. + if limine_mac && ! grub_tools_present; then + omarchy-mac-boot-update >&2 && return + elif "$MAC_BOOT_ROOT/usr/bin/mkinitcpio" -P &2 && omarchy-mac-boot-update >&2; then + return fi + log_step "mkinitcpio or omarchy-mac-boot-update failed while dropping the staged key" + return 1 } state_get() { @@ -112,16 +120,47 @@ limine_mac() { # The initramfs that asks for the owner's password must load the vendor # firmware first, or an M2 or later laptop's keyboard cannot type it. initramfs_orders_firmware() { - local kernel listing + local kernel + kernel=$(omarchy-mac-kernel) || return 1 + image_orders_firmware "$MAC_BOOT_ROOT/boot/initramfs-$kernel.img" "/boot/initramfs-$kernel.img" +} + +# The images this Mac boots: on a Limine Mac the initramfs inside its UKI, +# and /boot's image besides wherever GRUB is kept, because GRUB's retained +# entries boot it -- also where OMARCHY_MAC_BOOT_UPDATE_GRUB only suppressed +# the rebuild. Elsewhere GRUB boots /boot's image. +boot_image_orders_firmware() { + local kernel uki image status=0 + limine_mac || { + initramfs_orders_firmware + return + } kernel=$(omarchy-mac-kernel) || return 1 - if ! listing=$(lsinitcpio -l "$MAC_BOOT_ROOT/boot/initramfs-$kernel.img" 2>/dev/null); then - log_step "cannot list /boot/initramfs-$kernel.img" + uki=$(limine_esp_path)/EFI/Linux/omarchy_$kernel.efi + image=$(mktemp) || return 1 + if objcopy -O binary --only-section=.initrd "$MAC_BOOT_ROOT$uki" "$image" 2>/dev/null && [[ -s $image ]]; then + image_orders_firmware "$image" "the initramfs inside $uki" || status=1 + else + log_step "cannot read the initramfs inside $uki" + status=1 + fi + rm -f "$image" + if grub_tools_present; then + image_orders_firmware "$MAC_BOOT_ROOT/boot/initramfs-$kernel.img" "/boot/initramfs-$kernel.img" || status=1 + fi + return "$status" +} + +image_orders_firmware() { + local listing + if ! listing=$(lsinitcpio -l "$1" 2>/dev/null); then + log_step "cannot list $2" return 1 fi grep -Eq '(^|/)usr/lib/systemd/system-generators/systemd-cryptsetup-generator$' <<<"$listing" && grep -Eq '(^|/)usr/lib/systemd/system/omarchy-vendorfw-initrd\.service$' <<<"$listing" && grep -Eq '(^|/)usr/lib/systemd/system/systemd-cryptsetup@\.service\.d/omarchy-vendorfw-initrd\.conf$' <<<"$listing" || { - log_step "/boot/initramfs-$kernel.img does not load the vendor firmware before the disk password prompt" + log_step "$2 does not load the vendor firmware before the disk password prompt" return 1 } } @@ -297,7 +336,7 @@ provision_prepare() { luks_device_found || refuse "Could not find the encrypted disk that /etc/crypttab names." - initramfs_orders_firmware || + boot_image_orders_firmware || refuse "The boot image would ask for the disk password before the keyboard firmware loads." esp_selected || refuse "The EFI partition this Mac boots from is not where its boot files are written." @@ -317,7 +356,7 @@ provision_commit() { # The boot-partition key goes last: until then the initramfs still unlocks # with it, so a failure only has to put rd.luks.key= back, whichever attempt # dropped it. - if ! apple_rekey_boot || ! initramfs_orders_firmware || ! boot_image_types_layout; then + if ! apple_rekey_boot || ! boot_image_orders_firmware || ! boot_image_types_layout; then if [[ -f $BOOT_LUKS_KEY ]] && ! grep -q 'rd.luks.key=' "$GRUB_DEFAULT" 2>/dev/null; then log_step "restoring rd.luks.key= for the retry" grub_restore_rd_luks_key && omarchy-mac-boot-update >&2 || true diff --git a/omarchy-mac-boot/test/mac-boot-update-test.sh b/omarchy-mac-boot/test/mac-boot-update-test.sh index 2923166..cdf423c 100644 --- a/omarchy-mac-boot/test/mac-boot-update-test.sh +++ b/omarchy-mac-boot/test/mac-boot-update-test.sh @@ -86,3 +86,16 @@ pass "a Mac with no GRUB needs none" FAIL_limine_update=1 run && fail "a failed limine-update fails the boot update" ! grep -q omarchy-mac-limine-deploy "$calls" || fail "Limine is not deployed after a failed limine-update" pass "a failed limine-update stops the boot update" + +# A missing GRUB helper stops the update: the script has no set -e, so an +# unloaded helper would make grub_tools_present return 127 and the update +# would carry on as if GRUB were absent. +helper_less=$test_tmp/helper-less +mkdir -p "$helper_less" +cp "$update" "$helper_less/omarchy-mac-boot-update" +: >"$calls" +if PATH="$stub_bin:$PATH" bash "$helper_less/omarchy-mac-boot-update" 2>"$test_tmp/err"; then + fail "a missing GRUB helper fails the boot update" +fi +[[ ! -s $calls ]] || fail "a missing GRUB helper changes no boot files" "$(cat "$calls")" +pass "a missing GRUB helper stops the boot update" diff --git a/omarchy-mac-boot/test/mac-provision-test.sh b/omarchy-mac-boot/test/mac-provision-test.sh index cf2b2b4..088b7ed 100644 --- a/omarchy-mac-boot/test/mac-provision-test.sh +++ b/omarchy-mac-boot/test/mac-provision-test.sh @@ -33,7 +33,7 @@ SH # initramfs it carries. cat >"$stub_bin/lsinitcpio" <<'SH' #!/bin/bash -[[ $1 == -l && -f $2 ]] && exec cat "$2" +[[ $1 == -l && -f $2 ]] && exec cat "$(f=$(head -n 1 "$2"); [[ $f == /* && -f $f ]] && echo "$f" || echo "$2")" [[ $1 == -x && -f $2 ]] || exit 1 tree=$2.tree [[ -d $tree ]] || tree=$(head -n 1 "$2").tree @@ -63,13 +63,17 @@ done for layout in ${layouts//,/ }; do [[ -f $include/symbols/$layout ]] || exit 1; done SH # mkinitcpio builds from /etc/vconsole.conf as sd-vconsole and the plymouth -# hook do; keep-layout leaves the previous build's layout in the image. -cat >"$stub_bin/mkinitcpio" <"$stub_bin/build-image" <>"$calls" -[[ ! -e $test_tmp/fail-mkinitcpio ]] || exit 1 -image=$root/boot/initramfs-linux-aurora.img -if [[ -e $test_tmp/build-without-firmware ]]; then +image=\$1 +# grub-without-firmware leaves the firmware out of /boot's GRUB image only, +# the way a build can leave one image behind. +if [[ -e $test_tmp/build-without-firmware ]] \\ + || { [[ \${2:-} == uki && -e $test_tmp/uki-without-firmware ]]; } \\ + || { [[ \${2:-} != uki && -e $test_tmp/grub-without-firmware ]]; }; then echo ./usr/lib/systemd/system-generators/systemd-cryptsetup-generator >"\$image" else printf '%s\n' "$firmware_listing" >"\$image" @@ -88,6 +92,16 @@ layout=\$(. "$root/etc/vconsole.conf"; echo "\$XKBLAYOUT") [[ -z \$keymap || -e $test_tmp/build-without-keymap ]] || : >"\$tree/usr/share/kbd/keymaps/i386/qwerty/\$keymap.map.gz" [[ -z \$layout ]] || : >"\$tree/usr/share/X11/xkb/symbols/\$layout" SH +# The re-key builds with the image root's own mkinitcpio; the stub sits where +# the fixed path below MAC_BOOT_ROOT resolves in tests. +mkdir -p "$root/usr/bin" +cat >"$root/usr/bin/mkinitcpio" <>"$calls" +[[ ! -e $test_tmp/fail-mkinitcpio ]] || exit 1 +exec "$stub_bin/build-image" "$root/boot/initramfs-linux-aurora.img" +SH +chmod +x "$root/usr/bin/mkinitcpio" cat >"$stub_bin/omarchy-mac-boot-update" <>"$calls" if [[ -e $root/var/lib/omarchy/limine.enabled ]]; then printf 'ESP_PATH="/boot/efi"\nKERNEL_CMDLINE[default]="%s"\n' "\$cmdline" >"$root/etc/default/limine" mkdir -p "$root/boot/efi/EFI/Linux" - echo "$root/boot/initramfs-linux-aurora.img" >"$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" + "$stub_bin/build-image" "$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi.img" uki + echo "$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi.img" >"$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" else printf 'linux /vmlinuz-linux-aurora %s\n' "\$cmdline" >"$root/boot/grub/grub.cfg" fi +[[ ! -e $test_tmp/fail-boot-update-after-write ]] || exit 1 SH cat >"$stub_bin/findmnt" <<'SH' #!/bin/bash @@ -115,6 +131,12 @@ cat >"$stub_bin/omarchy-mac-esp" <<'SH' [[ -n ${TEST_ESP-/boot/efi} ]] || exit 1 echo "${TEST_ESP-/boot/efi}" SH +# omarchy-cmd-present, as the entrypoints' PATH sees it on a live system: the +# command is the fixture root's /usr/bin. +cat >"$stub_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +[[ -x $OMARCHY_MAC_BOOT_ROOT/usr/bin/$1 ]] +SH # The root's LUKS header: keyslots, then tokens, which luksDump lists alike. cat >"$stub_bin/cryptsetup" <<'SH' #!/bin/bash @@ -149,7 +171,9 @@ fixture() { printf 'format=1\nencrypt=1\n' >"$root/var/lib/omarchy/mac-first-boot/install.conf" printf 'staged_slot=0\nowner_slot=2\nrecovery_slot=3\nrecovery_shown=1\nphase=owner\n' >"$root/var/lib/omarchy/provisioning/luks-rekey.state" printf '%s\n' "$firmware_listing" >"$root/boot/initramfs-linux-aurora.img" - rm -f "$test_tmp"/fail-* "$test_tmp/build-without-firmware" "$test_tmp/build-without-keymap" "$test_tmp/keep-layout" + rm -f "$test_tmp"/fail-* "$test_tmp/build-without-firmware" "$test_tmp/uki-without-firmware" "$test_tmp/grub-without-firmware" \ + "$test_tmp/build-without-keymap" "$test_tmp/keep-layout" + rm -rf "$root/usr/bin/grub-probe" "$root/usr/bin/grub-mkconfig" : >"$calls" } @@ -158,6 +182,10 @@ limine_fixture() { : >"$root/var/lib/omarchy/limine.enabled" printf 'ESP_PATH="/boot/efi"\nKERNEL_CMDLINE[default]="root=UUID=x rw quiet rd.luks.name=%s=root %s"\n' "$luks_uuid" "$key_line" \ >"$root/etc/default/limine" + # The UKI limine-update last built, which this Mac boots. + mkdir -p "$root/boot/efi/EFI/Linux" + "$stub_bin/build-image" "$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi.img" uki + echo "$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi.img" >"$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" } run() { @@ -316,7 +344,53 @@ limine_fixture run provision-commit || fail "commit succeeds on a Limine Mac" "$(cat "$test_tmp/err")" ! grep -q 'rd.luks.key=' "$root/etc/default/limine" || fail "the Limine command line drops rd.luks.key=" run provision-verify || fail "a Limine Mac verifies after commit" "$(cat "$test_tmp/err")" -pass "provision-commit rebuilds a Limine Mac's command line without the staged key" +[[ $(cat "$calls") == "omarchy-mac-boot-update quiet rd.luks.name=$luks_uuid=root" ]] || + fail "a Limine Mac without GRUB builds only the UKI, not /boot's GRUB image" "$(cat "$calls")" +pass "provision-commit rebuilds a Limine Mac's command line without the staged key, with one initramfs build" + +limine_fixture +install -Dm755 /dev/null "$root/usr/bin/grub-probe" +install -Dm755 /dev/null "$root/usr/bin/grub-mkconfig" +run provision-commit || fail "commit succeeds on a Limine Mac that keeps GRUB" "$(cat "$test_tmp/err")" +[[ $(cat "$calls") == $'mkinitcpio -P\n'"omarchy-mac-boot-update quiet rd.luks.name=$luks_uuid=root" ]] || + fail "a Limine Mac that keeps GRUB also rebuilds GRUB's image" "$(cat "$calls")" +pass "a Limine Mac that keeps GRUB keeps its GRUB image current" + +# The firmware ordering is proven in the image the Mac boots, not in /boot's. +limine_fixture +key_before=$(sha256sum <"$root/boot/omarchy/luks-key") +touch "$test_tmp/uki-without-firmware" +if run provision-commit; then fail "a UKI without the firmware ordering fails commit"; fi +error_says "the initramfs inside /boot/efi/EFI/Linux/omarchy_linux-aurora.efi does not load the vendor firmware" +[[ $(sha256sum <"$root/boot/omarchy/luks-key") == "$key_before" ]] || fail "the key stays for the retry" +rm "$test_tmp/uki-without-firmware" +run provision-commit || fail "the retry with a good UKI commits" "$(cat "$test_tmp/err")" +pass "on a Limine Mac the firmware ordering is proven in the UKI that boots" + +# A Limine Mac that keeps GRUB boots /boot's image through GRUB's retained +# entries, so a good UKI does not save a /boot image without the firmware. +limine_fixture +install -Dm755 /dev/null "$root/usr/bin/grub-probe" +install -Dm755 /dev/null "$root/usr/bin/grub-mkconfig" +touch "$test_tmp/grub-without-firmware" +key_before=$(sha256sum <"$root/boot/omarchy/luks-key") +if run provision-commit; then fail "a good UKI does not save a GRUB image without the firmware ordering"; fi +error_says "/boot/initramfs-linux-aurora.img does not load the vendor firmware" +[[ $(sha256sum <"$root/boot/omarchy/luks-key") == "$key_before" ]] || fail "the key stays for the retry" +rm "$test_tmp/grub-without-firmware" +run provision-commit || fail "the retry with both images good commits" "$(cat "$test_tmp/err")" +pass "on a Limine Mac that keeps GRUB, the firmware ordering is proven in /boot's image too" + +# Prepare applies the same rule: a stale /boot image nothing boots does not +# refuse a UKI-only Mac, and a Limine Mac that keeps GRUB is held to both. +limine_fixture +echo ./usr/lib/systemd/system-generators/systemd-cryptsetup-generator >"$root/boot/initramfs-linux-aurora.img" +run provision-prepare || fail "a UKI-only Mac is ready however stale its unbooted GRUB image is" "$(cat "$test_tmp/err")" +install -Dm755 /dev/null "$root/usr/bin/grub-probe" +install -Dm755 /dev/null "$root/usr/bin/grub-mkconfig" +if run provision-prepare; then fail "a Limine Mac that keeps GRUB is held to its GRUB image too"; fi +error_says "before the keyboard firmware loads" +pass "provision-prepare proves the firmware ordering in the images that boot" fixture sed -i '/^recovery_shown=/d' "$root/var/lib/omarchy/provisioning/luks-rekey.state" @@ -349,6 +423,24 @@ for failure in fail-mkinitcpio fail-boot-update build-without-firmware; do done pass "a failed rebuild, or one without the firmware ordering, keeps the unattended unlock for the retry" +# An updater that fails after rewriting the command line, and whose restore +# fails too: the key stays and the boot files carry the restored command line +# for the retry. +limine_fixture +key_before=$(sha256sum <"$root/boot/omarchy/luks-key") +touch "$test_tmp/fail-boot-update-after-write" +if run provision-commit; then fail "commit fails when the updater fails after its rewrite"; fi +[[ $(sha256sum <"$root/boot/omarchy/luks-key") == "$key_before" ]] || fail "the boot-partition key stays" +grep -Fq 'rd.luks.key=' "$root/etc/default/limine" || fail "the restore names the key again" "$(cat "$root/etc/default/limine")" +grep -Fxq 'phase=configured' "$root/boot/omarchy/encrypt.state" || fail "encrypt.state stays configured" +[[ $(tail -n 1 "$calls") == "omarchy-mac-boot-update quiet rd.luks.name=$luks_uuid=root $key_line" ]] || + fail "the restore rewrites the boot files with the key" "$(cat "$calls")" +if run provision-verify; then fail "the staged unlock remains"; fi +rm -f "$test_tmp/fail-boot-update-after-write" +run provision-commit || fail "the retry commits" "$(cat "$test_tmp/err")" +run provision-verify || fail "the retry leaves nothing behind" +pass "an updater that fails after its rewrite still leaves the restored command line" + # An attempt killed after it rewrote GRUB's defaults, then a retry whose # rebuild fails: the key is still on the boot partition, so the command line # names it again. diff --git a/test/integration/provision-owner-luks-test.sh b/test/integration/provision-owner-luks-test.sh index 16d7de6..5578afa 100644 --- a/test/integration/provision-owner-luks-test.sh +++ b/test/integration/provision-owner-luks-test.sh @@ -43,7 +43,10 @@ done chmod 755 "$lifecycle/usr/lib/omarchy/mac-boot"/* chmod -R go-w "$lifecycle" -cat >"$stub_bin/mkinitcpio" <"$root/usr/bin/mkinitcpio" <>"$calls" [[ ! -e $tmp/fail-mkinitcpio ]] || exit 1 @@ -51,6 +54,7 @@ printf '%s\n' ./usr/lib/systemd/system-generators/systemd-cryptsetup-generator \ ./usr/lib/systemd/system/omarchy-vendorfw-initrd.service \\ ./usr/lib/systemd/system/systemd-cryptsetup@.service.d/omarchy-vendorfw-initrd.conf >"$root/boot/initramfs-linux-aurora.img" SH +chmod +x "$root/usr/bin/mkinitcpio" # The image holds no vconsole.conf, like one built for a Mac left on the US map. cat >"$stub_bin/lsinitcpio" <<'SH' #!/bin/bash @@ -255,7 +259,7 @@ fixture() { printf 'root UUID=%s none luks\n' "$luks_uuid" >"$root/etc/crypttab" : >"$root/dev/disk/by-uuid/$luks_uuid" printf 'format=1\nencrypt=1\n' >"$root/var/lib/omarchy/mac-first-boot/install.conf" - "$stub_bin/mkinitcpio" && : >"$calls" + "$root/usr/bin/mkinitcpio" && : >"$calls" printf '0 throwaway-install-key\n' >"$slots" rm -f "$tmp"/fail-* "$tmp/token-slot" "$tmp/token-id" "$screen" "$tmp/gum-stdin" rm -rf "$units"