From 9dd3de50d241e2d35d8f33016402fc7fab2c933b Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 08:16:28 +0530 Subject: [PATCH 01/27] Deliver missing zram repair to previously migrated users --- migrations/1789246530.sh | 28 ++++++++++ test/shell.d/zram-package-test.sh | 89 +++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 migrations/1789246530.sh diff --git a/migrations/1789246530.sh b/migrations/1789246530.sh new file mode 100644 index 00000000000..792a8cc963a --- /dev/null +++ b/migrations/1789246530.sh @@ -0,0 +1,28 @@ +echo "Repair missing zram configuration on previously migrated installs" + +# 1787669934 shipped before the missing-configuration repair. Users with its +# completion marker need a new migration, but must not have configured swap +# reactivated merely because this repair is new. Empty files and links also +# count as deliberate configuration, including administrator masks. +state_dir="${OMARCHY_MIGRATION_STATE:-$HOME/.local/state/omarchy/migrations}" +repair_pending="$state_dir/1789246530.zram-repair-pending" +if [[ ! -f $repair_pending ]]; then + zram_root="${OMARCHY_ZRAM_ROOT:-}" + for directory in /etc /run /usr/local/lib /usr/lib; do + for config in "$zram_root$directory/systemd/zram-generator.conf" \ + "$zram_root$directory/systemd/zram-generator.conf.d/"*.conf; do + if [[ -e $config || -L $config ]]; then + exit 0 + fi + done + done + # A failed activation may already have installed the fallback. Remember that + # this user started the repair so a retry cannot mistake it for a local choice. + mkdir -p "$state_dir" + touch "$repair_pending" +fi + +# Only the unconfigured population needs the existing repair. It verifies the +# required package, preserves active swap, and honours masked or absent units. +bash -euo pipefail "$OMARCHY_PATH/migrations/1787669934.sh" +rm -f "$repair_pending" diff --git a/test/shell.d/zram-package-test.sh b/test/shell.d/zram-package-test.sh index 6cfbd4f04ca..1b9d00206d7 100755 --- a/test/shell.d/zram-package-test.sh +++ b/test/shell.d/zram-package-test.sh @@ -305,3 +305,92 @@ if [[ -x $generator ]]; then else pass "zram-generator is unavailable; skipping real generator integration" fi + +# The repair was added to a migration already shipped in v4.0.2-2. Exercise +# the new delivery marker through the real migrator with that old marker set. +repair_name=1789246530.sh +cp "$ROOT/migrations/$repair_name" "$test_root/migrations/$repair_name" + +prepare_previously_migrated_user() { + local scenario="$1" + retry_home="$test_tmp/previously-migrated-$scenario" + retry_state="$retry_home/.local/state/omarchy/migrations" + retry_marker="$retry_state/$repair_name" + repair_pending="$retry_state/1789246530.zram-repair-pending" + export OMARCHY_ZRAM_ROOT="$test_tmp/previously-migrated-system-$scenario" + mkdir -p "$retry_state" + touch "$retry_state/$migration_name" + rm -f "$swap_active" + : >"$calls" +} + +prepare_previously_migrated_user unconfigured +run_required_package_migration +[[ -f $retry_marker && -f $swap_active ]] || fail "old completion markers do not suppress the new repair" +[[ ! -e $repair_pending ]] || fail "a successful repair clears its in-progress receipt" +cmp "$test_root/default/systemd/zram-generator.conf.d/90-omarchy.conf" \ + "$OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf" || fail "previously migrated users get persistent configuration" +assert_systemd_start_follows_reload +: >"$calls" +run_required_package_migration +[[ ! -s $calls ]] || fail "the new completion marker prevents repeated operations" +pass "previously migrated unconfigured installs receive the repair exactly once" + +# A stopped device with existing configuration is not evidence of this bug. +# Include the vendor default itself, because an administrator may stop swap +# without editing it; neither package nor service operations should run. +for directory in etc run usr/local/lib usr/lib; do + for kind in main drop-in empty mask dangling-mask; do + prepare_previously_migrated_user "configured-$directory-$kind" + config="$OMARCHY_ZRAM_ROOT/$directory/systemd/zram-generator.conf" + [[ $kind == main ]] || config="$config.d/90-omarchy.conf" + mkdir -p "$(dirname "$config")" + case "$kind" in + main | drop-in) cp "$ROOT/default/systemd/zram-generator.conf.d/90-omarchy.conf" "$config" ;; + empty) touch "$config" ;; + mask) ln -s /dev/null "$config" ;; + dangling-mask) ln -s "$test_tmp/does-not-exist" "$config" ;; + esac + run_required_package_migration + [[ -f $retry_marker && ! -e $repair_pending ]] || fail "configured $directory/$kind completes without a repair receipt" + [[ ! -s $calls && ! -e $swap_active ]] || fail "configured $directory/$kind does not reactivate stopped swap" + [[ -e $config || -L $config ]] || fail "configured $directory/$kind is preserved" + done +done +pass "new migration preserves configured and deliberately disabled swap at every level" + +prepare_previously_migrated_user start-failure +if TEST_START_STATUS=1 run_required_package_migration; then + fail "the new migration propagates activation failure" +fi +[[ ! -e $retry_marker && -f $repair_pending ]] || fail "failed activation leaves the new repair pending" +[[ -f $OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf ]] || fail "the failed activation fixture already wrote its configuration" +: >"$calls" +run_required_package_migration +[[ -f $retry_marker && -f $swap_active && ! -e $repair_pending ]] || fail "retry completes the repair despite its newly written configuration" +assert_systemd_start_follows_reload +pass "failed activation retries without mistaking its fallback for prior local configuration" + +prepare_previously_migrated_user package-failure +rm -f "$TEST_REPO_PACKAGE_AVAILABLE" "$TEST_REPO_PACKAGE_INSTALLED" +if run_required_package_migration; then + fail "the new migration cannot complete with an unavailable required package" +fi +[[ ! -e $retry_marker && -f $repair_pending ]] || fail "missing package keeps the new repair pending" +touch "$TEST_REPO_PACKAGE_AVAILABLE" +run_required_package_migration +[[ -f $retry_marker && -f $swap_active && ! -e $repair_pending ]] || fail "new repair recovers when the package becomes available" +pass "new migration keeps required-package failures retryable" + +prepare_previously_migrated_user unit-mask +TEST_LOAD_STATE=masked run_required_package_migration +[[ -f $retry_marker && ! -e $swap_active ]] || fail "masked swap units are not activated" +! grep -q '^systemctl start ' "$calls" || fail "do not start an explicitly masked unit" +pass "new repair respects an administrator's systemd unit mask" + +prepare_previously_migrated_user active +touch "$swap_active" +run_required_package_migration +[[ -f $retry_marker && -f $OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf ]] || fail "active swap gets persistent configuration" +! grep -q '^systemctl start ' "$calls" || fail "do not restart active swap during the new repair" +pass "new repair supplies persistence without restarting active swap" From 1c3dc3feb8c660f689677a66dcb3b6137d9d7999 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 08:21:04 +0530 Subject: [PATCH 02/27] Seed the Hermes launcher during fresh user setup --- install/user/all.sh | 1 + install/user/hermes.sh | 25 ++++++ migrations/1787760281.sh | 24 +---- test/shell.d/hermes-cli-migration-test.sh | 1 + test/shell.d/hermes-cli-test.sh | 8 +- test/shell.d/hermes-user-setup-test.sh | 104 ++++++++++++++++++++++ 6 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 install/user/hermes.sh create mode 100644 test/shell.d/hermes-user-setup-test.sh diff --git a/install/user/all.sh b/install/user/all.sh index 6334ed1bcde..510c4a9077f 100644 --- a/install/user/all.sh +++ b/install/user/all.sh @@ -16,3 +16,4 @@ run_logged "$OMARCHY_INSTALL/user/hardware/apple/mic.sh" run_logged "$OMARCHY_INSTALL/user/default-keyring.sh" run_logged "$OMARCHY_INSTALL/user/mise.sh" +run_logged "$OMARCHY_INSTALL/user/hermes.sh" diff --git a/install/user/hermes.sh b/install/user/hermes.sh new file mode 100644 index 00000000000..d9d9d55d207 --- /dev/null +++ b/install/user/hermes.sh @@ -0,0 +1,25 @@ +# Shared by fresh user setup and the existing-install migration. Provisioning +# marks migrations complete, so the lazy launcher must be seeded here too. +configure_hermes_launcher() { + local wrapper="$HOME/.local/bin/hermes" + + # Removing preinstalls opts out of the lazy wrappers, including Hermes. + [[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && return 0 + + # The app owns Hermes when installed. An unbootstrapped desktop is expected + # for a new user, so its readiness warning must not abort user finalization. + if omarchy-pkg-present hermes-desktop; then + omarchy-install-hermes-cli || true + return 0 + fi + + # Preserve user-owned executables, directories and links without running + # them. Only the installer's ownership predicate permits replacement. + if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then + return 0 + fi + + omarchy-install-hermes-cli +} + +configure_hermes_launcher diff --git a/migrations/1787760281.sh b/migrations/1787760281.sh index 952fa9a2810..074b9464a15 100755 --- a/migrations/1787760281.sh +++ b/migrations/1787760281.sh @@ -1,25 +1,3 @@ echo "Install the Hermes CLI wrapper for existing installs" -# Users who removed the preinstalls opted out of the mise wrappers, and Hermes -# is one of them. -[[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && exit 0 - -# Hermes Desktop provides its own Hermes. The installer stands aside for it, -# removing the mise copy and the Omarchy wrapper an earlier install may have -# left beside the app. It also reports when the app has not finished setting -# Hermes up, which is the app's to finish, not this migration's to fail on. -if omarchy-pkg-present hermes-desktop; then - omarchy-install-hermes-cli || true - exit 0 -fi - -# Anything already answering to hermes that this installer did not write -- -# an official install, a hand-rolled wrapper, even a dangling link -- belongs to -# the user and stays exactly as it is. The installer is asked rather than -# matched against here, so there is one answer to who owns that wrapper. -wrapper="$HOME/.local/bin/hermes" -if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then - exit 0 -fi - -omarchy-install-hermes-cli +source "$OMARCHY_PATH/install/user/hermes.sh" diff --git a/test/shell.d/hermes-cli-migration-test.sh b/test/shell.d/hermes-cli-migration-test.sh index bd18f6fb421..1c9f8341271 100755 --- a/test/shell.d/hermes-cli-migration-test.sh +++ b/test/shell.d/hermes-cli-migration-test.sh @@ -38,6 +38,7 @@ chmod +x "$mock_bin"/* run_migration() { OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \ OMARCHY_TEST_MISE_LOG="$mise_log" \ + OMARCHY_PATH="$ROOT" \ HOME="$test_home" \ PATH="$mock_bin:$ROOT/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null 2>&1 diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh index 1cf033ab20f..2737794d7d1 100755 --- a/test/shell.d/hermes-cli-test.sh +++ b/test/shell.d/hermes-cli-test.sh @@ -284,13 +284,13 @@ tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && fail "an unmarked Hermes mise environment is not given an Omarchy wrapper" pass "a Hermes mise environment needs wrapper ownership before replacement" -# install/user/mise.sh is sourced by install/user/all.sh through run_logged, +# install/user/hermes.sh is sourced by install/user/all.sh through run_logged, # which runs it under `bash -eE` and hands its exit code back to # omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user # -- the default browser, the mailto handler, the first-install migration # markers, the finalize-user marker -- runs after that source, so this leaf -# returning non-zero costs the user all of it. The Hermes installer is the only -# line in it that can fail, and it does exactly that whenever hermes-desktop is +# returning non-zero costs the user all of it. The Hermes installer returns +# non-zero whenever hermes-desktop is # installed but the app has not been launched yet: the case a second user on a # shared machine hits on their first login. mise_sh_home="$test_tmp/mise-sh-home" @@ -315,7 +315,7 @@ OMARCHY_TEST_DESKTOP_INSTALLED=1 \ OMARCHY_TEST_MISE_LOG="$mise_log" \ HOME="$mise_sh_home" \ PATH="$mock_bin:$ROOT/bin:$PATH" \ - bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 || + bash -eE -c 'source "$1"' bash "$ROOT/install/user/hermes.sh" >/dev/null 2>&1 || fail "user setup survives a Hermes install that cannot finish" pass "user setup survives a Hermes install that cannot finish" diff --git a/test/shell.d/hermes-user-setup-test.sh b/test/shell.d/hermes-user-setup-test.sh new file mode 100644 index 00000000000..dfc6b92d0ee --- /dev/null +++ b/test/shell.d/hermes-user-setup-test.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +fixture="$test_tmp/omarchy" +mkdir -p "$fixture/bin" "$fixture/install/user" "$fixture/install/helpers" "$fixture/migrations" "$fixture/default/agents" +cp "$ROOT/install/user/all.sh" "$ROOT/install/user/hermes.sh" "$fixture/install/user/" +cp "$ROOT/migrations/1787760281.sh" "$fixture/migrations/" +ln -s "$ROOT/default/agents/skills" "$fixture/default/agents/skills" +for command in omarchy-install-hermes-cli omarchy-done; do + ln -s "$ROOT/bin/$command" "$fixture/bin/$command" +done + +# Run the real user-stage driver and provisioner. Every unrelated leaf is +# intercepted at run_logged so it cannot reconfigure the active desktop. +cat >"$fixture/install/helpers/logging.sh" <<'SH' +run_logged() { + if [[ $1 == "$OMARCHY_INSTALL/user/hermes.sh" ]]; then + bash -eE -c 'source "$1"' bash "$1" + fi +} +SH +for command in xdg-user-dirs-update xdg-settings xdg-mime omarchy-refresh-applications; do + printf '#!/bin/bash\nexit 0\n' >"$fixture/bin/$command" +done +cat >"$fixture/bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $* == hermes-desktop && ${TEST_HERMES_DESKTOP:-0} == 1 ]] +SH +cat >"$fixture/bin/mise" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_MISE_CALLS" +[[ $1 != where ]] +SH +chmod +x "$fixture/bin/"* + +export TEST_MISE_CALLS="$test_tmp/mise.calls" +marker='# Written by omarchy-install-hermes-cli.' +run_first_install() { + HOME="$task_home" OMARCHY_PATH="$fixture" OMARCHY_INSTALL="$fixture/install" \ + OMARCHY_INSTALL_LOG_FILE="$test_tmp/provision.log" PATH="$fixture/bin:$PATH" \ + bash "$ROOT/bin/omarchy-provision-user" --first-install >"$test_tmp/provision.output" 2>&1 +} +prepare_user() { + task_home="$test_tmp/$1" + wrapper="$task_home/.local/bin/hermes" + mkdir -p "$task_home/.local/bin" "$task_home/.local/state/omarchy" + : >"$TEST_MISE_CALLS" +} +assert_finalized() { + [[ -f $task_home/.local/state/omarchy/migrations/1787760281.sh ]] || fail "first install marks the Hermes migration complete" + [[ -f $task_home/.local/state/omarchy/done/finalize-user ]] || fail "user finalization completes" +} + +prepare_user fresh +run_first_install || fail "fresh user provisioning succeeds" "$(cat "$test_tmp/provision.output")" +assert_finalized +[[ -x $wrapper ]] && grep -qxF "$marker" "$wrapper" || fail "fresh install seeds the Hermes wrapper before migrations are marked" +! grep -Eq '^(use|install|uninstall|rm) ' "$TEST_MISE_CALLS" || fail "fresh seeding remains lazy" +cp "$wrapper" "$test_tmp/expected-wrapper" +run_first_install || fail "repeat first-install setup succeeds" +cmp "$wrapper" "$test_tmp/expected-wrapper" || fail "repeat setup preserves the lazy wrapper" +pass "real first-install provisioning seeds Hermes before marking migrations complete" + +prepare_user opted-out +touch "$task_home/.local/state/omarchy/preinstalls-removed" +run_first_install || fail "preinstall opt-out does not block finalization" +assert_finalized +[[ ! -e $wrapper && ! -s $TEST_MISE_CALLS ]] || fail "fresh setup respects the preinstall opt-out" +pass "user provisioning preserves preinstall opt-out" + +for kind in executable nonexecutable dangling-link directory; do + prepare_user "foreign-$kind" + case "$kind" in + executable | nonexecutable) + printf '#!/bin/bash\ntouch "%s"\n' "$task_home/foreign-ran" >"$wrapper" + [[ $kind != executable ]] || chmod +x "$wrapper" + cp -p "$wrapper" "$task_home/expected" + ;; + dangling-link) ln -s "$task_home/missing" "$wrapper" ;; + directory) mkdir "$wrapper" ;; + esac + run_first_install || fail "foreign $kind does not block finalization" + assert_finalized + [[ ! -e $task_home/foreign-ran && ! -s $TEST_MISE_CALLS ]] || fail "foreign $kind is not executed or managed with mise" + case "$kind" in + executable | nonexecutable) + cmp "$wrapper" "$task_home/expected" || fail "foreign $kind content is preserved" + [[ $(stat -c %a "$wrapper") == "$(stat -c %a "$task_home/expected")" ]] || fail "foreign $kind permissions are preserved" + ;; + dangling-link) [[ -L $wrapper && $(readlink "$wrapper") == "$task_home/missing" ]] || fail "foreign dangling link is preserved" ;; + directory) [[ -d $wrapper ]] || fail "foreign directory is preserved" ;; + esac +done +pass "fresh setup preserves user-owned Hermes paths without executing them" + +prepare_user desktop +TEST_HERMES_DESKTOP=1 run_first_install || fail "unbootstrapped Hermes Desktop does not block user setup" +assert_finalized +[[ ! -e $wrapper ]] || fail "desktop ownership prevents a second Hermes launcher" +pass "first-install finalization completes while Hermes Desktop awaits its own setup" From bfb9c12c45d9e0fe1d8b922d0bcf3e5398464c67 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 08:22:20 +0530 Subject: [PATCH 03/27] Share local Wi-Fi when route lookup fails --- bin/omarchy-network-qr | 2 +- test/shell.d/network-qr-test.sh | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-network-qr b/bin/omarchy-network-qr index 9502c25c29d..b7db92a3943 100755 --- a/bin/omarchy-network-qr +++ b/bin/omarchy-network-qr @@ -22,7 +22,7 @@ if [[ -z $interface ]]; then # menu's visibility gate describe. Fall back to the first connected Wi-Fi # device. nmcli localizes state names, so pin the locale, and the prefix # match accepts states like "connected (externally)". - route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }') + route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }') || route_device="" if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then interface=$route_device else diff --git a/test/shell.d/network-qr-test.sh b/test/shell.d/network-qr-test.sh index 7c64ef28eff..4ac881a0c1d 100644 --- a/test/shell.d/network-qr-test.sh +++ b/test/shell.d/network-qr-test.sh @@ -8,6 +8,13 @@ tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/bin" +cat >"$tmp/bin/ip" <<'EOF' +#!/bin/bash +[[ $* == 'route get 1.1.1.1' ]] || exit 1 +(( ${QR_ROUTE_STATUS:-0} == 0 )) || exit "$QR_ROUTE_STATUS" +echo '1.1.1.1 dev omarchy-test-no-wireless' +EOF + cat >"$tmp/bin/nmcli" <<'EOF' #!/bin/bash if [[ $* == *"DEVICE,TYPE,STATE"* ]]; then @@ -28,13 +35,13 @@ payload=$("$QR_PAYLOAD_FILE" printf '## \n ## \n ##\n' EOF -chmod +x "$tmp/bin/nmcli" "$tmp/bin/qrencode" +chmod +x "$tmp/bin/ip" "$tmp/bin/nmcli" "$tmp/bin/qrencode" run_success_case() { local description=$1 fields=$2 expected_payload=$3 shift 3 local output meta matrix payload arg with_meta=false - local expected_matrix expected_security expected_ssid expected_iface="*" + local expected_matrix expected_security expected_ssid expected_iface="wlan0" for arg in "$@"; do [[ $arg == "--meta" ]] && with_meta=true || expected_iface=$arg @@ -50,8 +57,7 @@ run_success_case() { matrix=$(tail -n +2 <<<"$output") # The meta line leads with the shared interface, security, and SSID. With - # no interface argument the helper detects one from the live host, so that - # field is only pinned when the case pinned it. + # no interface argument the isolated NetworkManager fixture supplies wlan0. expected_security=${expected_payload#WIFI:T:} expected_security=${expected_security%%;*} expected_ssid=$(head -n1 <<<"$fields") @@ -88,6 +94,14 @@ run_success_case \ 'WIFI:T:WPA;S:Cafe Detected;P:secret;;' \ --meta +# A local-only connection can still be shared without a route to the Internet. +# ip returns non-zero for that state; the documented nmcli fallback must run. +QR_ROUTE_STATUS=2 run_success_case \ + "network QR helper shares connected Wi-Fi without an Internet route" \ + $'Local Network\nwpa-psk\nsecret\nno\n' \ + 'WIFI:T:WPA;S:Local Network;P:secret;;' \ + --meta + run_success_case \ "network QR helper supports open networks" \ $'Cafe Open\nnone\n\nno\n' \ From 6b5e311d3c403aeb16a54544c498b705d8eb6f7e Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 08:24:18 +0530 Subject: [PATCH 04/27] Defer reboot prompts during unattended updates --- bin/omarchy-update-restart | 18 +++-- test/shell.d/update-restart-test.sh | 104 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 test/shell.d/update-restart-test.sh diff --git a/bin/omarchy-update-restart b/bin/omarchy-update-restart index 6c0a5587e4c..b5e36ea38b8 100755 --- a/bin/omarchy-update-restart +++ b/bin/omarchy-update-restart @@ -1,6 +1,16 @@ #!/bin/bash # omarchy:summary=Restart after an update when the kernel changed +confirm_reboot() { + if [[ ${OMARCHY_UPDATE_UNATTENDED:-} == 1 ]]; then + omarchy-state set reboot-required + echo "Updates require a reboot. Reboot when convenient; unattended updates do not prompt or reboot." + return 1 + fi + + gum confirm "$1" +} + # Compare kernel version before and after updates. # Mac fork: this fork ships the Apple-Silicon kernel, so kernel-update detection # compares the linux-asahi *package version* (not upstream's `uname -r` vs @@ -10,18 +20,18 @@ kernel_after=$(pacman -Q linux-asahi 2>/dev/null | awk '{print $2}') rm -f /tmp/omarchy-kernel-before if [[ -n $kernel_before && $kernel_before != "$kernel_after" ]]; then - gum confirm "Linux kernel has been updated. Reboot?" && omarchy-state clear re*-required && sudo reboot now + confirm_reboot "Linux kernel has been updated. Reboot?" && omarchy-state clear re*-required && sudo reboot now fi if find /usr/lib/modules -maxdepth 2 -name vmlinuz -newermt "$(uptime -s)" 2>/dev/null | grep -q .; then - gum confirm "Linux kernel has been updated. Reboot?" && omarchy-system-reboot + confirm_reboot "Linux kernel has been updated. Reboot?" && omarchy-system-reboot elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then - gum confirm "Updates require reboot. Ready?" && omarchy-system-reboot + confirm_reboot "Updates require reboot. Ready?" && omarchy-system-reboot fi running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null) if [[ $running_hyprland == *"(deleted)"* ]]; then - gum confirm "Hyprland has been updated. Reboot?" && omarchy-system-reboot + confirm_reboot "Hyprland has been updated. Reboot?" && omarchy-system-reboot fi for file in "$HOME"/.local/state/omarchy/restart-*-required; do diff --git a/test/shell.d/update-restart-test.sh b/test/shell.d/update-restart-test.sh new file mode 100644 index 00000000000..d246b79fff9 --- /dev/null +++ b/test/shell.d/update-restart-test.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" +export TEST_RESTART_CALLS="$test_tmp/calls" +export TEST_KERNEL_BEFORE="$test_tmp/kernel-before" + +# Intercept the legacy /tmp filename before the script can read or remove it. +# Every fixture file, including the kernel-version scratch file, stays on disk. +cat >"$mock_bin/cat" <<'SH' +#!/bin/bash +if [[ $* == /tmp/omarchy-kernel-before ]]; then + exec /usr/bin/cat "$TEST_KERNEL_BEFORE" +fi +exec /usr/bin/cat "$@" +SH +cat >"$mock_bin/rm" <<'SH' +#!/bin/bash +if [[ $* == '-f /tmp/omarchy-kernel-before' ]]; then + exec /usr/bin/rm -f "$TEST_KERNEL_BEFORE" +fi +exec /usr/bin/rm "$@" +SH +cat >"$mock_bin/find" <<'SH' +#!/bin/bash +if [[ $1 == /usr/lib/modules ]]; then + [[ ${TEST_RESTART_REASON:-} != modules ]] || echo '/usr/lib/modules/fixture/vmlinuz' + exit 0 +fi +exec /usr/bin/find "$@" +SH +cat >"$mock_bin/readlink" <<'SH' +#!/bin/bash +[[ $* == /proc/123/exe ]] || exit 1 +if [[ ${TEST_RESTART_REASON:-} == hyprland ]]; then + echo '/usr/bin/Hyprland (deleted)' +else + echo '/usr/bin/Hyprland' +fi +SH +printf '#!/bin/bash\necho 123\n' >"$mock_bin/pgrep" +printf '#!/bin/bash\necho "linux-asahi 2"\n' >"$mock_bin/pacman" +printf '#!/bin/bash\necho "2026-09-13 00:00:00"\n' >"$mock_bin/uptime" +cat >"$mock_bin/gum" <<'SH' +#!/bin/bash +echo "gum $*" >>"$TEST_RESTART_CALLS" +exit "${TEST_CONFIRM_STATUS:-1}" +SH +for command in sudo omarchy-system-reboot omarchy-restart-shell omarchy-restart-audio; do + cat >"$mock_bin/$command" <<'SH' +#!/bin/bash +echo "${0##*/} $*" >>"$TEST_RESTART_CALLS" +SH +done +chmod +x "$mock_bin/"* + +run_restart() { + local mode="$1" reason="$2" + task_home="$test_tmp/$mode-$reason-${TEST_CONFIRM_STATUS:-1}" + state="$task_home/.local/state/omarchy" + mkdir -p "$state" + : >"$TEST_RESTART_CALLS" + : >"$TEST_KERNEL_BEFORE" + case "$reason" in + package) echo 1 >"$TEST_KERNEL_BEFORE" ;; + marker) touch "$state/reboot-required" ;; + esac + touch "$state/restart-audio-required" + HOME="$task_home" PATH="$mock_bin:$ROOT/bin:$PATH" TEST_RESTART_REASON="$reason" \ + OMARCHY_UPDATE_UNATTENDED="$mode" bash "$ROOT/bin/omarchy-update-restart" >"$test_tmp/output" 2>&1 +} + +for reason in package modules marker hyprland; do + run_restart 1 "$reason" + ! grep -Eq '^(gum|sudo|omarchy-system-reboot) ' "$TEST_RESTART_CALLS" || fail "unattended $reason neither prompts nor reboots" "$(cat "$TEST_RESTART_CALLS")" + [[ -f $state/reboot-required ]] || fail "unattended $reason keeps a reboot reminder" + [[ ! -f $state/restart-audio-required ]] || fail "unattended $reason handles service restart markers" + grep -q '^omarchy-restart-audio ' "$TEST_RESTART_CALLS" || fail "unattended $reason still restarts requested services" + grep -q '^omarchy-restart-shell ' "$TEST_RESTART_CALLS" || fail "unattended $reason still restarts the shell" +done +pass "all unattended reboot conditions defer without prompts and preserve the reminder" + +for reason in package modules marker hyprland; do + run_restart 0 "$reason" + grep -q '^gum confirm ' "$TEST_RESTART_CALLS" || fail "interactive $reason still asks before rebooting" + ! grep -Eq '^(sudo|omarchy-system-reboot) ' "$TEST_RESTART_CALLS" || fail "declining $reason does not reboot" +done +pass "interactive reboot prompts remain available and declining never reboots" + +TEST_CONFIRM_STATUS=0 run_restart 0 package +grep -q '^sudo reboot now$' "$TEST_RESTART_CALLS" || fail "confirmed Asahi kernel update preserves the reboot path" +TEST_CONFIRM_STATUS=0 run_restart 0 marker +grep -q '^omarchy-system-reboot ' "$TEST_RESTART_CALLS" || fail "confirmed reboot marker preserves the reboot path" +pass "interactive confirmation still reaches the existing reboot commands" + +run_restart 1 none +[[ ! -f $state/reboot-required ]] || fail "ordinary unattended updates do not invent a reboot requirement" +! grep -Eq '^(gum|sudo|omarchy-system-reboot) ' "$TEST_RESTART_CALLS" || fail "ordinary unattended updates neither prompt nor reboot" +pass "updates without reboot conditions leave no reboot reminder" From ad531edda36c37aefd5fbdb2af4574d3f8a18826 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 09:02:11 +0530 Subject: [PATCH 05/27] Stage isolated ARM channel transactions before selecting a lane --- bin/omarchy-channel-current | 3 + bin/omarchy-channel-set | 15 +- bin/omarchy-refresh-pacman | 6 + bin/omarchy-update | 15 +- bin/omarchy-update-system-pkgs | 5 + bin/omarchy-version-channel | 6 + default/pacman/pacman-rc.conf | 2 +- default/pacman/pacman-stable.conf | 2 +- docs/arm-package-sources.md | 12 ++ install/helpers/arm-channel.sh | 157 ++++++++++++++++ install/helpers/arm-package-sources.sh | 15 +- manual/30-updates.md | 2 + test/shell.d/arm-channel-test.sh | 99 ++++++++++ test/shell.d/arm-channel-transaction-test.sh | 185 +++++++++++++++++++ test/shell.d/channel-test.sh | 4 + test/shell.d/update-sequence-test.sh | 28 ++- 16 files changed, 545 insertions(+), 11 deletions(-) create mode 100644 install/helpers/arm-channel.sh create mode 100644 test/shell.d/arm-channel-test.sh create mode 100644 test/shell.d/arm-channel-transaction-test.sh diff --git a/bin/omarchy-channel-current b/bin/omarchy-channel-current index cc35e13c2ef..5fabce07af4 100755 --- a/bin/omarchy-channel-current +++ b/bin/omarchy-channel-current @@ -23,6 +23,9 @@ elif pacman -Q omarchy omarchy-settings >/dev/null 2>&1; then case "$channel" in stable) echo stable ;; rc) echo rc ;; + edge) + if [[ $(uname -m) == "aarch64" ]]; then echo edge; else echo unknown; fi + ;; *) echo unknown ;; esac else diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 7e112388508..f0305b2a216 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -34,7 +34,8 @@ validate_dev_checkout() { link_dev_checkout() { local checkout="$1" - [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout" + local repository="${2:-https://github.com/basecamp/omarchy.git}" + [[ -d $checkout/.git ]] || git clone "$repository" "$checkout" omarchy-dev-link "$checkout" --no-reboot } @@ -76,6 +77,18 @@ if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then leaving_dev=1 fi +if [[ $(uname -m) == "aarch64" ]]; then + # ARM lanes all ship omarchy + omarchy-settings. The normal update owns the + # lock, snapshot, staged transaction and post-package migrations. A missing + # lane fails before either pacman.conf or a development link is changed. + OMARCHY_UPDATE_CHANNEL="$pacman_channel" omarchy-update -y + if [[ -n $dev_checkout ]]; then + link_dev_checkout "$dev_checkout" https://github.com/omacom/omarchy-mac.git + omarchy-state set reboot-required + fi + exit 0 +fi + if [[ -n $dev_checkout ]]; then link_dev_checkout "$dev_checkout" export OMARCHY_PATH="$dev_checkout" diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 5bf9c80de26..b3522d4ccc7 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -3,6 +3,12 @@ # omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages. # omarchy:requires-sudo=true +if [[ $(uname -m) == "aarch64" ]]; then + # ARM refresh preserves custom repositories and mirror ordering. Explicit + # refresh uses the same validated lane switch as omarchy channel set. + exec env OMARCHY_UPDATE_CHANNEL="${1:-stable}" omarchy-update -y +fi + sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak if [[ -f /etc/pacman.d/mirrorlist.asahi-alarm ]]; then diff --git a/bin/omarchy-update b/bin/omarchy-update index e71e808664e..14b044656cb 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -38,13 +38,24 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start - omarchy-update-dev - omarchy-update-keyring + if [[ -z ${OMARCHY_UPDATE_CHANNEL:-} ]]; then + omarchy-update-dev + omarchy-update-keyring + fi # Migrations ship with the packages installed here and are written against # them, so everything below waits on this finishing. An upgrade that stopped # takes the update with it rather than migrating against what is still on disk. omarchy-update-system-pkgs + if [[ -n ${OMARCHY_UPDATE_CHANNEL:-} ]]; then + # The channel transaction installed the package-backed migration set. + # Do not run migrations from the checkout we may be leaving behind. + omarchy-dev-unlink --no-reboot + [[ $OMARCHY_PATH == "/usr/share/omarchy" ]] || omarchy-state set reboot-required + export OMARCHY_PATH=/usr/share/omarchy + export PATH="$OMARCHY_PATH/bin:$PATH" + unset OMARCHY_UPDATE_CHANNEL + fi omarchy-migrate omarchy-hook post-update omarchy-update-aur-pkgs diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index ac85279de52..d36e8685ad6 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -8,6 +8,11 @@ set -e targets=() if [[ $(uname -m) == "aarch64" ]]; then source "$OMARCHY_PATH/install/helpers/arm-package-sources.sh" + if [[ -n ${OMARCHY_UPDATE_CHANNEL:-} ]]; then + source "$OMARCHY_PATH/install/helpers/arm-channel.sh" + omarchy_arm_channel_apply "$OMARCHY_UPDATE_CHANNEL" + exit $? + fi omarchy_arm_prepare_package_sources mapfile -t targets < <(omarchy_arm_package_upgrade_args) fi diff --git a/bin/omarchy-version-channel b/bin/omarchy-version-channel index dcd3dd619d3..325fe6cc072 100755 --- a/bin/omarchy-version-channel +++ b/bin/omarchy-version-channel @@ -2,6 +2,12 @@ # omarchy:summary=Print the active Omarchy mirror and package channel +if [[ $(uname -m) == "aarch64" ]]; then + source "$OMARCHY_PATH/install/helpers/arm-channel.sh" + omarchy_arm_channel_current "${OMARCHY_PACMAN_CONFIG:-/etc/pacman.conf}" || echo unknown + exit 0 +fi + if grep -q "https://stable-mirror.omarchy.org/" /etc/pacman.d/mirrorlist; then mirror="stable" elif grep -q "https://rc-mirror.omarchy.org/" /etc/pacman.d/mirrorlist; then diff --git a/default/pacman/pacman-rc.conf b/default/pacman/pacman-rc.conf index b869880c19b..7d4c3474716 100644 --- a/default/pacman/pacman-rc.conf +++ b/default/pacman/pacman-rc.conf @@ -27,7 +27,7 @@ Server = https://pkgs.omarchy.org/edge/$arch [omarchy-aarch64] SigLevel = Optional TrustAll -Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/edge +Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/rc [asahi-alarm] Include = /etc/pacman.d/mirrorlist.asahi-alarm diff --git a/default/pacman/pacman-stable.conf b/default/pacman/pacman-stable.conf index 87aa84081b4..1f087ddde8b 100644 --- a/default/pacman/pacman-stable.conf +++ b/default/pacman/pacman-stable.conf @@ -82,7 +82,7 @@ Server = https://pkgs.omarchy.org/edge/$arch [omarchy-aarch64] SigLevel = Optional TrustAll -Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/edge +Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/stable [asahi-alarm] Include = /etc/pacman.d/mirrorlist.asahi-alarm diff --git a/docs/arm-package-sources.md b/docs/arm-package-sources.md index 0d92d4514ce..74e4b334669 100644 --- a/docs/arm-package-sources.md +++ b/docs/arm-package-sources.md @@ -8,6 +8,18 @@ The shared policy lives in `install/helpers/arm-package-sources.sh`. Package sig Use `omarchy update` for system upgrades. A bare `pacman -Syu` does not update the explicitly selected edge packages and can fail when their regular-repository dependencies change ABI. Edge is rolling; versions are resolved together at transaction time rather than pinned. +## ARM package channels + +The fork-owned repository uses distinct `stable`, `rc`, and `edge` release coordinates under `https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/`. All three lanes provide `omarchy` and `omarchy-settings`; ARM does not request the x86 `omarchy-dev` pair. Channel reporting reads the managed ARM server, so an older installation pointing at `/edge` reports edge even when its installed package names are `omarchy` and `omarchy-settings`. + +An explicit channel switch goes through the normal update lock, snapshot and migration pipeline. It stages the current pacman configuration, changing only the managed ARM lane and reapplying the existing explicit upstream graphics policy. Other repository ordering, options and mirror Includes are preserved. Custom or ambiguous ARM server/Include layouts are rejected rather than guessed. ARM refresh uses this same path and no longer runs the reset-only `pre-refresh-pacman` hook, because it does not discard and recreate the user's configuration. The x86 reset path retains that hook; normal update hooks still run after successful migrations. + +Before changing installed packages, the switch syncs isolated databases, verifies a matching desktop package pair, resolves the full transaction and downloads its archives under the configured signature policy. Required trust that is absent fails preparation; no unknown signing key is imported. It checks the resolved archive hashes and captures the repository databases and archives as local repositories. A second resolution against the real installed database must match the preflight manifest. One ordinary libalpm system-upgrade transaction then uses those captured repositories, preserving dependency reasons, replacement handling and conflict checks. Explicit version-constrained desktop targets allow RC-to-stable downgrades without enabling distribution-wide downgrades. Equal/older lane database timestamps are handled with a forced sync of the captured database. + +The persistent configuration is committed only after successful package installation and only if it has not changed independently. Transaction failures report the installed pair rather than claiming rollback: package hooks may have run before an error. A later migration failure keeps the ordinary update unsuccessful. No migration moves existing `/edge` users to a lane that may not yet be published. + +This freezes one switch transaction, not future distribution upgrades. Arch Linux ARM, Asahi and the explicitly selected upstream graphics stack still resolve according to their rolling policies on the next update. Record their resolved versions when qualifying an RC; a different resolved stack needs new compatibility evidence. Temporary repositories require a disk-backed `TMPDIR` (or the default `/var/tmp`) with sufficient free space, and are removed after the transaction. Existing package caches are reused without deleting their archives. + ## Recovering an install that predates this policy The policy travels inside the `omarchy` package, and both places that apply it — the installer and the update commands — are out of reach on a machine installed before it. The installer is over, and the update aborts in dependency resolution before the package carrying the helper can be replaced, so the machine cannot upgrade its way to the fix. Such a machine reports: diff --git a/install/helpers/arm-channel.sh b/install/helpers/arm-channel.sh new file mode 100644 index 00000000000..dcaeaf51387 --- /dev/null +++ b/install/helpers/arm-channel.sh @@ -0,0 +1,157 @@ +#!/bin/bash + +# ARM package lanes use the same stable package names. A lane is reported only +# for a single, directly configured managed server; custom repositories are not +# guessed from unrelated upstream mirrors or installed package names. +omarchy_arm_channel_current() { + local config="${1:-/etc/pacman.conf}" + awk ' + /^[[:space:]]*\[/ { selected = ($0 ~ /^[[:space:]]*\[omarchy-aarch64\][[:space:]]*(#.*)?$/); sections += selected } + selected && /^[[:space:]]*Include[[:space:]]*=/ { invalid = 1 } + selected && /^[[:space:]]*Server[[:space:]]*=/ { + servers++ + if ($0 !~ /^[[:space:]]*Server[[:space:]]*=[[:space:]]*https:\/\/github[.]com\/omarchy-mac\/omarchy-pkgs-aarch64\/releases\/download\/(stable|rc|edge)\/?[[:space:]]*(#.*)?$/) invalid = 1 + value = $0 + sub(/^.*\/download\//, "", value) + sub(/[\/[:space:]#].*$/, "", value) + } + END { if (sections == 1 && servers == 1 && !invalid) print value; else exit 1 } + ' "$config" +} + +omarchy_arm_channel_render() { + local config="$1" channel="$2" output="$3" + case "$channel" in stable | rc | edge) ;; *) echo "Invalid ARM package channel: $channel" >&2; return 1 ;; esac + if ! omarchy_arm_channel_current "$config" >/dev/null; then + echo "Cannot switch a custom or ambiguous ARM repository. Keep the current configuration and configure its lane explicitly." >&2 + return 1 + fi + awk -v channel="$channel" ' + /^[[:space:]]*\[/ { selected = ($0 ~ /^[[:space:]]*\[omarchy-aarch64\][[:space:]]*(#.*)?$/) } + selected && /^[[:space:]]*Server[[:space:]]*=/ { sub(/\/download\/(stable|rc|edge)/, "/download/" channel) } + { print } + ' "$config" >"$output" +} + +# Called inside omarchy-update's lock/snapshot boundary. The installing +# transaction keeps libalpm's sysupgrade/replacement/reason semantics, but uses +# captured repository databases and verified archives instead of mutable feeds. +omarchy_arm_channel_apply() ( + set -euo pipefail + local channel="$1" config="${OMARCHY_PACMAN_CONFIG:-/etc/pacman.conf}" + local scratch="${TMPDIR:-/var/tmp}" stage dbpath repo name version filename hash size extra pair_version="" + local required available archive cache + local -a targets caches + case $(findmnt -n -o FSTYPE -T "$scratch") in + "" | tmpfs | ramfs) echo "ARM channel staging needs a disk-backed temporary directory." >&2; return 1 ;; + esac + stage=$(mktemp -d "$scratch/omarchy-channel.XXXXXXXX") + trap 'sudo rm -rf -- "$stage"' EXIT + # The pacman downloader runs as DownloadUser and must read local repo files. + chmod 755 "$stage" + mkdir -m 755 "$stage/db" "$stage/cache" "$stage/repos" + cp "$config" "$stage/original.conf" + omarchy_arm_channel_render "$config" "$channel" "$stage/lane.conf" + omarchy_arm_render_package_sources "$stage/lane.conf" >"$stage/source.conf" + pacman-conf --config "$stage/source.conf" >"$stage/resolved.conf" + dbpath=$(pacman-conf --config "$stage/source.conf" DBPath) + sudo cp -a "$dbpath/local" "$stage/db/local" + + local -a probe=(--config "$stage/resolved.conf" --dbpath "$stage/db" --cachedir "$stage/cache" --logfile "$stage/preflight.log") + sudo env OMARCHY_UPDATE_PACMAN=1 pacman "${probe[@]}" -Sy --noconfirm + sudo pacman "${probe[@]}" -Sl omarchy-aarch64 >"$stage/lane-packages" + for name in omarchy omarchy-settings; do + version=$(awk -v name="$name" '$1 == "omarchy-aarch64" && $2 == name { print $3 }' "$stage/lane-packages") + if [[ -z $version || $version == *$'\n'* || ( -n $pair_version && $version != "$pair_version" ) ]]; then + echo "The $channel lane does not provide one matching omarchy/omarchy-settings package pair. Current configuration is unchanged." >&2 + return 1 + fi + pair_version=$version + targets+=("omarchy-aarch64/$name=$version") + done + targets+=(--ignore omarchy,omarchy-settings) + while read -r name; do targets+=("$name"); done < <(omarchy_arm_package_upgrade_args) + + local format='%r %n %v %f %h %s' + sudo pacman "${probe[@]}" -Sup --needed --noconfirm --ask 4 --print-format "$format" "${targets[@]}" >"$stage/expected" + required=$(awk '{ if ($6 !~ /^[0-9]+$/) exit 1; total += $6 } END { printf "%.0f", total + 104857600 }' "$stage/expected") + available=$(df -B1 --output=avail "$stage" | tail -1 | tr -d '[:space:]') + if [[ ! $available =~ ^[0-9]+$ ]] || (( available < required )); then + echo "Insufficient disk space for channel archives ($required bytes required)." >&2 + return 1 + fi + # Download-only verifies the configured signature policy without installing + # a keyring or changing any installed package. Missing trust fails here. + sudo env OMARCHY_UPDATE_PACMAN=1 pacman "${probe[@]}" -Suw --needed --noconfirm --ask 4 "${targets[@]}" + caches=("$stage/cache") + while read -r cache; do caches+=("$cache"); done < <(pacman-conf --config "$stage/resolved.conf" CacheDir) + + pacman-conf --config "$stage/resolved.conf" --repo-list >"$stage/repositories" + while read -r repo; do + [[ $repo =~ ^[[:alnum:]_.-]+$ ]] || { echo "Invalid repository name: $repo" >&2; return 1; } + mkdir -m 755 "$stage/repos/$repo" + sudo cp "$stage/db/sync/$repo.db" "$stage/repos/$repo/$repo.db" + if [[ -f $stage/db/sync/$repo.db.sig ]]; then + sudo cp "$stage/db/sync/$repo.db.sig" "$stage/repos/$repo/$repo.db.sig" + fi + done <"$stage/repositories" + while read -r repo name version filename hash size extra; do + [[ -n $repo ]] || continue + if [[ -n $extra || ! $filename =~ ^[[:alnum:]_.+:-]+$ || ! $hash =~ ^[[:xdigit:]]{64}$ || ! $size =~ ^[0-9]+$ || ! -d $stage/repos/$repo ]]; then + echo "Invalid package manifest entry: $name" >&2 + return 1 + fi + archive="" + for cache in "${caches[@]}"; do + if [[ -f $cache/$filename ]]; then archive="$cache/$filename"; break; fi + done + [[ -n $archive ]] || { echo "Downloaded archive is missing: $filename" >&2; return 1; } + printf '%s %s\n' "$hash" "$archive" | sha256sum -c - + if [[ -f $archive.sig ]]; then + sudo cp "$archive.sig" "$stage/repos/$repo/$filename.sig" + fi + if [[ $archive == "$stage/cache/$filename" ]]; then + sudo mv "$archive" "$stage/repos/$repo/$filename" + else + sudo cp "$archive" "$stage/repos/$repo/$filename" + fi + done <"$stage/expected" + + # Flattened options retain the real root/db/keyring, Includes have already + # been resolved, and every repository now has exactly one local server. + # A custom transfer command must not turn file:// back into a network fetch. + awk -v base="$stage/repos" ' + /^[[:space:]]*(Server|CacheServer|XferCommand)[[:space:]]*=/ { next } + /^\[/ { + print + if ($0 != "[options]") { repo = $0; gsub(/^\[|\]$/, "", repo); print "Server = file://" base "/" repo } + next + } + { print } + ' "$stage/resolved.conf" >"$stage/frozen.conf" + if ! cmp -s "$config" "$stage/original.conf"; then + echo "pacman.conf changed during channel preparation. Preserving it; retry after reviewing the change." >&2 + return 1 + fi + # A different lane may have an equal or older database timestamp. Force the + # captured database into the real sync cache before comparing transactions. + sudo env OMARCHY_UPDATE_PACMAN=1 pacman --config "$stage/frozen.conf" -Syy --noconfirm + sudo pacman --config "$stage/frozen.conf" -Sup --needed --noconfirm --ask 4 --print-format "$format" "${targets[@]}" >"$stage/actual" + if ! diff -u "$stage/expected" "$stage/actual"; then + echo "Installed package state changed during channel preparation. Retry; the active configuration is unchanged." >&2 + return 1 + fi + if ! sudo env OMARCHY_UPDATE_PACMAN=1 pacman --config "$stage/frozen.conf" -Syu --needed --noconfirm --ask 4 "${targets[@]}"; then + echo "Channel transaction failed; no new channel configuration was committed. Package hooks may have run; installed pair:" >&2 + pacman -Q omarchy omarchy-settings >&2 || true + return 1 + fi + if ! cmp -s "$config" "$stage/original.conf"; then + echo "Packages were installed, but pacman.conf changed during the transaction. Preserving it; inspect the configuration before retrying the channel switch." >&2 + return 1 + fi + sudo cp -p "$config" "$config.bak" + sudo install -m 644 "$stage/source.conf" "$config" + echo "ARM package channel is now $channel ($pair_version)." + echo "The selected upstream graphics stack and distribution dependencies were resolved at transaction time." +) diff --git a/install/helpers/arm-package-sources.sh b/install/helpers/arm-package-sources.sh index ec6a9fb911c..d906a58337e 100644 --- a/install/helpers/arm-package-sources.sh +++ b/install/helpers/arm-package-sources.sh @@ -31,16 +31,21 @@ omarchy_arm_package_repo() { printf '%s\n' '[omarchy]' 'Usage = Sync' 'SigLevel = Required DatabaseOptional' 'Server = https://pkgs.omarchy.org/edge/$arch' } +omarchy_arm_render_package_sources() { + local config="$1" + awk ' + /^[[:space:]]*\[/ { omit = ($0 ~ /^[[:space:]]*\[omarchy\][[:space:]]*(#.*)?$/) } + !omit { print } + ' "$config" || return + omarchy_arm_package_repo +} + omarchy_arm_prepare_package_sources() { local config="${1:-/etc/pacman.conf}" backup="${2:-backup}" updated key="40DFB630FF42BCFFB047046CF0134EE680CAC571" updated=$(mktemp) || return # Replace an existing unrestricted Omarchy section without changing the # user's regular repositories, mirror choices, or their ordering. - awk ' - /^[[:space:]]*\[/ { omit = ($0 ~ /^[[:space:]]*\[omarchy\][[:space:]]*(#.*)?$/) } - !omit { print } - ' "$config" > "$updated" || { rm -f "$updated"; return 1; } - omarchy_arm_package_repo >> "$updated" || { rm -f "$updated"; return 1; } + omarchy_arm_render_package_sources "$config" > "$updated" || { rm -f "$updated"; return 1; } if ! cmp -s "$config" "$updated"; then if [[ $backup != "preserve-backup" ]]; then sudo cp "$config" "$config.bak" || { rm -f "$updated"; return 1; } diff --git a/manual/30-updates.md b/manual/30-updates.md index 246138c386a..31152842249 100644 --- a/manual/30-updates.md +++ b/manual/30-updates.md @@ -20,6 +20,8 @@ Finally, there's the dev channel, which links Omarchy directly to a git checkout You can switch between channels using _Update > Channel_ from the Omarchy menu (or `omarchy-channel-set` in the terminal). +On Apple Silicon, stable, RC and edge select separate Omarchy Mac package feeds. A switch checks that the requested feed and both desktop packages are available before changing your configuration. Existing installations keep their current feed until you explicitly switch; an older install may report edge even though its package names have no `-dev` suffix. Arch Linux ARM, Asahi and the selected graphics packages follow their own update schedules, so these channels do not provide the delayed x86 Arch mirror described above. + ### Firmware updates Your packages aren't the only thing that goes stale. Many laptops and peripherals ship BIOS, SSD, and dock firmware through the Linux Vendor Firmware Service, and _Update > Firmware_ in the Omarchy menu will fetch and install whatever your hardware has waiting. It installs `fwupd` the first time you run it. Plenty of firmware can only be written during a reboot, so don't be surprised to be asked for one. diff --git a/test/shell.d/arm-channel-test.sh b/test/shell.d/arm-channel-test.sh new file mode 100644 index 00000000000..bbbebee0a45 --- /dev/null +++ b/test/shell.d/arm-channel-test.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" +source "$ROOT/install/helpers/arm-channel.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" "$test_tmp/home" +config="$test_tmp/pacman.conf" +cat >"$config" <<'CONF' +[options] +Architecture = aarch64 +IgnorePkg = locally-pinned +[private-first] +Server = https://private.example/$arch +[omarchy-aarch64] +SigLevel = Optional TrustAll +Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/edge +[core] +Include = /etc/pacman.d/my-custom-mirrorlist +[private-last] +Server = https://last.example/$arch +CONF +cp "$config" "$test_tmp/original" +for lane in stable rc edge; do + omarchy_arm_channel_render "$config" "$lane" "$test_tmp/staged" + [[ $(omarchy_arm_channel_current "$test_tmp/staged") == "$lane" ]] || fail "detect rendered $lane" + sed "s|/download/edge|/download/$lane|" "$test_tmp/original" >"$test_tmp/expected" + cmp "$test_tmp/staged" "$test_tmp/expected" || fail "$lane preserves all custom sections, options and mirror Includes in place" + [[ $(omarchy_arm_channel_current "$ROOT/default/pacman/pacman-$lane.conf") == "$lane" ]] || fail "$lane template selects its own ARM feed" +done +cmp "$config" "$test_tmp/original" || fail 'staging never modifies the active config' +pass 'ARM lanes are distinct and staging preserves custom repository order and mirrors' + +for layout in custom-server include duplicate missing; do + cp "$test_tmp/original" "$test_tmp/custom" + case "$layout" in + custom-server) sed -i 's|https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/edge|https://custom.example/edge|' "$test_tmp/custom" ;; + include) sed -i '/^\[omarchy-aarch64\]/a Include = /etc/pacman.d/custom-lane' "$test_tmp/custom" ;; + duplicate) printf '\n[omarchy-aarch64]\nServer = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/rc\n' >>"$test_tmp/custom" ;; + missing) sed -i 's/\[omarchy-aarch64\]/[user-repository]/' "$test_tmp/custom" ;; + esac + cp "$test_tmp/custom" "$test_tmp/custom-before" + if omarchy_arm_channel_render "$test_tmp/custom" rc "$test_tmp/rejected" >/dev/null 2>&1; then + fail "$layout must not be guessed or overwritten" + fi + cmp "$test_tmp/custom" "$test_tmp/custom-before" || fail "$layout config is preserved" +done +pass 'custom and ambiguous ARM repository layouts fail without modification' + +printf '#!/bin/bash\necho aarch64\n' >"$test_tmp/bin/uname" +cat >"$test_tmp/bin/omarchy-update" <<'SH' +#!/bin/bash +echo "update $* lane=${OMARCHY_UPDATE_CHANNEL:-}" >>"$TEST_CHANNEL_CALLS" +exit "${TEST_CHANNEL_FAILURE:-0}" +SH +cat >"$test_tmp/bin/pacman" <<'SH' +#!/bin/bash +[[ $* == '-Q omarchy omarchy-settings' ]] +SH +for command in omarchy-refresh-pacman sudo omarchy-dev-unlink omarchy-state omarchy-dev-link; do + printf '#!/bin/bash\necho "%s $*" >>"$TEST_CHANNEL_CALLS"\n' "$command" >"$test_tmp/bin/$command" +done +printf '#!/bin/bash\nexit 0\n' >"$test_tmp/bin/gum" +cat >"$test_tmp/bin/git" <<'SH' +#!/bin/bash +echo "git $*" >>"$TEST_CHANNEL_CALLS" +mkdir -p "${@: -1}/.git" "${@: -1}/bin" "${@: -1}/default" "${@: -1}/shell" +SH +chmod +x "$test_tmp/bin/"* +export TEST_CHANNEL_CALLS="$test_tmp/calls" +for lane in stable rc edge; do + : >"$TEST_CHANNEL_CALLS" + HOME="$test_tmp/home" OMARCHY_PATH=/usr/share/omarchy PATH="$test_tmp/bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-channel-set" "$lane" + [[ $(cat "$TEST_CHANNEL_CALLS") == "update -y lane=$lane" ]] || fail "$lane must invoke one update pipeline without separate package or refresh transactions" +done +pass 'ARM channel selection delegates exactly once to the normal update pipeline' + +HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" OMARCHY_PACMAN_CONFIG="$config" PATH="$test_tmp/bin:$PATH" \ + bash "$ROOT/bin/omarchy-version-channel" >"$test_tmp/version" +[[ $(cat "$test_tmp/version") == edge ]] || fail 'legacy edge feed reports edge despite stable-named packages' +printf '#!/bin/bash\necho edge\n' >"$test_tmp/bin/omarchy-version-channel" +chmod +x "$test_tmp/bin/omarchy-version-channel" +[[ $(OMARCHY_PATH=/usr/share/omarchy PATH="$test_tmp/bin:$PATH" bash "$ROOT/bin/omarchy-channel-current") == edge ]] || fail 'stable-named ARM pair can report the actual edge lane' +pass 'ARM channel reporting follows the configured feed rather than x86 package names' + +: >"$TEST_CHANNEL_CALLS" +if HOME="$test_tmp/home" TEST_CHANNEL_FAILURE=1 OMARCHY_PATH=/usr/share/omarchy PATH="$test_tmp/bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-channel-set" dev >/dev/null 2>&1; then + fail 'failed ARM channel update cannot report successful dev selection' +fi +[[ $(cat "$TEST_CHANNEL_CALLS") == 'update -y lane=edge' ]] || fail 'failed lane cannot clone or link a dev checkout' +: >"$TEST_CHANNEL_CALLS" +HOME="$test_tmp/home" OMARCHY_PATH=/usr/share/omarchy PATH="$test_tmp/bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-channel-set" dev >/dev/null +[[ $(head -1 "$TEST_CHANNEL_CALLS") == 'update -y lane=edge' ]] || fail 'successful package transaction precedes dev linkage' +grep -q '^git clone https://github.com/omacom/omarchy-mac.git ' "$TEST_CHANNEL_CALLS" || fail 'ARM dev uses the Mac source repository' +pass 'ARM dev linkage occurs only after successful package update' diff --git a/test/shell.d/arm-channel-transaction-test.sh b/test/shell.d/arm-channel-transaction-test.sh new file mode 100644 index 00000000000..2d457ac1aca --- /dev/null +++ b/test/shell.d/arm-channel-transaction-test.sh @@ -0,0 +1,185 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +# The native transaction cases belong in a contained uid-0 test process. All +# packages, repositories and the pacman root are synthetic and disk-backed. +if (( EUID != 0 )) || ! command -v repo-add >/dev/null; then + pass 'native ARM channel transactions require the contained root test runner' + exit 0 +fi +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +export CHANNEL_TEST_ROOT="$ROOT" CHANNEL_TEST_STORAGE="$test_tmp" +python3 - <<'PY' +import io, os, pathlib, subprocess, tarfile + +root = pathlib.Path(os.environ['CHANNEL_TEST_ROOT']) +work = pathlib.Path(os.environ['CHANNEL_TEST_STORAGE']) +lanes = work / 'lanes' +guest = work / 'guest' +for d in ['db/local', 'cache', 'etc', 'hooks', 'log']: + (guest / d).mkdir(parents=True) +(guest / 'db/local/ALPM_DB_VERSION').write_text('9\n') +for d in ['stable', 'rc', 'edge', 'regular', 'graphics', 'baseline']: + (lanes / d).mkdir(parents=True) + +def run(argv, **kw): + p = subprocess.run(argv, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kw) + if p.returncode: + raise AssertionError(f'{argv}: exit {p.returncode}\n{p.stdout}') + return ''.join(line for line in p.stdout.splitlines(True) if not line.startswith('warning:')) + +def pkg(lane, name, version, *metadata): + content = work / f'build-{lane}-{name}' + content.mkdir() + data = f'pkgname = {name}\npkgver = {version}\npkgdesc = channel fixture\narch = aarch64\nbuilddate = 1\nsize = 1\n' + (content / '.PKGINFO').write_text(data + ''.join(f'{x}\n' for x in metadata)) + (content / f'{name}.txt').write_text(f'{name} {version}\n') + archive = lanes / lane / f'{name}-{version}-aarch64.pkg.tar.zst' + run(['bsdtar', '--zstd', '-cf', str(archive), '-C', str(content), '.PKGINFO', f'{name}.txt']) + return archive + +base = [] +for name in ['omarchy', 'omarchy-settings']: + base.append(pkg('baseline', name, '4.0.2-2')) +for name in ['hyprland', 'hyprtoolkit', 'hyprland-guiutils', 'aquamarine', 'ordinary', 'old-widget']: + base.append(pkg('baseline', name, '1-1')) +for lane, version in [('stable', '4.0.2-2'), ('rc', '4.0.3rc1-1'), ('edge', '4.0.3rc1-1')]: + pkg(lane, 'omarchy', version, 'depend = newlib') + pkg(lane, 'omarchy-settings', version) + run(['repo-add', str(lanes / lane / 'omarchy-aarch64.db.tar.gz'), *map(str, (lanes / lane).glob('*.pkg.tar.zst'))]) +for name in ['hyprland', 'hyprtoolkit', 'hyprland-guiutils']: + pkg('graphics', name, '2-1', 'depend = aquamarine=2-1') +run(['repo-add', str(lanes / 'graphics/omarchy.db.tar.gz'), *map(str, (lanes / 'graphics').glob('*.pkg.tar.zst'))]) +for name in ['aquamarine', 'ordinary', 'newlib']: + pkg('regular', name, '2-1') +pkg('regular', 'new-widget', '2-1', 'replaces = old-widget', 'conflict = old-widget') +run(['repo-add', str(lanes / 'regular/extra.db.tar.gz'), *map(str, (lanes / 'regular').glob('*.pkg.tar.zst'))]) + +transport = work / 'transport.py' +transport.write_text('''import os, pathlib, shutil, sys +base = pathlib.Path(sys.argv[1]); url, dest = sys.argv[2:] +if '/releases/download/' in url: + lane, name = url.split('/releases/download/', 1)[1].split('/', 1) +elif 'pkgs.omarchy.org' in url: + lane, name = 'graphics', url.rsplit('/', 1)[1] +else: + lane, name = 'regular', url.rsplit('/', 1)[1] +source = base / lane / name +if not source.is_file(): sys.exit(1) +shutil.copyfile(source, dest) +if os.environ.get('CHANNEL_MUTATE_SOURCE') == '1' and lane == 'rc' and name.startswith('omarchy-4.'): + source.write_bytes(source.read_bytes() + b'changed-after-download') + (base / 'rc/omarchy-aarch64.db').write_bytes((base / 'stable/omarchy-aarch64.db').read_bytes()) +''') +config = guest / 'etc/pacman.conf' +config.write_text(f'''[options] +RootDir = {guest} +DBPath = {guest}/db +CacheDir = {guest}/cache +LogFile = {guest}/log/pacman.log +HookDir = {guest}/hooks +Architecture = aarch64 +SigLevel = Never +LocalFileSigLevel = Never +XferCommand = /usr/bin/python3 {transport} {lanes} %u %o +[extra] +Server = https://regular.invalid/$repo/$arch +[omarchy-aarch64] +SigLevel = Never +Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/edge +''') +pacman = ['pacman', '--config', str(config)] +run([*pacman, '-U', '--noconfirm', *map(str, base)]) +run([*pacman, '-D', '--asdeps', 'aquamarine']) +original = config.read_bytes() +stub = work / 'bin'; stub.mkdir() +(stub / 'sudo').write_text('#!/bin/bash\nexec "$@"\n') +(stub / 'sudo').chmod(0o755) +env = dict(os.environ, PATH=f'{stub}:' + os.environ['PATH'], OMARCHY_PACMAN_CONFIG=str(config)) +# These native fixtures test resolver/transaction behavior with unsigned local +# packages. Production's required graphics signatures remain unchanged. +script = '''source "$2/install/helpers/arm-package-sources.sh" +omarchy_arm_package_repo() { + printf '%s\\n' '[omarchy]' 'Usage = Sync' 'SigLevel = Never' 'Server = https://pkgs.omarchy.org/edge/$arch' +} +source "$2/install/helpers/arm-channel.sh" +omarchy_arm_channel_apply "$1" +''' +def channel(lane, success=True): + p = subprocess.run(['bash', '-euo', 'pipefail', '-c', script, 'bash', lane, str(root)], env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (work / f'{lane}-{success}.log').write_text(p.stdout) + if (p.returncode == 0) != success: + raise AssertionError(f'channel {lane}: {p.returncode}\n{p.stdout}') + return p.stdout + +# A missing lane must fail before modifying config or installed packages. +db = lanes / 'rc/omarchy-aarch64.db' +saved = db.read_bytes(); db.unlink() +before = run([*pacman, '-Q']) +channel('rc', False) +assert config.read_bytes() == original and run([*pacman, '-Q']) == before +db.write_bytes(saved) +print('ok - absent lane leaves active config and installed packages untouched') + +# Metadata for only one of the pair is not a usable lane. +buffer = io.BytesIO() +with tarfile.open(fileobj=io.BytesIO(saved)) as old, tarfile.open(fileobj=buffer, mode='w:gz') as new: + for item in old.getmembers(): + if item.name.startswith('omarchy-settings-'): continue + new.addfile(item, old.extractfile(item) if item.isfile() else None) +db.write_bytes(buffer.getvalue()) +channel('rc', False) +assert config.read_bytes() == original and run([*pacman, '-Q']) == before +db.write_bytes(saved) +print('ok - a lane missing one desktop package cannot change installed state or config') + +archive = next((lanes / 'rc').glob('omarchy-4.*.pkg.tar.zst')) +archive_bytes = archive.read_bytes() +archive.unlink() +channel('rc', False) +assert config.read_bytes() == original and run([*pacman, '-Q']) == before +archive.write_bytes(archive_bytes + b'corrupt-archive') +channel('rc', False) +assert config.read_bytes() == original and run([*pacman, '-Q']) == before +archive.write_bytes(archive_bytes) +print('ok - missing or hash-mismatched archives fail before any package is installed') + +# libalpm detects an unowned-file collision at transaction commit, after the +# resolver/download preflight. Neither member of the pair may be installed. +collision = guest / 'newlib.txt' +collision.write_text('administrator file\n') +failure_output = channel('rc', False) +assert config.read_bytes() == original and run([*pacman, '-Q']) == before, failure_output + '\nBEFORE:\n' + before + '\nAFTER:\n' + run([*pacman, '-Q']) +assert collision.read_text() == 'administrator file\n' +collision.unlink() +print('ok - a real file-conflict transaction failure preserves both packages and active configuration') + +env['CHANNEL_MUTATE_SOURCE'] = '1' +for cached in (guest / 'cache').glob('*.pkg.tar.zst*'): + cached.unlink() +channel('rc') +del env['CHANNEL_MUTATE_SOURCE'] +assert archive.read_bytes() != archive_bytes and db.read_bytes() != saved +archive.write_bytes(archive_bytes) +db.write_bytes(saved) +assert 'download/rc' in config.read_text() +assert run([*pacman, '-Q', 'omarchy', 'omarchy-settings']).splitlines() == ['omarchy 4.0.3rc1-1', 'omarchy-settings 4.0.3rc1-1'] +assert 'ordinary 2-1' in run([*pacman, '-Q', 'ordinary']) +assert 'new-widget 2-1' in run([*pacman, '-Q', 'new-widget']) +assert subprocess.run([*pacman, '-Q', 'old-widget'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0 +deps = run([*pacman, '-Qqd']).splitlines() +assert 'aquamarine' in deps and 'newlib' in deps +assert 'ordinary' in run([*pacman, '-Qqe']).splitlines() +assert config.read_text().index('[extra]') < config.read_text().index('[omarchy-aarch64]') +print('ok - frozen native transaction upgrades both packages, dependencies and replacements while preserving install reasons') +print('ok - same-version source bytes and lane DB mutation after download cannot change the frozen transaction') + +channel('stable') +assert 'download/stable' in config.read_text() +assert run([*pacman, '-Q', 'omarchy', 'omarchy-settings']).splitlines() == ['omarchy 4.0.2-2', 'omarchy-settings 4.0.2-2'] +assert 'ordinary 2-1' in run([*pacman, '-Q', 'ordinary']) +print('ok - rc to stable downgrades only the explicit pair while retaining the upgraded distribution stack') +PY diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 664e17c50b5..1cdb3f282e1 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -11,6 +11,10 @@ stub_bin="$test_tmp/bin" log_file="$test_tmp/channel.log" mkdir -p "$stub_bin" "$test_tmp/home" +# Preserve coverage for the existing x86 channel path on an ARM test host. +printf '#!/bin/bash\necho x86_64\n' >"$stub_bin/uname" +chmod +x "$stub_bin/uname" + write_stub() { local name="$1" local body="$2" diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh index 2dd62b6e43f..2a13bc9adbd 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -9,6 +9,11 @@ trap 'rm -rf "$test_tmp"' EXIT stub_bin="$test_tmp/bin" mkdir -p "$stub_bin" +mkdir -p "$test_tmp/home" "$test_tmp/packaged" +ln -s "$stub_bin" "$test_tmp/packaged/bin" +# The installed-path reset must resolve to fixture helpers too. Keep the +# updater logic unchanged and substitute only its package root in this copy. +sed "s|/usr/share/omarchy|$test_tmp/packaged|g" "$ROOT/bin/omarchy-update" >"$test_tmp/channel-update" # Every step omarchy-update runs, recorded in order with the unattended flag it # was handed. One of them can be told to fail. @@ -30,6 +35,8 @@ steps=( omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart + omarchy-dev-unlink + omarchy-state ) for step in "${steps[@]}"; do @@ -48,8 +55,9 @@ run_update() { STEP_LOG="$test_tmp/steps" \ FAILING_STEP="${FAILING_STEP:-}" \ OMARCHY_UPDATE_LOGGED=1 \ + HOME="$test_tmp/home" \ PATH="$stub_bin:$PATH" \ - bash "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" + bash "${UPDATE_TEST_SCRIPT:-$ROOT/bin/omarchy-update}" "$@" >"$test_tmp/out" 2>"$test_tmp/err" } steps_run() { @@ -106,3 +114,21 @@ for step in omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update- fi done pass "a blocked package upgrade stops the update before it migrates" + +# A channel update uses one system transaction under the same snapshot/lock +# boundary; it must not first install keyrings or update an old dev checkout. +UPDATE_TEST_SCRIPT="$test_tmp/channel-update" OMARCHY_PATH="$ROOT" OMARCHY_UPDATE_CHANNEL=rc run_update -y || fail "channel update succeeds" +[[ $(grep -c '^omarchy-update-system-pkgs ' "$test_tmp/steps") == 1 ]] || fail "channel update has one system package transaction" +! grep -Eq '^omarchy-update-(dev|keyring) ' "$test_tmp/steps" || fail "channel update does not mutate sources or install keyrings before staging" +[[ $(steps_run | awk '/omarchy-update-system-pkgs/,/omarchy-migrate/') == $'omarchy-update-system-pkgs\nomarchy-dev-unlink\nomarchy-state\nomarchy-migrate' ]] || fail "channel migration follows transaction and package-backed path restoration" +pass "channel update retains lock and snapshot orchestration around a single system transaction" + +if UPDATE_TEST_SCRIPT="$test_tmp/channel-update" OMARCHY_PATH="$ROOT" OMARCHY_UPDATE_CHANNEL=rc FAILING_STEP=omarchy-update-system-pkgs run_update -y; then + fail "failed channel transaction cannot complete its update" +fi +! grep -Eq '^(omarchy-dev-unlink|omarchy-migrate) ' "$test_tmp/steps" || fail "failed channel transaction cannot change runtime path or run migrations" +if UPDATE_TEST_SCRIPT="$test_tmp/channel-update" OMARCHY_PATH="$ROOT" OMARCHY_UPDATE_CHANNEL=rc FAILING_STEP=omarchy-migrate run_update -y; then + fail "failed channel migration cannot pass for a complete update" +fi +! grep -Eq '^(omarchy-hook|omarchy-update-restart) ' "$test_tmp/steps" || fail "failed channel migration stops downstream work" +pass "channel transaction and migration failures propagate without downstream success" From 232b33632842a94346a2ee0f089a28a73bb9500e Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 08:34:41 +0530 Subject: [PATCH 06/27] Pin Mac package inputs and preserve local boot configuration --- .github/workflows/install-vm.yml | 7 +- .github/workflows/main.yml | 7 +- build-inputs/README.md | 9 ++ build-inputs/omarchy-first-run-packages.patch | 93 +++++++++++++++++++ build-inputs/omarchy-pkgs-revision | 1 + build-inputs/prepare-recipes.sh | 48 ++++++++++ build-packages.sh | 59 ++++++++++-- install.sh | 18 ++-- .../install-mac-snapper-dependency-test.sh | 1 + test/shell.d/package-build-contract-test.sh | 51 ++++++++++ test/shell.d/settings-package-units-test.sh | 4 + 11 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 build-inputs/README.md create mode 100644 build-inputs/omarchy-first-run-packages.patch create mode 100644 build-inputs/omarchy-pkgs-revision create mode 100755 build-inputs/prepare-recipes.sh create mode 100755 test/shell.d/package-build-contract-test.sh diff --git a/.github/workflows/install-vm.yml b/.github/workflows/install-vm.yml index aad6dbea377..7f9a9378ade 100644 --- a/.github/workflows/install-vm.yml +++ b/.github/workflows/install-vm.yml @@ -37,12 +37,15 @@ jobs: # The default pull_request checkout tests GitHub's merge ref. persist-credentials: false + - name: Read package recipe pin + id: recipes + run: echo "revision=$(cat build-inputs/omarchy-pkgs-revision)" >> "$GITHUB_OUTPUT" + - name: Checkout omarchy-pkgs uses: actions/checkout@v4 with: repository: omacom/omarchy-pkgs - # Companion first-run dependencies and units (omacom/omarchy-pkgs#341). - ref: 6d27290193109c07b0134360d382e64790ae5dda + ref: ${{ steps.recipes.outputs.revision }} path: omarchy-pkgs persist-credentials: false diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e9c10ba54e4..308b695b708 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -82,12 +82,15 @@ jobs: with: node-version: "20" + - name: Read package recipe pin + id: recipes + run: echo "revision=$(cat build-inputs/omarchy-pkgs-revision)" >> "$GITHUB_OUTPUT" + - name: Checkout omarchy-pkgs uses: actions/checkout@v4 with: repository: omacom/omarchy-pkgs - # Companion first-run dependencies and units (omacom/omarchy-pkgs#341). - ref: 6d27290193109c07b0134360d382e64790ae5dda + ref: ${{ steps.recipes.outputs.revision }} path: omarchy-pkgs persist-credentials: false diff --git a/build-inputs/README.md b/build-inputs/README.md new file mode 100644 index 00000000000..398dd5b543b --- /dev/null +++ b/build-inputs/README.md @@ -0,0 +1,9 @@ +# Apple Silicon package inputs + +`omarchy-pkgs-revision` pins the merged upstream recipe commit for this desktop source. `prepare-recipes.sh` copies that checkout and applies `omarchy-first-run-packages.patch` before any build; the native installer, desktop CI, and ARM publisher all use this contract. The patch carries the publisher's existing Snapper and keyboard-unit fixes plus the same conditional plocate-unit support for development recipes that stable recipes already have. Updating the pin requires reviewing and testing the overlay against the new commit. + +`build-packages.sh` uses this checkout's `version` for both desktop packages, including the exact `omarchy-settings` dependency. Use attached prerelease versions (`4.0.3rc1`), which pacman sorts below the final `4.0.3`. A source version change resets the recipe release to 1; `OMARCHY_PKGREL` can explicitly select a positive release number. Downloaded font and keyring sources retain makepkg checksum verification. The output directory includes `build-inputs.txt` with the recipe revision, recipe file hashes, source revision, dirty-file count, and effective PKGBUILD hashes. + +`OMARCHY_PKGS_PATH` accepts a repository checkout or its `pkgbuilds` directory. Release builds require the pinned revision and a clean recipe tree. For intentional development with a different or modified recipe tree, also set `OMARCHY_ALLOW_CUSTOM_RECIPES=1`; this emits a warning and records the custom input hashes. Custom recipes still must match the reviewed ARM overlay. Their output is not evidence for the pinned release build. + +The settings builder retains the fork's Asahi mkinitcpio drop-ins and includes both paths in pacman's backup list so upgrades preserve administrator edits. Package archive checks and isolated old-package upgrade transactions must cover these files whenever the recipe pin or overlay changes. diff --git a/build-inputs/omarchy-first-run-packages.patch b/build-inputs/omarchy-first-run-packages.patch new file mode 100644 index 00000000000..89432fac595 --- /dev/null +++ b/build-inputs/omarchy-first-run-packages.patch @@ -0,0 +1,93 @@ +diff --git a/pkgbuilds/omarchy-dev/PKGBUILD b/pkgbuilds/omarchy-dev/PKGBUILD +index 58630ed..e79935f 100644 +--- a/pkgbuilds/omarchy-dev/PKGBUILD ++++ b/pkgbuilds/omarchy-dev/PKGBUILD +@@ -44,6 +44,7 @@ depends=( + + # System font (UI assumes it) + 'ttf-jetbrains-mono-nerd-basic' ++ 'snapper' # Root snapshots also apply to btrfs installations on Apple Silicon. + ) + + # Bootloader stack. Lives here rather than on omarchy-settings because +@@ -57,11 +58,10 @@ depends_x86_64=( + 'limine' + 'limine-mkinitcpio-hook' + 'limine-snapper-sync' +- 'snapper' + ) + + # Apple Silicon boots through m1n1 + GRUB from the Asahi packages and keeps +-# Arch Linux ARM's kernel and boot layout, so the Limine/Snapper stack does not ++# Arch Linux ARM's kernel and boot layout, so the Limine stack does not + # apply. Wi-Fi on the Broadcom parts only scans reliably through the iwd + # backend, which install/hardware/network.sh selects on Apple Silicon. + depends_aarch64=( +diff --git a/pkgbuilds/omarchy-settings-dev/PKGBUILD b/pkgbuilds/omarchy-settings-dev/PKGBUILD +index 8cda17b..4158b1c 100644 +--- a/pkgbuilds/omarchy-settings-dev/PKGBUILD ++++ b/pkgbuilds/omarchy-settings-dev/PKGBUILD +@@ -193,6 +193,11 @@ package() { + install -Dm644 default/systemd/user/omarchy-tailscale-receive.service "$pkgdir/usr/lib/systemd/user/omarchy-tailscale-receive.service" + install -Dm644 default/systemd/user/omarchy-fcitx5.service "$pkgdir/usr/lib/systemd/user/omarchy-fcitx5.service" + install -Dm644 default/systemd/user/omarchy-crash-watch.service "$pkgdir/usr/lib/systemd/user/omarchy-crash-watch.service" ++ # Newer source trees enable this at first login; older pinned releases do ++ # not contain or request the unit. Keep both source layouts buildable. ++ if [[ -f default/systemd/user/omarchy-brightness-keyboard-auto.service ]]; then ++ install -Dm644 default/systemd/user/omarchy-brightness-keyboard-auto.service "$pkgdir/usr/lib/systemd/user/omarchy-brightness-keyboard-auto.service" ++ fi + # Marks app.slice (and only app.slice) as a systemd-oomd kill candidate, so + # memory pressure costs one app scope instead of the compositor session. + # Thresholds live in etc/systemd/oomd.conf.d/10-omarchy.conf. Both this and +diff --git a/pkgbuilds/omarchy-settings/PKGBUILD b/pkgbuilds/omarchy-settings/PKGBUILD +index a457e6d..ada2878 100644 +--- a/pkgbuilds/omarchy-settings/PKGBUILD ++++ b/pkgbuilds/omarchy-settings/PKGBUILD +@@ -188,6 +188,11 @@ package() { + install -Dm644 default/systemd/user/omarchy-tailscale-receive.service "$pkgdir/usr/lib/systemd/user/omarchy-tailscale-receive.service" + install -Dm644 default/systemd/user/omarchy-fcitx5.service "$pkgdir/usr/lib/systemd/user/omarchy-fcitx5.service" + install -Dm644 default/systemd/user/omarchy-crash-watch.service "$pkgdir/usr/lib/systemd/user/omarchy-crash-watch.service" ++ # Newer source trees enable this at first login; older pinned releases do ++ # not contain or request the unit. Keep both source layouts buildable. ++ if [[ -f default/systemd/user/omarchy-brightness-keyboard-auto.service ]]; then ++ install -Dm644 default/systemd/user/omarchy-brightness-keyboard-auto.service "$pkgdir/usr/lib/systemd/user/omarchy-brightness-keyboard-auto.service" ++ fi + # Marks app.slice (and only app.slice) as a systemd-oomd kill candidate, so + # memory pressure costs one app scope instead of the compositor session. + # Thresholds live in etc/systemd/oomd.conf.d/10-omarchy.conf. Both this and +diff --git a/pkgbuilds/omarchy/PKGBUILD b/pkgbuilds/omarchy/PKGBUILD +index 2e20a38..7ec3267 100644 +--- a/pkgbuilds/omarchy/PKGBUILD ++++ b/pkgbuilds/omarchy/PKGBUILD +@@ -58,6 +58,7 @@ depends=( + + # System font (UI assumes it) + 'ttf-jetbrains-mono-nerd-basic' ++ 'snapper' # Root snapshots also apply to btrfs installations on Apple Silicon. + ) + + # Bootloader stack. Lives here rather than on omarchy-settings because +@@ -71,11 +72,10 @@ depends_x86_64=( + 'limine' + 'limine-mkinitcpio-hook' + 'limine-snapper-sync' +- 'snapper' + ) + + # Apple Silicon boots through m1n1 + GRUB from the Asahi packages and keeps +-# Arch Linux ARM's kernel and boot layout, so the Limine/Snapper stack does not ++# Arch Linux ARM's kernel and boot layout, so the Limine stack does not + # apply. Wi-Fi on the Broadcom parts only scans reliably through the iwd + # backend, which install/hardware/network.sh selects on Apple Silicon. + depends_aarch64=( +diff --git a/pkgbuilds/omarchy-settings-dev/PKGBUILD b/pkgbuilds/omarchy-settings-dev/PKGBUILD +--- a/pkgbuilds/omarchy-settings-dev/PKGBUILD ++++ b/pkgbuilds/omarchy-settings-dev/PKGBUILD +@@ -205,3 +210,6 @@ +- install -Dm644 default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf "$pkgdir/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf" ++ # Older source trees configure locate through install/config/locate.sh. ++ if [[ -f default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf ]]; then ++ install -Dm644 default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf "$pkgdir/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf" ++ fi + + # Same for applications/** — used by omarchy-refresh-applications, diff --git a/build-inputs/omarchy-pkgs-revision b/build-inputs/omarchy-pkgs-revision new file mode 100644 index 00000000000..33d5bec027c --- /dev/null +++ b/build-inputs/omarchy-pkgs-revision @@ -0,0 +1 @@ +19ef4b560ffd6f26df67665400394278065cf437 diff --git a/build-inputs/prepare-recipes.sh b/build-inputs/prepare-recipes.sh new file mode 100755 index 00000000000..ce739bc9f24 --- /dev/null +++ b/build-inputs/prepare-recipes.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# The desktop owns the recipe revision and ARM overlay used by every builder. +# Copy before patching so a retry never changes the caller's checkout. +prepare_omarchy_recipes() { + local recipe_source="$1" destination="$2" inputs_dir revision actual + inputs_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + revision=$(<"$inputs_dir/omarchy-pkgs-revision") + [[ $revision =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid package recipe pin' >&2; return 1; } + [[ ! -d $recipe_source/pkgbuilds ]] || recipe_source="$recipe_source/pkgbuilds" + + actual=$(git -C "$recipe_source" rev-parse HEAD 2>/dev/null) || actual=unversioned + if [[ ${OMARCHY_ALLOW_CUSTOM_RECIPES:-0} != 1 ]]; then + [[ $actual == "$revision" ]] || { + echo "Package recipes must be at $revision (found $actual). Set OMARCHY_ALLOW_CUSTOM_RECIPES=1 only for an intentional custom build." >&2 + return 1 + } + [[ -z $(git -C "$recipe_source" status --porcelain --untracked-files=all -- .) ]] || { + echo 'Package recipe checkout has local changes; use a clean checkout or explicitly opt into a custom build.' >&2 + return 1 + } + else + echo "Warning: custom recipes selected ($actual); this is not a release-qualified build." >&2 + fi + [[ ! -e $destination ]] || { echo "Recipe output already exists: $destination" >&2; return 1; } + mkdir -p "$destination/pkgbuilds" || return 1 + if [[ ${OMARCHY_ALLOW_CUSTOM_RECIPES:-0} == 1 ]]; then + cp -a "$recipe_source/." "$destination/pkgbuilds/" || return 1 + else + # Export the immutable tree; ignored makepkg cache/build files in a clean + # developer checkout must never become implicit release inputs. + git -C "$(git -C "$recipe_source" rev-parse --show-toplevel)" archive "$revision:pkgbuilds" | tar -xf - -C "$destination/pkgbuilds" || return 1 + fi + local overlay="$inputs_dir/omarchy-first-run-packages.patch" + if git apply --check --unsafe-paths --directory="$destination" "$overlay" 2>/dev/null; then + git apply --unsafe-paths --directory="$destination" "$overlay" || return 1 + elif ! git apply --reverse --check --unsafe-paths --directory="$destination" "$overlay" 2>/dev/null; then + echo 'Package recipes no longer match the ARM first-run overlay; review them before building.' >&2 + return 1 + fi + printf '%s\n' "recipe_commit=$actual" "recipe_pin=$revision" "custom_recipes=${OMARCHY_ALLOW_CUSTOM_RECIPES:-0}" >"$destination/provenance" + (cd "$destination" && find pkgbuilds -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) >>"$destination/provenance" +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + set -euo pipefail + prepare_omarchy_recipes "${1:?Usage: prepare-recipes.sh SOURCE DESTINATION}" "${2:?Destination required}" +fi diff --git a/build-packages.sh b/build-packages.sh index 846f0b6678e..279963be4e5 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -3,8 +3,8 @@ # Build the Omarchy packages for Apple Silicon from this checkout. # # omarchy, omarchy-settings, omarchy-keyring, and ttf-jetbrains-mono-nerd-basic -# are all arch=any, so they need no architecture-specific build. The only Apple -# Silicon delta is the limine bootloader stack, patched out below. +# include architecture-specific settings and dependencies. Build on aarch64 +# using the pinned recipes and shared ARM overlay below. # # OMARCHY_PKGREL bumps pkgrel on omarchy and omarchy-settings only, so a Mac # hotfix can ship as 4.0.1-2 without waiting for an upstream 4.0.2 tag. Leave @@ -14,6 +14,8 @@ set -euo pipefail readonly checkout="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly output_dir="${OMARCHY_PACKAGE_OUTPUT:-$checkout/build-output}" +source "$checkout/build-inputs/prepare-recipes.sh" + readonly source_cache="${OMARCHY_PACKAGE_SRCDEST:-${XDG_CACHE_HOME:-$HOME/.cache}/omarchy-build/sources}" # Macs boot m1n1 -> u-boot -> GRUB, so limine is wrong here. Two of these have @@ -61,6 +63,20 @@ find_omarchy_pkgs() { return 1 } +set_source_version() { + local pkgbuild="$1" version + version=$(<"$checkout/version") + [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(rc[1-9][0-9]*)?$ ]] || + fail "Invalid source version: $version (use X.Y.Z or X.Y.ZrcN)" + grep -qE '^pkgver=' "$pkgbuild" || fail "no pkgver= in $pkgbuild" + if ! grep -Fxq "pkgver=$version" "$pkgbuild" && [[ -z ${OMARCHY_PKGREL:-} ]]; then + sed -i 's/^pkgrel=.*/pkgrel=1/' "$pkgbuild" + fi + # Local source overrides upstream's _commit. Its runtime version must also + # determine package metadata and the exact omarchy-settings dependency. + sed -i "s/^pkgver=.*/pkgver=$version/" "$pkgbuild" +} + set_pkgrel() { local pkgbuild="$1" rel=${OMARCHY_PKGREL:-} @@ -132,6 +148,18 @@ keep_apple_silicon_mkinitcpio_drop_ins() { fail "could not keep /etc/mkinitcpio.conf.d in $pkgbuild" grep -qF 'rm -rf "$pkgdir/etc/limine-entry-tool.d"' "$pkgbuild" || fail "lost the limine-entry-tool.d cleanup in $pkgbuild" + + # These files now ship on ARM too. Pacman needs their hashes in backup=() + # to preserve local edits and offer changed defaults as .pacnew on upgrade. + cat >>"$pkgbuild" <<'BACKUP' + +if [[ $CARCH == aarch64 ]]; then + backup+=( + 'etc/mkinitcpio.conf.d/omarchy_hooks.conf' + 'etc/mkinitcpio.conf.d/thunderbolt_module.conf' + ) +fi +BACKUP } # makepkg runs with --nodeps because the runtime dependencies include packages @@ -141,12 +169,16 @@ install_build_dependencies() { local pkgbuild_source="$1" package local -a build_dependencies=() + local metadata for package in "${packages[@]}"; do + # makepkg evaluates arch-specific and computed dependency arrays. Parsing + # shell text loses inline entries, comments and makedepends_aarch64. + metadata=$(cd "$pkgbuild_source/$package" && OMARCHY_SRC="$checkout" makepkg --printsrcinfo) || + fail "Could not read build dependencies for $package" while read -r dependency; do [[ -n $dependency ]] || continue build_dependencies+=("$dependency") - done < <(sed -n '/^makedepends=(/,/^)/p' "$pkgbuild_source/$package/PKGBUILD" | - sed '1d;$d' | tr -d "'\"" | tr -d ' ') + done < <(awk -v arch="$(uname -m)" '$1 == "makedepends" || $1 == "makedepends_" arch { print $3 }' <<<"$metadata") done (( ${#build_dependencies[@]} )) || return 0 @@ -154,7 +186,12 @@ install_build_dependencies() { # pacman -T reports only what is missing, so an already-equipped machine # needs no sudo at all, and repeated makedepends collapse. local -a missing=() - mapfile -t missing < <(pacman -T "${build_dependencies[@]}" || true) + local missing_output status=0 + missing_output=$(pacman -T "${build_dependencies[@]}") || status=$? + (( status == 0 || status == 127 )) || fail "Could not query build dependencies (pacman status $status)" + if [[ -n $missing_output ]]; then + mapfile -t missing <<<"$missing_output" + fi (( ${#missing[@]} )) || return 0 log "Installing build dependencies: ${missing[*]}" @@ -190,14 +227,17 @@ build_package() { fi if [[ $package == "omarchy" || $package == "omarchy-settings" ]]; then set_pkgrel "$build_dir/$package/PKGBUILD" + set_source_version "$build_dir/$package/PKGBUILD" fi + sha256sum "$build_dir/$package/PKGBUILD" >>"$output_dir/build-inputs.txt" + # SRCDEST caches downloaded sources outside the throwaway build directory, so # a rebuild does not re-fetch the 125 MB font archive. ( cd "$build_dir/$package" SRCDEST="$source_cache" OMARCHY_SRC="$checkout" \ - makepkg --force --noconfirm --nodeps --skipinteg + makepkg --force --noconfirm --nodeps ) # A configured makepkg signer leaves detached .sig files beside the archive; @@ -224,15 +264,18 @@ main() { [[ -d "$pkgbuild_source/$package" ]] || fail "$pkgbuild_source/$package is missing." done - install_build_dependencies "$pkgbuild_source" - # build_dir stays global: an EXIT trap runs after main's locals are gone, and # under set -u a local would abort the trap instead of cleaning up. build_dir="$(mktemp -d)" trap remove_build_dir EXIT + prepare_omarchy_recipes "$pkgbuild_source" "$build_dir/recipes" + pkgbuild_source="$build_dir/recipes/pkgbuilds" + install_build_dependencies "$pkgbuild_source" mkdir -p "$output_dir" "$source_cache" remove_old_packages + cp "$build_dir/recipes/provenance" "$output_dir/build-inputs.txt" + printf '%s\n' "source_commit=$(git -C "$checkout" rev-parse HEAD)" "source_version=$(<"$checkout/version")" "source_dirty=$(git -C "$checkout" status --porcelain --untracked-files=all | wc -l)" >>"$output_dir/build-inputs.txt" for package in "${packages[@]}"; do build_package "$package" "$pkgbuild_source" "$build_dir" done diff --git a/install.sh b/install.sh index 10b64172294..04e0100efa4 100755 --- a/install.sh +++ b/install.sh @@ -86,14 +86,20 @@ ensure_package_sources() { local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy-build" local pkgs_checkout="$cache_dir/omarchy-pkgs" - if [[ -d $pkgs_checkout/.git ]]; then - log "Updating the PKGBUILD checkout" - git -C "$pkgs_checkout" pull --ff-only || warn "Could not update $pkgs_checkout; using it as is." - else - log "Cloning the PKGBUILD checkout" + local revision + revision=$(<"$checkout/build-inputs/omarchy-pkgs-revision") + [[ $revision =~ ^[0-9a-f]{40}$ ]] || fail "Invalid package recipe revision" + if [[ ! -d $pkgs_checkout/.git ]]; then + log "Cloning the pinned PKGBUILD checkout" mkdir -p "$cache_dir" - git clone --depth 1 https://github.com/omacom/omarchy-pkgs.git "$pkgs_checkout" + git clone https://github.com/omacom/omarchy-pkgs.git "$pkgs_checkout" + fi + [[ -z $(git -C "$pkgs_checkout" status --porcelain --untracked-files=all) ]] || + fail "Cached recipes have local changes: $pkgs_checkout" + if ! git -C "$pkgs_checkout" cat-file -e "$revision^{commit}" 2>/dev/null; then + git -C "$pkgs_checkout" fetch origin "$revision" || fail "Could not fetch pinned package recipes" fi + git -C "$pkgs_checkout" checkout --detach "$revision" || fail "Could not select pinned package recipes" export OMARCHY_PKGS_PATH="$pkgs_checkout" } diff --git a/test/shell.d/install-mac-snapper-dependency-test.sh b/test/shell.d/install-mac-snapper-dependency-test.sh index 3bfb208b7b3..3cf701675cb 100644 --- a/test/shell.d/install-mac-snapper-dependency-test.sh +++ b/test/shell.d/install-mac-snapper-dependency-test.sh @@ -12,6 +12,7 @@ for dependency in absent snapper 'snapper>=0.12'; do case_dir="$work_dir/${dependency//[>=]/_}" mkdir -p "$case_dir/source/omarchy" "$case_dir/build" "$case_dir/output" cat >"$case_dir/source/omarchy/PKGBUILD" <<'PKGBUILD' +pkgver=0.0.0 pkgrel=1 depends=( 'gum' diff --git a/test/shell.d/package-build-contract-test.sh b/test/shell.d/package-build-contract-test.sh new file mode 100755 index 00000000000..40c32f8bb68 --- /dev/null +++ b/test/shell.d/package-build-contract-test.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname -- "${BASH_SOURCE[0]}")/base-test.sh" +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +# Exercise local-source package metadata, including the pair's dynamic pin. +cat >"$work_dir/PKGBUILD" <<'RECIPE' +pkgver=99.0.0 +pkgrel=9 +depends=("omarchy-settings=${pkgver}") +RECIPE +( + source "$ROOT/build-packages.sh" + set_source_version "$work_dir/PKGBUILD" + source "$work_dir/PKGBUILD" + [[ $pkgver == "$(<"$ROOT/version")" && $pkgrel == 1 ]] + [[ ${depends[0]} == "omarchy-settings=$(<"$ROOT/version")" ]] + OMARCHY_PKGREL=3 set_pkgrel "$work_dir/PKGBUILD" + OMARCHY_PKGREL=3 set_source_version "$work_dir/PKGBUILD" + source "$work_dir/PKGBUILD" + [[ $pkgrel == 3 ]] +) || fail 'local source version controls package metadata and exact pair dependency' +pass 'source version replaces stale recipe version and preserves explicit pkgrel' + +# Reject dirty/wrong default sources before touching an existing output. +( + source "$ROOT/build-inputs/prepare-recipes.sh" + mkdir "$work_dir/unknown" + if prepare_omarchy_recipes "$work_dir/unknown" "$work_dir/rejected" 2>/dev/null; then exit 1; fi + [[ ! -e $work_dir/rejected ]] +) || fail 'unversioned recipes require explicit custom-build opt-in' +pass 'release builds reject unversioned recipe inputs before staging' + +# Check Arch's interpreted metadata, not grep of PKGBUILD shell syntax. +# The fixtures cover both common and architecture-specific build dependencies. +( + source "$ROOT/build-packages.sh" + makepkg() { printf '\tmakedepends = git\n\tmakedepends_aarch64 = imagemagick>=7\n\tmakedepends_x86_64 = wrong-arch\n'; } + uname() { echo aarch64; } + pacman() { + [[ $1 == -T ]] + [[ $* == *'imagemagick>=7'* && $* != *wrong-arch* ]] + return 0 + } + for package in "${packages[@]}"; do mkdir -p "$work_dir/recipes/$package"; done + install_build_dependencies "$work_dir/recipes" + pacman() { return 1; } + if ( install_build_dependencies "$work_dir/recipes" >/dev/null 2>&1 ); then exit 1; fi +) || fail 'build dependency resolution honors arch arrays and rejects database errors' +pass 'build dependencies use makepkg metadata and propagate database errors' diff --git a/test/shell.d/settings-package-units-test.sh b/test/shell.d/settings-package-units-test.sh index fe3e1ca86ca..455b2f50778 100644 --- a/test/shell.d/settings-package-units-test.sh +++ b/test/shell.d/settings-package-units-test.sh @@ -11,6 +11,10 @@ pkgs_root="${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs}" test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT +# Exercise the same canonical recipe overlay used by the native builder. +source "$ROOT/build-inputs/prepare-recipes.sh" +prepare_omarchy_recipes "$pkgs_root" "$test_tmp/recipes" +pkgs_root="$test_tmp/recipes/pkgbuilds" mkdir -p "$test_tmp/source/omarchy" # Use tracked checkout contents, not an installed desktop or the developer's From eda3a6db5ffa42e80466b02bcb24e7d3be8c16c3 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 08:52:15 +0530 Subject: [PATCH 07/27] Configure zram during fresh ARM system setup --- install/hardware/all.sh | 1 + install/hardware/zram.sh | 13 +++ install/helpers/zram.sh | 27 +++++ migrations/1787669934.sh | 13 +-- migrations/1789246530.sh | 13 +-- test/shell.d/zram-fresh-install-test.sh | 140 ++++++++++++++++++++++++ test/shell.d/zram-package-test.sh | 2 + 7 files changed, 189 insertions(+), 20 deletions(-) create mode 100644 install/hardware/zram.sh create mode 100644 install/helpers/zram.sh create mode 100755 test/shell.d/zram-fresh-install-test.sh diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 7f33ad442a0..ca12c782e1c 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -1,3 +1,4 @@ +run_logged "$OMARCHY_INSTALL/hardware/zram.sh" run_logged "$OMARCHY_INSTALL/hardware/asus-rog.sh" run_logged "$OMARCHY_INSTALL/hardware/framework16.sh" run_logged "$OMARCHY_INSTALL/hardware/dell-xps-touchpad-haptics.sh" diff --git a/install/hardware/zram.sh b/install/hardware/zram.sh new file mode 100644 index 00000000000..e6a716b4c60 --- /dev/null +++ b/install/hardware/zram.sh @@ -0,0 +1,13 @@ +# ARM settings leave generator configuration to the installed machine. Fresh +# users receive completed migration markers, so system setup must supply the +# missing default before user provisioning. Keep every existing local choice. +if [[ ${OMARCHY_FIRST_INSTALL:-0} == "1" && ${OMARCHY_UPGRADE:-0} != "1" && $(uname -m) == "aarch64" ]]; then + source "$OMARCHY_INSTALL/helpers/zram.sh" + if ! omarchy_zram_has_config; then + if omarchy-pkg-missing zram-generator; then + echo "zram-generator is required before configuring compressed swap; retry system setup after installing it." >&2 + return 1 + fi + omarchy_zram_write_default + fi +fi diff --git a/install/helpers/zram.sh b/install/helpers/zram.sh new file mode 100644 index 00000000000..b8cbdf6250d --- /dev/null +++ b/install/helpers/zram.sh @@ -0,0 +1,27 @@ +# An empty file or symlink may deliberately disable the generator. Check every +# main-file and drop-in location before supplying any new default. +omarchy_zram_has_config() { + local root="${OMARCHY_ZRAM_ROOT:-}" directory config + for directory in /etc /run /usr/local/lib /usr/lib; do + for config in "$root$directory/systemd/zram-generator.conf" \ + "$root$directory/systemd/zram-generator.conf.d/"*.conf; do + [[ ! -e $config && ! -L $config ]] || return 0 + done + done + return 1 +} + +# System setup runs as root. Publish only a complete configuration, and never +# replace a file an administrator created while the default was being staged. +# A failed copy leaves no partial main file that a retry could mistake for an +# administrator's deliberate empty configuration. +omarchy_zram_write_default() ( + local directory="${OMARCHY_ZRAM_ROOT:-}/etc/systemd" staging + mkdir -p "$directory" || return 1 + staging=$(mktemp "$directory/.omarchy-zram-XXXXXXXX") || return 1 + trap 'rm -f "$staging"' EXIT + install -m 0644 "$OMARCHY_PATH/default/systemd/zram-generator.conf.d/90-omarchy.conf" "$staging" || return 1 + if ! omarchy_zram_has_config; then + ln -T -- "$staging" "$directory/zram-generator.conf" || return 1 + fi +) diff --git a/migrations/1787669934.sh b/migrations/1787669934.sh index 91e6581c4c2..b6317bf619c 100644 --- a/migrations/1787669934.sh +++ b/migrations/1787669934.sh @@ -17,18 +17,9 @@ fi # Keep /usr/lib package-owned so a corrected settings package can install its # vendor drop-in without a file conflict; that drop-in takes precedence later. zram_root="${OMARCHY_ZRAM_ROOT:-}" -zram_configured=false -for directory in /etc /run /usr/local/lib /usr/lib; do - for config in "$zram_root$directory/systemd/zram-generator.conf" \ - "$zram_root$directory/systemd/zram-generator.conf.d/"*.conf; do - if [[ -e $config || -L $config ]]; then - zram_configured=true - break 2 - fi - done -done +source "$OMARCHY_PATH/install/helpers/zram.sh" -if [[ $zram_configured == "false" ]]; then +if ! omarchy_zram_has_config; then sudo install -D -m 0644 "$OMARCHY_PATH/default/systemd/zram-generator.conf.d/90-omarchy.conf" \ "$zram_root/etc/systemd/zram-generator.conf" fi diff --git a/migrations/1789246530.sh b/migrations/1789246530.sh index 792a8cc963a..1e52dd7d9b0 100644 --- a/migrations/1789246530.sh +++ b/migrations/1789246530.sh @@ -7,15 +7,10 @@ echo "Repair missing zram configuration on previously migrated installs" state_dir="${OMARCHY_MIGRATION_STATE:-$HOME/.local/state/omarchy/migrations}" repair_pending="$state_dir/1789246530.zram-repair-pending" if [[ ! -f $repair_pending ]]; then - zram_root="${OMARCHY_ZRAM_ROOT:-}" - for directory in /etc /run /usr/local/lib /usr/lib; do - for config in "$zram_root$directory/systemd/zram-generator.conf" \ - "$zram_root$directory/systemd/zram-generator.conf.d/"*.conf; do - if [[ -e $config || -L $config ]]; then - exit 0 - fi - done - done + source "$OMARCHY_PATH/install/helpers/zram.sh" + if omarchy_zram_has_config; then + exit 0 + fi # A failed activation may already have installed the fallback. Remember that # this user started the repair so a retry cannot mistake it for a local choice. mkdir -p "$state_dir" diff --git a/test/shell.d/zram-fresh-install-test.sh b/test/shell.d/zram-fresh-install-test.sh new file mode 100755 index 00000000000..3678d171839 --- /dev/null +++ b/test/shell.d/zram-fresh-install-test.sh @@ -0,0 +1,140 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname -- "${BASH_SOURCE[0]}")/base-test.sh" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" +export OMARCHY_PATH="${OMARCHY_ZRAM_PACKAGE_ROOT:-$ROOT}" +export OMARCHY_INSTALL="$OMARCHY_PATH/install" +export OMARCHY_FIRST_INSTALL=1 OMARCHY_UPGRADE=0 +export TEST_ZRAM_INSTALLED="$work/installed" TEST_ZRAM_CALLS="$work/calls" +touch "$TEST_ZRAM_INSTALLED" "$TEST_ZRAM_CALLS" +cat >"$work/bin/uname" <<'STUB' +#!/bin/bash +printf '%s\n' "${TEST_ARCH:-aarch64}" +STUB +cat >"$work/bin/omarchy-pkg-missing" <<'STUB' +#!/bin/bash +printf 'package %s\n' "$*" >>"$TEST_ZRAM_CALLS" +[[ ! -e $TEST_ZRAM_INSTALLED ]] +STUB +cat >"$work/bin/install" <<'STUB' +#!/bin/bash +if [[ ${TEST_COPY_FAIL:-0} == 1 ]]; then + printf partial >"${@: -1}" + exit 1 +fi +exec /usr/bin/install "$@" +STUB +cat >"$work/bin/systemctl" <<'STUB' +#!/bin/bash +printf 'unexpected systemctl %s\n' "$*" >>"$TEST_ZRAM_CALLS" +exit 99 +STUB +chmod +x "$work/bin/"* +export PATH="$work/bin:$PATH" + +# Use the actual dispatcher and logging runner. Intercept unrelated hardware +# leaves so this fixture cannot configure the developer's machine. +source "$OMARCHY_INSTALL/helpers/logging.sh" +eval "$(declare -f run_logged | sed '1s/run_logged/zram_run_logged/')" +run_logged() { + if [[ $1 == "$OMARCHY_INSTALL/hardware/zram.sh" ]]; then + zram_run_logged "$1" + fi +} +export -f run_logged zram_run_logged omarchy_log_line omarchy_log_to_stdout +run_setup() { + # A separate interpreter retains errexit when the test expects a failure. + bash -euo pipefail -c 'source "$OMARCHY_INSTALL/hardware/all.sh"' >"$work/setup.log" 2>&1 +} +new_root() { + export OMARCHY_ZRAM_ROOT="$work/$1" + mkdir -p "$OMARCHY_ZRAM_ROOT" + : >"$TEST_ZRAM_CALLS" +} + +new_root fresh +run_setup +config="$OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf" +cmp "$OMARCHY_PATH/default/systemd/zram-generator.conf.d/90-omarchy.conf" "$config" || fail 'fresh hardware setup installs the shipped default' +[[ $(stat -c %a "$config") == 644 ]] || fail 'fresh default is readable by the generator' +: >"$TEST_ZRAM_CALLS" +run_setup +[[ ! -s $TEST_ZRAM_CALLS ]] || fail 'repeated setup preserves configuration without package or service actions' +pass 'fresh and repeated ARM hardware setup delivers persistent configuration' + +for context in ordinary upgrade x86; do + new_root "$context" + case "$context" in + ordinary) OMARCHY_FIRST_INSTALL=0 run_setup ;; + upgrade) OMARCHY_UPGRADE=1 run_setup ;; + x86) TEST_ARCH=x86_64 run_setup ;; + esac + [[ ! -e $OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf && ! -s $TEST_ZRAM_CALLS ]] || fail "$context setup must not preempt migration or change x86 policy" +done +pass 'non-fresh, upgrade, and x86 paths remain unchanged' + +for directory in etc run usr/local/lib usr/lib; do + for layout in main drop-in; do + for kind in custom empty mask dangling-mask; do + new_root "preserve-$directory-$layout-$kind" + config="$OMARCHY_ZRAM_ROOT/$directory/systemd/zram-generator.conf" + [[ $layout == main ]] || config="$config.d/99-local.conf" + mkdir -p "$(dirname "$config")" + case "$kind" in + custom) printf '[zram0]\nzram-size = ram / 4\n' >"$config" ;; + empty) touch "$config" ;; + mask) ln -s /dev/null "$config" ;; + dangling-mask) ln -s "$work/missing" "$config" ;; + esac + cp -P "$config" "$work/expected" + run_setup + [[ ! -s $TEST_ZRAM_CALLS ]] || fail "preserve $directory/$layout/$kind without activation" + if [[ -L $config ]]; then + [[ $(readlink "$config") == "$(readlink "$work/expected")" ]] || fail 'preserve symlink target' + else + cmp "$config" "$work/expected" || fail 'preserve administrator bytes' + fi + rm "$work/expected" + if [[ $config != "$OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf" ]]; then + [[ ! -e $OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf ]] || fail 'do not supplement existing generator configuration' + fi + done + done +done +pass 'all main/drop-in locations preserve custom, empty, and masked configurations' + +new_root package-failure +rm "$TEST_ZRAM_INSTALLED" +if run_setup; then fail 'missing required package must fail first installation'; fi +[[ ! -e $OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf ]] || fail 'missing generator must not publish a config' +touch "$TEST_ZRAM_INSTALLED" +run_setup +[[ -f $OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf ]] || fail 'retry after package installation must configure swap' + +new_root copy-failure +if TEST_COPY_FAIL=1 run_setup; then fail 'copy failure must fail first installation'; fi +[[ ! -e $OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf ]] || fail 'partial default must never become live configuration' +[[ -z $(find "$OMARCHY_ZRAM_ROOT" -name '.omarchy-zram-*' -print) ]] || fail 'failed staging must clean its temporary file' +run_setup +cmp "$OMARCHY_PATH/default/systemd/zram-generator.conf.d/90-omarchy.conf" "$OMARCHY_ZRAM_ROOT/etc/systemd/zram-generator.conf" || fail 'copy retry must publish the complete default' +! grep -q 'systemctl' "$TEST_ZRAM_CALLS" || fail 'fresh setup must not control the running manager' +pass 'package and partial-copy failures propagate and remain retryable' + +generator=/usr/lib/systemd/system-generators/zram-generator +if [[ -x $generator ]]; then + new_root generator + mkdir -p "$OMARCHY_ZRAM_ROOT/proc" "$work/generated" + printf 'MemTotal: 8388608 kB\n' >"$OMARCHY_ZRAM_ROOT/proc/meminfo" + : >"$OMARCHY_ZRAM_ROOT/proc/cmdline" + ZRAM_GENERATOR_ROOT="$OMARCHY_ZRAM_ROOT" "$generator" "$work/generated" + [[ ! -e $work/generated/dev-zram0.swap ]] || fail 'unconfigured generator should have no device' + run_setup + ZRAM_GENERATOR_ROOT="$OMARCHY_ZRAM_ROOT" "$generator" "$work/generated" + [[ -f $work/generated/dev-zram0.swap && -L $work/generated/swap.target.wants/dev-zram0.swap ]] || fail 'fresh setup must produce a swap.target device on next boot' + grep -Fx 'Requires=systemd-zram-setup@zram0.service' "$work/generated/dev-zram0.swap" >/dev/null || fail 'generated swap must depend on device setup' + pass 'real native generator consumes the fresh-install configuration' +else + pass 'zram-generator unavailable; native generator coverage skipped' +fi diff --git a/test/shell.d/zram-package-test.sh b/test/shell.d/zram-package-test.sh index 1b9d00206d7..c22450935b6 100755 --- a/test/shell.d/zram-package-test.sh +++ b/test/shell.d/zram-package-test.sh @@ -20,6 +20,8 @@ migration_name=$(basename "$migration") first_marker="$first_home/.local/state/omarchy/migrations/$migration_name" second_marker="$second_home/.local/state/omarchy/migrations/$migration_name" mkdir -p "$stub_bin" "$test_root/migrations" +mkdir -p "$test_root/install/helpers" +cp "$ROOT/install/helpers/zram.sh" "$test_root/install/helpers/zram.sh" cp "$migration" "$test_root/migrations/$migration_name" mkdir -p "$test_root/default/systemd/zram-generator.conf.d" cp "$ROOT/default/systemd/zram-generator.conf.d/90-omarchy.conf" \ From 63c9c0782c07a63cc4e4b8a5c65bc37d4a2f8cb5 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 09:31:02 +0530 Subject: [PATCH 08/27] Install preflighted ARM channel packages with isolated trust --- bin/omarchy-upgrade-to-quattro-mac | 4 +- docs/arm-package-sources.md | 16 +- install.sh | 103 ++++++++++-- install/helpers/arm-channel.sh | 160 ++++++++++++++++--- install/post-install/pacman.sh | 14 +- test/shell.d/arm-channel-test.sh | 12 ++ test/shell.d/arm-channel-transaction-test.sh | 48 +++++- test/shell.d/install-channel-test.sh | 83 ++++++++++ test/shell.d/install-mac-test.sh | 8 +- 9 files changed, 404 insertions(+), 44 deletions(-) create mode 100755 test/shell.d/install-channel-test.sh diff --git a/bin/omarchy-upgrade-to-quattro-mac b/bin/omarchy-upgrade-to-quattro-mac index 88996d78690..637fe6eec1f 100755 --- a/bin/omarchy-upgrade-to-quattro-mac +++ b/bin/omarchy-upgrade-to-quattro-mac @@ -173,8 +173,8 @@ ensure_arm_package_repo() { if ! grep -q '^\[omarchy-aarch64\]' /etc/pacman.conf; then local block block=$(sed -n '/^\[omarchy-aarch64\]/,/^Server[[:space:]]*=/p' \ - "$checkout/default/pacman/pacman-stable.conf") - [[ -n $block ]] || fail "default/pacman/pacman-stable.conf has no [omarchy-aarch64] section." + "$checkout/default/pacman/pacman-edge.conf") + [[ -n $block ]] || fail "default/pacman/pacman-edge.conf has no [omarchy-aarch64] section." log "Adding the Omarchy ARM package repo" printf '\n%s\n' "$block" | sudo tee -a /etc/pacman.conf >/dev/null diff --git a/docs/arm-package-sources.md b/docs/arm-package-sources.md index 74e4b334669..a9a8e3f73ee 100644 --- a/docs/arm-package-sources.md +++ b/docs/arm-package-sources.md @@ -14,11 +14,21 @@ The fork-owned repository uses distinct `stable`, `rc`, and `edge` release coord An explicit channel switch goes through the normal update lock, snapshot and migration pipeline. It stages the current pacman configuration, changing only the managed ARM lane and reapplying the existing explicit upstream graphics policy. Other repository ordering, options and mirror Includes are preserved. Custom or ambiguous ARM server/Include layouts are rejected rather than guessed. ARM refresh uses this same path and no longer runs the reset-only `pre-refresh-pacman` hook, because it does not discard and recreate the user's configuration. The x86 reset path retains that hook; normal update hooks still run after successful migrations. -Before changing installed packages, the switch syncs isolated databases, verifies a matching desktop package pair, resolves the full transaction and downloads its archives under the configured signature policy. Required trust that is absent fails preparation; no unknown signing key is imported. It checks the resolved archive hashes and captures the repository databases and archives as local repositories. A second resolution against the real installed database must match the preflight manifest. One ordinary libalpm system-upgrade transaction then uses those captured repositories, preserving dependency reasons, replacement handling and conflict checks. Explicit version-constrained desktop targets allow RC-to-stable downgrades without enabling distribution-wide downgrades. Equal/older lane database timestamps are handled with a forced sync of the captured database. +Before changing installed packages, the switch syncs isolated databases, verifies a matching desktop package pair, resolves the full transaction and downloads its archives under the configured signature policy. Verification uses a private copy of public keyring trust. Required trust that is absent fails preparation; any undeclared key imported during verification rejects the transaction without changing the live keyring. It checks the resolved archive hashes and captures the repository databases and archives as local repositories. A second resolution against the real installed database must match the preflight manifest. One ordinary libalpm system-upgrade transaction then uses those captured repositories, preserving dependency reasons, replacement handling and conflict checks. Explicit version-constrained desktop targets allow RC-to-stable downgrades without enabling distribution-wide downgrades. Equal/older lane database timestamps are handled with a forced sync of the captured database. -The persistent configuration is committed only after successful package installation and only if it has not changed independently. Transaction failures report the installed pair rather than claiming rollback: package hooks may have run before an error. A later migration failure keeps the ordinary update unsuccessful. No migration moves existing `/edge` users to a lane that may not yet be published. +The persistent configuration is committed only after successful package installation and only if it has not changed independently. Transaction failures report the installed pair rather than claiming rollback: package hooks may have run before an error. On abort, the prior sync databases are restored under pacman’s real lock if they still match this transaction’s captured cache. Independent cache changes or a held lock retain recovery files and report that compensation could not complete. Installed packages and hook effects are not rolled back. A later migration failure keeps the ordinary update unsuccessful. No migration moves existing `/edge` users to a lane that may not yet be published. -This freezes one switch transaction, not future distribution upgrades. Arch Linux ARM, Asahi and the explicitly selected upstream graphics stack still resolve according to their rolling policies on the next update. Record their resolved versions when qualifying an RC; a different resolved stack needs new compatibility evidence. Temporary repositories require a disk-backed `TMPDIR` (or the default `/var/tmp`) with sufficient free space, and are removed after the transaction. Existing package caches are reused without deleting their archives. +This freezes one switch transaction, not future distribution upgrades. Arch Linux ARM, Asahi and the explicitly selected upstream graphics stack still resolve according to their rolling policies on the next update. Record their resolved versions when qualifying an RC; a different resolved stack needs new compatibility evidence. Captured repositories use a task directory beneath `${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/channels`, require disk-backed storage with sufficient free space, and are removed after the transaction. Existing package caches are reused without deleting their archives. + +## Fresh Apple Silicon installation + +`./install.sh --channel rc` (or `OMARCHY_MIRROR=rc ./install.sh`) installs the published lane's captured `omarchy`/`omarchy-settings` pair. It verifies availability, resolves dependencies and downloads under the configured signature policy before changing locale, packages or active repository configuration. If the base has no managed ARM section, preflight adds one only to its candidate; custom or hidden managed sections must be configured explicitly. The new managed lane uses the existing Mac repository's `Optional TrustAll` policy; unsigned release metadata is not authenticated by this check. Required upstream graphics signatures remain required. + +Fresh preflight can initialize an ephemeral local signing key in its private keyring and fetch and trust only the declared upstream stack fingerprint `40DFB630FF42BCFFB047046CF0134EE680CAC571`. Host secret keys are never copied. The private keyring and its agent are removed on exit. Existing distribution/Asahi trust must already be provisioned by the base system. Accepted installation can then establish the declared stack signer in the live keyring for subsequent package setup. + +The captured core/system transaction is followed by the ordinary rolling default-package and optional AUR setup. A temporary `IgnorePkg` entry protects the published desktop pair during those later operations, and both package versions are checked after defaults and after system/user setup. Setup preserves the preflighted repository configuration; cleanup removes only the installer's temporary pin, before recording a factory snapshot on success. This does not freeze optional/default dependencies, so their resolved versions and any unavailable packages remain part of RC qualification. + +Without a channel argument or `OMARCHY_MIRROR`, the source installer retains its checkout-build behavior and legacy `/edge` repository default. Existing clients and 3-to-4 bootstraps are not silently redirected to an unpublished `/stable` lane. ## Recovering an install that predates this policy diff --git a/install.sh b/install.sh index 04e0100efa4..f329e21842f 100755 --- a/install.sh +++ b/install.sh @@ -12,6 +12,9 @@ readonly checkout="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly package_output="$checkout/build-output" readonly asahi_alarm_key="12CE6799A94A3F1B5DDFFE88F576553597FB8FEB" source "$checkout/install/helpers/arm-package-sources.sh" +source "$checkout/install/helpers/arm-channel.sh" +install_channel="${OMARCHY_MIRROR:-}" +channel_stage="" # gum is how the rest of Omarchy talks to people, but it arrives with the # omarchy package well into this script, so every helper falls back to plain @@ -123,6 +126,10 @@ install_omarchy_packages() { # env-bootstrap is the single source of truth for OMARCHY_PATH and PATH, and # this shell started before the package existed. + load_installed_environment +} + +load_installed_environment() { source /usr/share/omarchy/default/bash/env-bootstrap } @@ -154,8 +161,8 @@ ensure_arm_package_repo() { if ! grep -q '^\[omarchy-aarch64\]' /etc/pacman.conf; then local block block=$(sed -n '/^\[omarchy-aarch64\]/,/^Server[[:space:]]*=/p' \ - "$checkout/default/pacman/pacman-stable.conf") - [[ -n $block ]] || fail "default/pacman/pacman-stable.conf has no [omarchy-aarch64] section." + "$checkout/default/pacman/pacman-edge.conf") + [[ -n $block ]] || fail "default/pacman/pacman-edge.conf has no [omarchy-aarch64] section." log "Adding the Omarchy ARM package repo" printf '\n%s\n' "$block" | sudo tee -a /etc/pacman.conf >/dev/null @@ -267,12 +274,18 @@ seed_user_defaults() { run_system_setup() { log "Running Omarchy system setup" - sudo omarchy-apply-system --install-user "$USER" --first-install + if [[ -n $install_channel ]]; then + sudo env OMARCHY_MIRROR="$install_channel" OMARCHY_PRESERVE_PACMAN_CONFIG=1 omarchy-apply-system --install-user "$USER" --first-install + else + sudo omarchy-apply-system --install-user "$USER" --first-install + fi # System setup restores pacman.conf and can introduce repositories absent # from the starting image. Trust their keys and refresh with a full upgrade # before user setup installs packages, retaining the explicit edge stack. - ensure_arm_package_repo + if [[ -z $install_channel ]]; then + ensure_arm_package_repo + fi log "Running Omarchy user setup" omarchy-provision-user --first-install @@ -300,18 +313,86 @@ snapshot_factory_baseline() { sudo rmdir "$top" } +parse_install_options() { + while (( $# )); do + case "$1" in + --channel) + (( $# >= 2 )) || fail "--channel needs stable, rc, or edge" + install_channel="$2" + shift 2 + ;; + *) fail "Unknown installer argument: $1" ;; + esac + done + case "$install_channel" in "" | stable | rc | edge) ;; *) fail "Invalid channel: $install_channel" ;; esac +} + +verify_published_pair() { + [[ -n $channel_stage ]] || return 0 + local name expected actual + expected=$(<"$channel_stage/pair-version") + for name in omarchy omarchy-settings; do + actual=$(pacman -Q "$name") || return + [[ $actual == "$name $expected" ]] || fail "Setup changed the preflighted package pair: $actual (expected $expected)" + done +} + +protect_published_pair() { + # Defaults and optional AUR setup remain rolling. Ignore the captured pair + # during that phase, including package helpers run by system/user setup. + awk '{ print; if ($0 ~ /^[[:space:]]*\[options\][[:space:]]*$/) print "IgnorePkg = omarchy omarchy-settings # omarchy-install-pair" }' /etc/pacman.conf >"$channel_stage/protected.conf" + sudo install -m 644 "$channel_stage/protected.conf" /etc/pacman.conf +} + +unprotect_published_pair() { + if [[ -n $channel_stage && -f $channel_stage/protected.conf ]]; then + # Remove only our temporary pin, retaining any administrator changes. + sed '/^IgnorePkg = omarchy omarchy-settings # omarchy-install-pair$/d' /etc/pacman.conf >"$channel_stage/unpinned.conf" + sudo install -m 644 "$channel_stage/unpinned.conf" /etc/pacman.conf + rm "$channel_stage/protected.conf" + fi +} + +cleanup_channel_install() { + if [[ -n $channel_stage ]]; then + unprotect_published_pair + omarchy_arm_channel_stage_remove "$channel_stage" + fi +} + main() { + parse_install_options "$@" check_preconditions - ensure_utf8_locale - ensure_arm_package_repo - ensure_gum - ensure_aur_helper - ensure_package_sources - build_omarchy_packages - install_omarchy_packages + if [[ -n $install_channel ]]; then + channel_stage=$(omarchy_arm_channel_stage_new) + trap cleanup_channel_install EXIT + # Availability, resolution and signature checks precede locale or system + # changes. Apply exactly the captured published pair and dependencies. + omarchy_arm_channel_prepare "$channel_stage" "$install_channel" fresh + ensure_utf8_locale + omarchy_arm_channel_apply_prepared "$channel_stage" + load_installed_environment + protect_published_pair + # Optional package setup uses the live keyring after the accepted core + # transaction. Establish the same declared stack signer there now. + omarchy_arm_prepare_package_sources + ensure_gum + ensure_aur_helper + else + ensure_utf8_locale + ensure_arm_package_repo + ensure_gum + ensure_aur_helper + ensure_package_sources + build_omarchy_packages + install_omarchy_packages + fi install_default_package_set + verify_published_pair seed_user_defaults run_system_setup + verify_published_pair + unprotect_published_pair snapshot_factory_baseline log "Install complete. Reboot to start Omarchy." diff --git a/install/helpers/arm-channel.sh b/install/helpers/arm-channel.sh index dcaeaf51387..8dd19e9d1e2 100644 --- a/install/helpers/arm-channel.sh +++ b/install/helpers/arm-channel.sh @@ -20,8 +20,17 @@ omarchy_arm_channel_current() { } omarchy_arm_channel_render() { - local config="$1" channel="$2" output="$3" + local config="$1" channel="$2" output="$3" allow_new="${4:-}" case "$channel" in stable | rc | edge) ;; *) echo "Invalid ARM package channel: $channel" >&2; return 1 ;; esac + if [[ $allow_new == "fresh" ]] && ! grep -qE '^[[:space:]]*\[omarchy-aarch64\]' "$config"; then + if pacman-conf --config "$config" --repo-list | grep -qxF omarchy-aarch64; then + echo "A managed ARM repository is hidden in an Include; configure its lane explicitly." >&2 + return 1 + fi + cat "$config" >"$output" + printf '\n[omarchy-aarch64]\nSigLevel = Optional TrustAll\nServer = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/%s\n' "$channel" >>"$output" + return + fi if ! omarchy_arm_channel_current "$config" >/dev/null; then echo "Cannot switch a custom or ambiguous ARM repository. Keep the current configuration and configure its lane explicitly." >&2 return 1 @@ -33,31 +42,98 @@ omarchy_arm_channel_render() { ' "$config" >"$output" } -# Called inside omarchy-update's lock/snapshot boundary. The installing -# transaction keeps libalpm's sysupgrade/replacement/reason semantics, but uses -# captured repository databases and verified archives instead of mutable feeds. -omarchy_arm_channel_apply() ( - set -euo pipefail - local channel="$1" config="${OMARCHY_PACMAN_CONFIG:-/etc/pacman.conf}" - local scratch="${TMPDIR:-/var/tmp}" stage dbpath repo name version filename hash size extra pair_version="" - local required available archive cache - local -a targets caches +# Keep large captured transactions on persistent disk, independent of /tmp. +omarchy_arm_channel_stage_new() { + local scratch="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/channels" + mkdir -p "$scratch" || return case $(findmnt -n -o FSTYPE -T "$scratch") in - "" | tmpfs | ramfs) echo "ARM channel staging needs a disk-backed temporary directory." >&2; return 1 ;; + "" | tmpfs | ramfs) echo "ARM channel staging needs a disk-backed cache directory." >&2; return 1 ;; esac - stage=$(mktemp -d "$scratch/omarchy-channel.XXXXXXXX") - trap 'sudo rm -rf -- "$stage"' EXIT - # The pacman downloader runs as DownloadUser and must read local repo files. + mktemp -d "$scratch/transaction.XXXXXXXX" +} + +omarchy_arm_channel_stage_remove() { + local stage="$1" + # A private keyring may start its own agent; never address the host agent. + sudo gpgconf --homedir "$stage/keyring" --kill gpg-agent 2>/dev/null || true + sudo rm -rf -- "$stage/keyring/private-keys-v1.d" + if ! omarchy_arm_channel_restore_sync "$stage"; then + echo "Retaining transaction recovery files in $stage" >&2 + return 1 + fi + sudo rm -rf -- "$stage" +} + +# The installing pacman must lock its real DBPath. If it fails after the +# captured sync, compensate only our sync cache, under that same lock and only +# if nobody has changed it since capture. Installed packages are not rolled back. +omarchy_arm_channel_restore_sync() { + local stage="$1" + [[ -f $stage/restore-sync ]] || return 0 + local dbpath + dbpath=$(pacman-conf --config "$stage/frozen.conf" DBPath) || return + sudo bash -euo pipefail -c ' + dbpath="$1"; stage="$2" + if ! (set -C; : >"$dbpath/db.lck") 2>/dev/null; then + echo "Cannot restore channel sync cache while another pacman holds its lock." >&2 + exit 1 + fi + cleanup() { rm -f "$dbpath/db.lck"; } + trap cleanup EXIT + if ! diff -qr "$dbpath/sync" "$stage/applied-sync" >/dev/null; then + echo "Sync databases changed independently; preserving them and the recovery backup." >&2 + exit 1 + fi + rm -rf "$dbpath/sync" + if [[ -d $stage/previous-sync ]]; then + cp -a "$stage/previous-sync" "$dbpath/sync" + fi + rm "$stage/restore-sync" + ' bash "$dbpath" "$stage" +} + +omarchy_arm_channel_key_fingerprints() { + sudo gpg --homedir "$1" --batch --with-colons --list-keys 2>/dev/null | + awk -F: '$1 == "fpr" { print $10 }' | sort +} + +# Preflight has no installed-package/config/keyring side effects. The caller +# retains this directory until applying or abandoning the captured transaction. +omarchy_arm_channel_prepare() { + local stage="$1" channel="$2" allow_new="${3:-}" + local config="${OMARCHY_PACMAN_CONFIG:-/etc/pacman.conf}" + local dbpath repo name version filename hash size extra pair_version="" + local required available archive cache gpgdir keyfile + local -a targets=() caches=() chmod 755 "$stage" mkdir -m 755 "$stage/db" "$stage/cache" "$stage/repos" cp "$config" "$stage/original.conf" - omarchy_arm_channel_render "$config" "$channel" "$stage/lane.conf" + omarchy_arm_channel_render "$config" "$channel" "$stage/lane.conf" "$allow_new" omarchy_arm_render_package_sources "$stage/lane.conf" >"$stage/source.conf" pacman-conf --config "$stage/source.conf" >"$stage/resolved.conf" dbpath=$(pacman-conf --config "$stage/source.conf" DBPath) sudo cp -a "$dbpath/local" "$stage/db/local" - local -a probe=(--config "$stage/resolved.conf" --dbpath "$stage/db" --cachedir "$stage/cache" --logfile "$stage/preflight.log") + # Verification may import keys even during download-only. Give libalpm a + # private copy of public trust, never the live keyring or its secret keys. + gpgdir=$(pacman-conf --config "$stage/resolved.conf" GPGDir) + sudo install -d -m 700 "$stage/keyring" + for keyfile in pubring.gpg pubring.kbx trustdb.gpg gpg.conf; do + if sudo test -f "$gpgdir/$keyfile"; then + sudo cp -p "$gpgdir/$keyfile" "$stage/keyring/$keyfile" + fi + done + # Fresh bases may lack the declared upstream stack signer. Bootstrap only + # that exact fingerprint into private trust, using an ephemeral local signer. + local key="40DFB630FF42BCFFB047046CF0134EE680CAC571" + if [[ $allow_new == "fresh" ]] && ! sudo gpg --homedir "$stage/keyring" --batch --list-keys "$key" >/dev/null 2>&1; then + sudo pacman-key --gpgdir "$stage/keyring" --init + sudo pacman-key --gpgdir "$stage/keyring" --recv-keys "$key" --keyserver hkps://keys.openpgp.org + omarchy_arm_channel_key_fingerprints "$stage/keyring" | grep -qxF "$key" || return 1 + sudo pacman-key --gpgdir "$stage/keyring" --lsign-key "$key" + fi + omarchy_arm_channel_key_fingerprints "$stage/keyring" >"$stage/keys-before" + local -a probe=(--config "$stage/resolved.conf" --dbpath "$stage/db" --cachedir "$stage/cache" --gpgdir "$stage/keyring" --logfile "$stage/preflight.log") sudo env OMARCHY_UPDATE_PACMAN=1 pacman "${probe[@]}" -Sy --noconfirm sudo pacman "${probe[@]}" -Sl omarchy-aarch64 >"$stage/lane-packages" for name in omarchy omarchy-settings; do @@ -83,6 +159,11 @@ omarchy_arm_channel_apply() ( # Download-only verifies the configured signature policy without installing # a keyring or changing any installed package. Missing trust fails here. sudo env OMARCHY_UPDATE_PACMAN=1 pacman "${probe[@]}" -Suw --needed --noconfirm --ask 4 "${targets[@]}" + omarchy_arm_channel_key_fingerprints "$stage/keyring" >"$stage/keys-after" + if ! cmp -s "$stage/keys-before" "$stage/keys-after"; then + echo "Preflight required an undeclared signing key. Current configuration and live keyring are unchanged." >&2 + return 1 + fi caches=("$stage/cache") while read -r cache; do caches+=("$cache"); done < <(pacman-conf --config "$stage/resolved.conf" CacheDir) @@ -120,7 +201,8 @@ omarchy_arm_channel_apply() ( # Flattened options retain the real root/db/keyring, Includes have already # been resolved, and every repository now has exactly one local server. # A custom transfer command must not turn file:// back into a network fetch. - awk -v base="$stage/repos" ' + awk -v base="$stage/repos" -v keyring="$stage/keyring" ' + /^[[:space:]]*GPGDir[[:space:]]*=/ { print "GPGDir = " keyring; next } /^[[:space:]]*(Server|CacheServer|XferCommand)[[:space:]]*=/ { next } /^\[/ { print @@ -129,21 +211,49 @@ omarchy_arm_channel_apply() ( } { print } ' "$stage/resolved.conf" >"$stage/frozen.conf" + printf '%s\n' "$config" >"$stage/config-path" + printf '%s\n' "$channel" >"$stage/channel" + printf '%s\n' "$pair_version" >"$stage/pair-version" + printf '%s\n' "${targets[@]}" >"$stage/targets" +} + +# Called within the updater's existing lock/snapshot boundary, or by a fresh +# installer after preflight. Preserve libalpm sysupgrade and replace semantics. +omarchy_arm_channel_apply_prepared() { + local stage="$1" config channel pair_version dbpath sync_status=0 + local format='%r %n %v %f %h %s' + local -a targets + config=$(<"$stage/config-path") + channel=$(<"$stage/channel") + pair_version=$(<"$stage/pair-version") + mapfile -t targets <"$stage/targets" if ! cmp -s "$config" "$stage/original.conf"; then echo "pacman.conf changed during channel preparation. Preserving it; retry after reviewing the change." >&2 return 1 fi # A different lane may have an equal or older database timestamp. Force the # captured database into the real sync cache before comparing transactions. - sudo env OMARCHY_UPDATE_PACMAN=1 pacman --config "$stage/frozen.conf" -Syy --noconfirm + dbpath=$(pacman-conf --config "$stage/frozen.conf" DBPath) + if [[ -d $dbpath/sync ]]; then + sudo cp -a "$dbpath/sync" "$stage/previous-sync" + fi + sudo env OMARCHY_UPDATE_PACMAN=1 pacman --config "$stage/frozen.conf" -Syy --noconfirm || sync_status=$? + sudo cp -a "$dbpath/sync" "$stage/applied-sync" + touch "$stage/restore-sync" + (( sync_status == 0 )) || return "$sync_status" sudo pacman --config "$stage/frozen.conf" -Sup --needed --noconfirm --ask 4 --print-format "$format" "${targets[@]}" >"$stage/actual" if ! diff -u "$stage/expected" "$stage/actual"; then echo "Installed package state changed during channel preparation. Retry; the active configuration is unchanged." >&2 return 1 fi - if ! sudo env OMARCHY_UPDATE_PACMAN=1 pacman --config "$stage/frozen.conf" -Syu --needed --noconfirm --ask 4 "${targets[@]}"; then + if ! sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman --config "$stage/frozen.conf" -Syu --needed --noconfirm --ask 4 "${targets[@]}" 2>&1 | tee "$stage/transaction-output"; then echo "Channel transaction failed; no new channel configuration was committed. Package hooks may have run; installed pair:" >&2 - pacman -Q omarchy omarchy-settings >&2 || true + pacman --config "$stage/frozen.conf" -Q omarchy omarchy-settings >&2 || true + return 1 + fi + # libalpm may exit zero after a failed post-transaction hook. + if grep -q '^error:' "$stage/transaction-output"; then + echo "Packages may be installed, but pacman reported a transaction/hook error. Channel configuration was not committed; inspect the output and installed state." >&2 return 1 fi if ! cmp -s "$config" "$stage/original.conf"; then @@ -152,6 +262,16 @@ omarchy_arm_channel_apply() ( fi sudo cp -p "$config" "$config.bak" sudo install -m 644 "$stage/source.conf" "$config" + rm "$stage/restore-sync" echo "ARM package channel is now $channel ($pair_version)." echo "The selected upstream graphics stack and distribution dependencies were resolved at transaction time." +} + +omarchy_arm_channel_apply() ( + set -euo pipefail + local stage + stage=$(omarchy_arm_channel_stage_new) + trap 'omarchy_arm_channel_stage_remove "$stage"' EXIT + omarchy_arm_channel_prepare "$stage" "$1" + omarchy_arm_channel_apply_prepared "$stage" ) diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index 8becfd87008..76ac9951015 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -1,15 +1,23 @@ # Configure pacman after package installation completes. Offline target package # installs use the live ISO's offline pacman.conf until this final restore. -cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf +pacman_mirror="${OMARCHY_MIRROR:-stable}" +if [[ $(uname -m) == "aarch64" && -z ${OMARCHY_MIRROR:-} ]]; then + pacman_mirror=edge +fi +# The explicit source installer already validated and installed this config. +# Preserve its custom repository order and temporary package-pair protection. +if [[ ${OMARCHY_PRESERVE_PACMAN_CONFIG:-0} != "1" ]]; then + cp -f "$OMARCHY_PATH/default/pacman/pacman-$pacman_mirror.conf" /etc/pacman.conf +fi # Overwriting the mirrorlist throws away the Asahi Alarm mirrors the machine # was installed with, leaving one slow generic server. Keep what is there and # append ours only where it is missing, as omarchy-refresh-pacman-mirrorlist does. if [[ -s /etc/pacman.d/mirrorlist ]] && grep -qE '^[[:space:]]*Server[[:space:]]*=' /etc/pacman.d/mirrorlist; then while read -r mirror; do grep -qxF "$mirror" /etc/pacman.d/mirrorlist || printf '%s\n' "$mirror" >>/etc/pacman.d/mirrorlist - done < <(grep -E '^[[:space:]]*Server[[:space:]]*=' "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}") + done < <(grep -E '^[[:space:]]*Server[[:space:]]*=' "$OMARCHY_PATH/default/pacman/mirrorlist-$pacman_mirror") else - cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist + cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$pacman_mirror" /etc/pacman.d/mirrorlist fi # Every pacman.conf variant here Includes the asahi-alarm mirrorlist, so ship it diff --git a/test/shell.d/arm-channel-test.sh b/test/shell.d/arm-channel-test.sh index bbbebee0a45..3e641dfbad4 100644 --- a/test/shell.d/arm-channel-test.sh +++ b/test/shell.d/arm-channel-test.sh @@ -48,6 +48,18 @@ for layout in custom-server include duplicate missing; do done pass 'custom and ambiguous ARM repository layouts fail without modification' +printf '%s\n' '[options]' 'Architecture = aarch64' '[extra]' 'Server = https://regular.example/$arch' >"$test_tmp/fresh" +cp "$test_tmp/fresh" "$test_tmp/fresh-before" +omarchy_arm_channel_render "$test_tmp/fresh" rc "$test_tmp/fresh-rendered" fresh +[[ $(omarchy_arm_channel_current "$test_tmp/fresh-rendered") == rc ]] || fail 'fresh candidate adds explicit RC lane' +cmp "$test_tmp/fresh" "$test_tmp/fresh-before" || fail 'fresh render preserves active configuration' +printf '%s\n' '[omarchy-aarch64]' 'Server = https://custom.example/repo' >"$test_tmp/hidden" +printf 'Include = %s\n' "$test_tmp/hidden" >>"$test_tmp/fresh" +if omarchy_arm_channel_render "$test_tmp/fresh" rc "$test_tmp/rejected" fresh >/dev/null 2>&1; then + fail 'fresh installer must not shadow a hidden custom ARM repository' +fi +pass 'fresh candidates add a missing lane but reject hidden custom repositories' + printf '#!/bin/bash\necho aarch64\n' >"$test_tmp/bin/uname" cat >"$test_tmp/bin/omarchy-update" <<'SH' #!/bin/bash diff --git a/test/shell.d/arm-channel-transaction-test.sh b/test/shell.d/arm-channel-transaction-test.sh index 2d457ac1aca..9fb40dc8c43 100644 --- a/test/shell.d/arm-channel-transaction-test.sh +++ b/test/shell.d/arm-channel-transaction-test.sh @@ -19,7 +19,7 @@ root = pathlib.Path(os.environ['CHANNEL_TEST_ROOT']) work = pathlib.Path(os.environ['CHANNEL_TEST_STORAGE']) lanes = work / 'lanes' guest = work / 'guest' -for d in ['db/local', 'cache', 'etc', 'hooks', 'log']: +for d in ['db/local', 'cache', 'etc', 'hooks', 'log', 'keyring']: (guest / d).mkdir(parents=True) (guest / 'db/local/ALPM_DB_VERSION').write_text('9\n') for d in ['stable', 'rc', 'edge', 'regular', 'graphics', 'baseline']: @@ -58,6 +58,23 @@ for name in ['aquamarine', 'ordinary', 'newlib']: pkg('regular', 'new-widget', '2-1', 'replaces = old-widget', 'conflict = old-widget') run(['repo-add', str(lanes / 'regular/extra.db.tar.gz'), *map(str, (lanes / 'regular').glob('*.pkg.tar.zst'))]) +# A real local signing key verifies that preflight and final install can use +# copied public trust without copying the source secret key or mutating it. +# The runner maps this verified disk directory at /tmp too; use its shorter +# alias so Unix agent socket names remain below sockaddr_un limits. +keyring = pathlib.Path('/tmp') / work.name / 'guest/keyring' +assert keyring.samefile(guest / 'keyring'), 'contained runner must bind its disk TMPDIR at /tmp' +keyring.chmod(0o700) +gpg = ['gpg', '--homedir', str(keyring), '--batch', '--pinentry-mode', 'loopback', '--passphrase', ''] +run([*gpg, '--quick-generate-key', 'Channel fixture ', 'ed25519', 'sign', '0']) +for lane in ['stable', 'rc', 'edge']: + archives = list((lanes / lane).glob('*.pkg.tar.zst')) + for archive in archives: + run([*gpg, '--detach-sign', str(archive)]) + run(['repo-add', str(lanes / lane / 'omarchy-aarch64.db.tar.gz'), *map(str, archives)]) +run(['gpgconf', '--homedir', str(keyring), '--kill', 'gpg-agent']) +key_files = {p.name: p.read_bytes() for p in keyring.iterdir() if p.is_file()} + transport = work / 'transport.py' transport.write_text('''import os, pathlib, shutil, sys base = pathlib.Path(sys.argv[1]); url, dest = sys.argv[2:] @@ -81,6 +98,7 @@ DBPath = {guest}/db CacheDir = {guest}/cache LogFile = {guest}/log/pacman.log HookDir = {guest}/hooks +GPGDir = {keyring} Architecture = aarch64 SigLevel = Never LocalFileSigLevel = Never @@ -88,13 +106,16 @@ XferCommand = /usr/bin/python3 {transport} {lanes} %u %o [extra] Server = https://regular.invalid/$repo/$arch [omarchy-aarch64] -SigLevel = Never +SigLevel = Required DatabaseOptional Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/edge ''') pacman = ['pacman', '--config', str(config)] run([*pacman, '-U', '--noconfirm', *map(str, base)]) run([*pacman, '-D', '--asdeps', 'aquamarine']) original = config.read_bytes() +def sync_state(): + return {p.relative_to(guest / 'db/sync').as_posix(): p.read_bytes() for p in (guest / 'db/sync').glob('**/*') if p.is_file()} +original_sync = sync_state() stub = work / 'bin'; stub.mkdir() (stub / 'sudo').write_text('#!/bin/bash\nexec "$@"\n') (stub / 'sudo').chmod(0o755) @@ -154,6 +175,7 @@ collision.write_text('administrator file\n') failure_output = channel('rc', False) assert config.read_bytes() == original and run([*pacman, '-Q']) == before, failure_output + '\nBEFORE:\n' + before + '\nAFTER:\n' + run([*pacman, '-Q']) assert collision.read_text() == 'administrator file\n' +assert sync_state() == original_sync, 'failed transaction must restore preexisting sync databases\n' + failure_output collision.unlink() print('ok - a real file-conflict transaction failure preserves both packages and active configuration') @@ -182,4 +204,26 @@ assert 'download/stable' in config.read_text() assert run([*pacman, '-Q', 'omarchy', 'omarchy-settings']).splitlines() == ['omarchy 4.0.2-2', 'omarchy-settings 4.0.2-2'] assert 'ordinary 2-1' in run([*pacman, '-Q', 'ordinary']) print('ok - rc to stable downgrades only the explicit pair while retaining the upgraded distribution stack') +assert all((keyring / name).read_bytes() == data for name, data in key_files.items()) +print('ok - signed package preflight and installation preserve original public trust and secret keys') + +# libalpm can return zero after a failed post-transaction hook. The hook runs +# inside the synthetic RootDir; its intentionally absent executable is inert. +(guest / 'hooks/99-fixture-fail.hook').write_text('''[Trigger] +Operation = Upgrade +Type = Package +Target = omarchy +[Action] +Description = Fixture posttransaction failure +When = PostTransaction +Exec = /fixture-does-not-exist +''') +previous_config = config.read_bytes() +previous_sync = sync_state() +output = channel('rc', False) +assert config.read_bytes() == previous_config +assert sync_state() == previous_sync, 'hook failure must restore original lane sync databases' +assert run([*pacman, '-Q', 'omarchy']).strip() == 'omarchy 4.0.3rc1-1' +assert 'transaction/hook error' in output, output +print('ok - posttransaction hook failure reports partial state and does not commit channel success') PY diff --git a/test/shell.d/install-channel-test.sh b/test/shell.d/install-channel-test.sh new file mode 100755 index 00000000000..5db99052f3f --- /dev/null +++ b/test/shell.d/install-channel-test.sh @@ -0,0 +1,83 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/base-test.sh" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# Execute real main/option parsing with inert leaf stubs. In particular, never +# source an installed environment or run a real updater in these fixtures. +python3 - "$ROOT/install.sh" "$work/functions" <<'PY' +import re, sys +text = open(sys.argv[1]).read() +names = ['main', 'parse_install_options', 'verify_published_pair', 'run_system_setup'] +open(sys.argv[2], 'w').write('\n'.join(re.search(r'^' + name + r'\(\) \{.*?^}', text, re.M | re.S)[0] for name in names)) +PY +cat >"$work/driver" <<'DRIVER' +set -euo pipefail +source "$FUNCTIONS" +install_channel="${CHANNEL:-}" +channel_stage="" +log() { :; } +fail() { echo "$*" >&2; exit 1; } +step() { echo "$*" >>"$CALLS"; [[ ${FAIL_AT:-} != "$1" ]]; } +check_preconditions() { step preconditions; } +omarchy_arm_channel_stage_new() { echo "$STAGE"; } +omarchy_arm_channel_prepare() { step "prepare $2 $3"; printf '4.0.3rc1-1\n' >"$1/pair-version"; } +omarchy_arm_channel_apply_prepared() { step apply; } +cleanup_channel_install() { step cleanup; } +ensure_utf8_locale() { step locale; } +load_installed_environment() { step environment; } +protect_published_pair() { step protect; } +unprotect_published_pair() { step unprotect; } +omarchy_arm_prepare_package_sources() { step trust; } +ensure_arm_package_repo() { step repositories; } +ensure_gum() { step gum; } +ensure_aur_helper() { step aur; } +ensure_package_sources() { step recipes; } +build_omarchy_packages() { step build; } +install_omarchy_packages() { step local-install; } +install_default_package_set() { step defaults; } +seed_user_defaults() { step seed; } +run_system_setup() { step setup; } +snapshot_factory_baseline() { step snapshot; } +pacman() { echo "$2 ${PAIR_VERSION:-4.0.3rc1-1}"; } +main "$@" +DRIVER +export FUNCTIONS="$work/functions" STAGE="$work/stage" CALLS="$work/calls" +mkdir "$STAGE" +run_case() { + : >"$CALLS" + bash "$work/driver" "$@" >"$work/out" 2>&1 +} +run_case --channel rc || fail 'published RC orchestration' +[[ $(cat "$CALLS") == $'preconditions\nprepare rc fresh\nlocale\napply\nenvironment\nprotect\ntrust\ngum\naur\ndefaults\nseed\nsetup\nunprotect\nsnapshot\ncleanup' ]] || fail 'published preflight precedes mutations and never builds different bytes' +pass 'explicit RC installs the preflighted pair and bypasses local builds' +FAIL_AT='prepare rc fresh' run_case --channel rc && fail 'failed preflight must stop' +[[ $(cat "$CALLS") == $'preconditions\nprepare rc fresh\ncleanup' ]] || fail 'failed preflight leaves locale and package state untouched' +pass 'missing or invalid lane stops before system mutation' +FAIL_AT=apply run_case --channel stable && fail 'failed captured transaction must stop' +[[ $(cat "$CALLS") == $'preconditions\nprepare stable fresh\nlocale\napply\ncleanup' ]] || fail 'failed captured transaction skips subsequent setup' +PAIR_VERSION=4.0.3rc2-1 run_case --channel rc && fail 'pair changed by default phase must fail' +! grep -q '^setup$' "$CALLS" || fail 'changed pair aborts before setup/snapshot' +pass 'transaction failure or pair drift cannot report completed install' +run_case || fail 'legacy source install' +grep -q '^build$' "$CALLS" || fail 'legacy installer still builds checkout' +! grep -q '^prepare' "$CALLS" || fail 'legacy installer does not switch to an unavailable stable lane' +CHANNEL=edge run_case || fail 'environment lane selection' +grep -qx 'prepare edge fresh' "$CALLS" || fail 'OMARCHY_MIRROR lane interface' +run_case --channel bogus && fail 'invalid lane must fail' +[[ ! -s $CALLS ]] || fail 'invalid option must not reach preconditions' +pass 'legacy source build and explicit environment lane contracts remain distinct' +setup_output=$(FUNCTIONS="$work/functions" bash -euo pipefail -c ' + source "$FUNCTIONS" + install_channel=rc USER=fixture + log() { :; } + sudo() { + [[ $* == "env OMARCHY_MIRROR=rc OMARCHY_PRESERVE_PACMAN_CONFIG=1 omarchy-apply-system --install-user fixture --first-install" ]] + echo system + } + ensure_arm_package_repo() { echo unexpected-refresh; exit 1; } + omarchy-provision-user() { [[ $* == "--first-install" ]]; echo user; } + run_system_setup +') || fail 'published setup environment propagation' +[[ $setup_output == $'system\nuser' ]] || fail 'published setup preserves candidate config without a second system transaction' +pass 'published system setup preserves the staged lane and package-pair protection' diff --git a/test/shell.d/install-mac-test.sh b/test/shell.d/install-mac-test.sh index 1067348521e..51bf015b197 100755 --- a/test/shell.d/install-mac-test.sh +++ b/test/shell.d/install-mac-test.sh @@ -105,7 +105,7 @@ pass "refreshing limine no-ops on a machine without limine" # the install looks nothing like the rest of Omarchy until its last stretch. grep -qF 'ensure_gum' "$install_script" || fail "the installer installs gum up front" -gum_call=$(grep -n '^ ensure_gum$' "$install_script" | cut -d: -f1) +gum_call=$(grep -n '^[[:space:]]*ensure_gum$' "$install_script" | tail -1 | cut -d: -f1) set_call=$(grep -n '^ install_default_package_set$' "$install_script" | cut -d: -f1) [[ -n $gum_call && -n $set_call ]] || fail "the installer installs gum and the package set" (( gum_call < set_call )) || fail "gum is installed before the long package phase" @@ -131,10 +131,10 @@ refresh_call=$(grep -nF ' sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --needed fail "the Asahi keyring is installed before the package database refresh" pass "the installer bootstraps Asahi signing keys before refreshing ARM packages" -repo_call=$(sed -n '/^main() {/,/^}/p' "$install_script" | grep -n '^ ensure_arm_package_repo$' | cut -d: -f1) +repo_call=$(sed -n '/^main() {/,/^}/p' "$install_script" | grep -n '^[[:space:]]*ensure_arm_package_repo$' | cut -d: -f1) main_line=$(grep -n '^main() {$' "$install_script" | cut -d: -f1) repo_call=$(( main_line + repo_call - 1 )) -build_call=$(grep -n '^ build_omarchy_packages$' "$install_script" | cut -d: -f1) +build_call=$(grep -n '^[[:space:]]*build_omarchy_packages$' "$install_script" | cut -d: -f1) [[ -n $repo_call && -n $build_call ]] || fail "the installer prepares repositories before package builds" (( repo_call < build_call && repo_call < gum_call )) || fail "the compatible stack transaction precedes package operations" grep -qF 'source "$checkout/install/helpers/arm-package-sources.sh"' "$install_script" || fail "the installer uses shared source policy" @@ -147,6 +147,8 @@ for failing_stage in none system repositories; do setup_status=0 setup_output=$(SETUP_BODY="$setup_body" FAILING_STAGE="$failing_stage" bash -c ' set -euo pipefail + USER=fixture + install_channel="" log() { :; } sudo() { [[ $* == "omarchy-apply-system --install-user $USER --first-install" ]] From 86ec160884f8d88a7516509aea7b288dd20bcf7f Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 09:33:40 +0530 Subject: [PATCH 09/27] Deliver ARM defaults from their available package sources --- docs/arm-package-sources.md | 6 ++++- install.sh | 5 ++-- install/helpers/arm-package-sources.sh | 25 +++++++++++++++----- install/omarchy-aarch64-unavailable.packages | 5 ++++ test/shell.d/arm-package-sources-test.sh | 7 +++++- test/shell.d/arm-package-transaction-test.sh | 9 ++++--- 6 files changed, 44 insertions(+), 13 deletions(-) diff --git a/docs/arm-package-sources.md b/docs/arm-package-sources.md index a9a8e3f73ee..4c69a376ad9 100644 --- a/docs/arm-package-sources.md +++ b/docs/arm-package-sources.md @@ -2,7 +2,7 @@ Apple Silicon installations use the regular Arch Linux ARM, Asahi Alarm, and Mac package repositories. The official `https://pkgs.omarchy.org/edge/$arch` repository has `Usage = Sync`, so it is refreshed but excluded from automatic package selection and upgrades. -The installer, system updater, and pacman channel refresh explicitly select `omarchy/hyprland`, `omarchy/hyprtoolkit`, and `omarchy/hyprland-guiutils` alongside a full system upgrade. Aquamarine and other dependencies resolve from the regular repositories. Dependency failures stop the transaction; no packages are ignored or dependencies bypassed. +The installer, system updater, and pacman channel refresh explicitly select `omarchy/hyprland`, `omarchy/hyprtoolkit`, and `omarchy/hyprland-guiutils` alongside a full system upgrade. Fresh defaults explicitly request `omarchy/asdcontrol` and `omarchy/tobi-try`, which are absent from the regular ARM repositories. Updates include those source-qualified targets only while the apps are installed, preserving intentional removals. Aquamarine and other dependencies resolve from the regular repositories. Dependency failures stop the transaction; no packages are ignored or dependencies bypassed. The shared policy lives in `install/helpers/arm-package-sources.sh`. Package signatures are required and the existing Omarchy signing key is imported by its full fingerprint. Repository configuration preserves other repositories and mirror choices, saving `/etc/pacman.conf.bak` when it changes. @@ -30,6 +30,10 @@ The captured core/system transaction is followed by the ordinary rolling default Without a channel argument or `OMARCHY_MIRROR`, the source installer retains its checkout-build behavior and legacy `/edge` repository default. Existing clients and 3-to-4 bootstraps are not silently redirected to an unpublished `/stable` lane. +The ARM default name `nvim` maps to the real `neovim` package (also required by `omarchy-nvim`). `qemu-user-static-binfmt` is reported as unavailable: the current Arch Linux ARM QEMU build removes static binaries, and dynamic `qemu-user-binfmt` does not preserve foreign-root execution semantics. Existing static packages are not removed or replaced. Static cross-architecture execution remains a qualification gap. + +Earlier default installation attempts did not record durable failure receipts. A missing `asdcontrol` or `tobi-try` therefore cannot be distinguished from an intentional removal; no migration reinstalls absent optional apps. Users who want them can request `yay -S omarchy/asdcontrol omarchy/tobi-try`. Already installed copies receive the new source-selection behavior through the packaged updater on its next run. + ## Recovering an install that predates this policy The policy travels inside the `omarchy` package, and both places that apply it — the installer and the update commands — are out of reach on a machine installed before it. The installer is over, and the update aborts in dependency resolution before the package carrying the helper can be replaced, so the machine cannot upgrade its way to the fix. Such a machine reports: diff --git a/install.sh b/install.sh index f329e21842f..cfe0b4bb2cf 100755 --- a/install.sh +++ b/install.sh @@ -228,7 +228,7 @@ package_is_unavailable_here() { } install_default_package_set() { - local package skipped=() unbuildable=() attempt_unavailable=0 + local package target skipped=() unbuildable=() attempt_unavailable=0 load_unavailable_packages if should_attempt_unavailable; then @@ -249,7 +249,8 @@ install_default_package_set() { unbuildable+=("$package") continue fi - yay -S --needed --noconfirm "$package" /dev/null 2>&1; then + targets+=("omarchy/$package") + fi + done + for target in "${targets[@]}"; do names+=("${target#*/}"); done local IFS=, - printf '%s\n' --ignore "${names[*]}" - omarchy_arm_package_targets + printf '%s\n' --ignore "${names[*]}" "${targets[@]}" +} + +omarchy_arm_default_package_target() { + case "$1" in + asdcontrol | tobi-try) printf 'omarchy/%s\n' "$1" ;; + nvim) printf '%s\n' neovim ;; + *) printf '%s\n' "$1" ;; + esac } omarchy_arm_package_is_selected() { diff --git a/install/omarchy-aarch64-unavailable.packages b/install/omarchy-aarch64-unavailable.packages index 85e9781c26c..74e0ae82f55 100644 --- a/install/omarchy-aarch64-unavailable.packages +++ b/install/omarchy-aarch64-unavailable.packages @@ -39,3 +39,8 @@ pinta # appimage variant does build here, so this is a naming problem rather than a # missing port: obsidian-appimage would work if it were asked for by name. obsidian + +# Arch Linux ARM explicitly removes the static QEMU build. Dynamic qemu-user +# and qemu-user-binfmt are not replacements for static foreign-root execution. +# Preserve any existing static installation; report this default as unavailable. +qemu-user-static-binfmt diff --git a/test/shell.d/arm-package-sources-test.sh b/test/shell.d/arm-package-sources-test.sh index 0c80fe5eca0..c57f381b757 100644 --- a/test/shell.d/arm-package-sources-test.sh +++ b/test/shell.d/arm-package-sources-test.sh @@ -56,7 +56,12 @@ for package in hyprland hyprtoolkit hyprland-guiutils; do ! grep -qE "(^| )$package( |$)" "$test_tmp/yay" || fail "$package must not be downgraded by yay" done grep -q 'wf-recorder' "$test_tmp/yay" || fail 'regular package path still runs' -pass 'default package loop preserves the compatibility transaction selection' +for package in asdcontrol tobi-try; do + grep -q -- "-S --needed --noconfirm omarchy/$package" "$test_tmp/yay" || fail "$package is explicitly sourced from the only available repository" +done +grep -q -- '-S --needed --noconfirm neovim' "$test_tmp/yay" || fail 'ARM nvim default uses its real package name' +! grep -q -- '-S --needed --noconfirm nvim$' "$test_tmp/yay" || fail 'invalid nvim package name is not requested' +pass 'default package loop preserves the compatibility transaction selection and delivers ARM-only defaults' for config in "$ROOT"/default/pacman/pacman*.conf; do section=$(sed -n '/^\[omarchy\]$/,/^$/p' "$config") diff --git a/test/shell.d/arm-package-transaction-test.sh b/test/shell.d/arm-package-transaction-test.sh index ee07ec47469..05b8cdbd7a7 100644 --- a/test/shell.d/arm-package-transaction-test.sh +++ b/test/shell.d/arm-package-transaction-test.sh @@ -25,7 +25,7 @@ write_package() { mkdir -p "$directory" write_desc "$2" "$3" > "$directory/desc" } -for package in hyprland hyprtoolkit hyprland-guiutils normal; do +for package in hyprland hyprtoolkit hyprland-guiutils asdcontrol normal; do write_package "$test_tmp/db/local" "$package" '2-1' : > "$test_tmp/db/local/$package-2-1/files" write_package "$test_tmp/extra" "$package" '4-1' @@ -48,15 +48,18 @@ select_packages() { pacman --config "$test_tmp/pacman.conf" -Sup --needed --noconfirm \ --print-format '%r/%n %v' "$@" 2> "$test_tmp/errors" } +export OMARCHY_PACMAN_CONFIG="$test_tmp/pacman.conf" mapfile -t targets < <(omarchy_arm_package_upgrade_args) for version in 2-1 3-1 1-1; do rm -rf "$test_tmp/omarchy" - for package in hyprland hyprtoolkit hyprland-guiutils; do + for package in hyprland hyprtoolkit hyprland-guiutils asdcontrol tobi-try; do write_package "$test_tmp/omarchy" "$package" "$version" done tar -czf "$test_tmp/db/sync/omarchy.db" -C "$test_tmp/omarchy" --transform='s|^\./||' . selected=$(select_packages "${targets[@]}") || fail 'pacman resolves the protected transaction' "$(cat "$test_tmp/errors")" grep -qx 'extra/normal 4-1' <<< "$selected" || fail 'ordinary packages still upgrade' + ! grep -q 'tobi-try' <<< "$selected" || fail 'removed optional defaults remain removed' + ! grep -q '^extra/asdcontrol' <<< "$selected" || fail 'installed upstream-only optional defaults retain their selected source' ! grep -q '^extra/hypr' <<< "$selected" || fail 'regular repository cannot replace the selected stack' if [[ $version == "2-1" ]]; then [[ $selected == 'extra/normal 4-1' ]] || fail 'unchanged compositor packages are not reinstalled' @@ -64,7 +67,7 @@ for version in 2-1 3-1 1-1; do baseline=$(select_packages "${unprotected[@]}") grep -qx 'extra/hyprtoolkit 4-1' <<< "$baseline" || fail 'fixture reproduces the original --needed sysupgrade bug' else - for package in hyprland hyprtoolkit hyprland-guiutils; do + for package in hyprland hyprtoolkit hyprland-guiutils asdcontrol; do grep -qx "omarchy/$package $version" <<< "$selected" || fail 'changed packages use the explicit repository, including downgrades' done fi From ce15680c02b457899bd9f18046812db2fa7ff52e Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 09:33:40 +0530 Subject: [PATCH 10/27] Prepare version 4.0.3rc1 --- version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version b/version index 4d54daddb61..f9c1d302a1d 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.0.2 +4.0.3rc1 From 14faf421e30959e073a4807990a3170fd7827fe2 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 09:39:58 +0530 Subject: [PATCH 11/27] Remove trailing whitespace from channel installer test --- test/shell.d/install-channel-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/shell.d/install-channel-test.sh b/test/shell.d/install-channel-test.sh index 5db99052f3f..d8dfd15ef05 100755 --- a/test/shell.d/install-channel-test.sh +++ b/test/shell.d/install-channel-test.sh @@ -51,7 +51,7 @@ run_case() { run_case --channel rc || fail 'published RC orchestration' [[ $(cat "$CALLS") == $'preconditions\nprepare rc fresh\nlocale\napply\nenvironment\nprotect\ntrust\ngum\naur\ndefaults\nseed\nsetup\nunprotect\nsnapshot\ncleanup' ]] || fail 'published preflight precedes mutations and never builds different bytes' pass 'explicit RC installs the preflighted pair and bypasses local builds' -FAIL_AT='prepare rc fresh' run_case --channel rc && fail 'failed preflight must stop' +FAIL_AT='prepare rc fresh' run_case --channel rc && fail 'failed preflight must stop' [[ $(cat "$CALLS") == $'preconditions\nprepare rc fresh\ncleanup' ]] || fail 'failed preflight leaves locale and package state untouched' pass 'missing or invalid lane stops before system mutation' FAIL_AT=apply run_case --channel stable && fail 'failed captured transaction must stop' From 198262cdf8d78dac17d1985fb37215d1bea4cb97 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 09:47:09 +0530 Subject: [PATCH 12/27] Align installer and update fixtures with channel orchestration --- test/shell.d/aarch64-packages-test.sh | 36 ++++++++++---- test/shell.d/helpers/install-orchestration.sh | 48 +++++++++++++++++++ test/shell.d/hermes-user-setup-test.sh | 3 +- test/shell.d/install-channel-test.sh | 46 +----------------- test/shell.d/locale-setup-test.sh | 34 +++++++++---- test/shell.d/update-package-conflict-test.sh | 8 +++- 6 files changed, 108 insertions(+), 67 deletions(-) create mode 100644 test/shell.d/helpers/install-orchestration.sh diff --git a/test/shell.d/aarch64-packages-test.sh b/test/shell.d/aarch64-packages-test.sh index f3168dc9991..a121f8fe2f9 100755 --- a/test/shell.d/aarch64-packages-test.sh +++ b/test/shell.d/aarch64-packages-test.sh @@ -55,16 +55,32 @@ for config in "$ROOT"/default/pacman/pacman*.conf; do done pass "every shipped pacman config offers the Omarchy ARM repo" -# The shipped config only reaches /etc during post-install, which runs after the -# package set. Adding the repo any later leaves herdr building zig from source -# for two hours, so the order in main() is the whole point of the fix. -install_main=$(sed -n '/^main() {/,/^}/p' "$ROOT/install.sh") -repo_call=$(grep -n '^ ensure_arm_package_repo$' <<<"$install_main" | cut -d: -f1) -set_call=$(grep -n '^ install_default_package_set$' <<<"$install_main" | cut -d: -f1) -[[ -n $repo_call && -n $set_call ]] || fail "the installer adds the ARM repo and installs the set" -(( repo_call < set_call )) || - fail "the ARM repo is added before the default package set is installed" -pass "the ARM repo is added before the default package set is installed" +# Both installer paths must establish a usable ARM repository before defaults +# can accidentally fall through to AUR builds. Exercise the real branching +# orchestration instead of matching source indentation. +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +work="$test_tmp/installer" +mkdir "$work" +source "$ROOT/test/shell.d/helpers/install-orchestration.sh" +for path in source channel; do + options=() repo_step=repositories + if [[ $path == "channel" ]]; then + options=(--channel rc) + repo_step=apply + fi + CHANNEL="" run_case "${options[@]}" || fail "$path installer orchestration" + awk -v repo_step="$repo_step" ' + $0 == repo_step { ready = 1 } + $0 == "defaults" { if (!ready) exit 1; defaults++ } + END { if (defaults != 1) exit 1 } + ' "$CALLS" || fail "$path installer establishes ARM packages before defaults" "$(cat "$CALLS")" + if CHANNEL="" FAIL_AT="$repo_step" run_case "${options[@]}"; then + fail "$path installer must stop when repository preparation fails" + fi + ! grep -qx defaults "$CALLS" || fail "$path repository failure cannot fall through to AUR defaults" + pass "$path installer establishes ARM packages before defaults and stops on repository failure" +done # The Quattro upgrade has the same trap with a twist: a 3.x machine's # /etc/pacman.conf predates the ARM repo entirely, and installing packages diff --git a/test/shell.d/helpers/install-orchestration.sh b/test/shell.d/helpers/install-orchestration.sh new file mode 100644 index 00000000000..e2c994076b6 --- /dev/null +++ b/test/shell.d/helpers/install-orchestration.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Shared inert installer driver. Caller provides ROOT and a disposable work directory. +# Execute real main/option parsing with inert leaf stubs. In particular, never +# source an installed environment or run a real updater in these fixtures. +python3 - "$ROOT/install.sh" "$work/functions" <<'PY' +import re, sys +text = open(sys.argv[1]).read() +names = ['main', 'parse_install_options', 'verify_published_pair', 'run_system_setup'] +open(sys.argv[2], 'w').write('\n'.join(re.search(r'^' + name + r'\(\) \{.*?^}', text, re.M | re.S)[0] for name in names)) +PY +cat >"$work/driver" <<'DRIVER' +set -euo pipefail +source "$FUNCTIONS" +install_channel="${CHANNEL:-}" +channel_stage="" +log() { :; } +fail() { echo "$*" >&2; exit 1; } +step() { echo "$*" >>"$CALLS"; [[ ${FAIL_AT:-} != "$1" ]]; } +check_preconditions() { step preconditions; } +omarchy_arm_channel_stage_new() { echo "$STAGE"; } +omarchy_arm_channel_prepare() { step "prepare $2 $3"; printf '4.0.3rc1-1\n' >"$1/pair-version"; } +omarchy_arm_channel_apply_prepared() { step apply; } +cleanup_channel_install() { step cleanup; } +ensure_utf8_locale() { step locale; } +load_installed_environment() { step environment; } +protect_published_pair() { step protect; } +unprotect_published_pair() { step unprotect; } +omarchy_arm_prepare_package_sources() { step trust; } +ensure_arm_package_repo() { step repositories; } +ensure_gum() { step gum; } +ensure_aur_helper() { step aur; } +ensure_package_sources() { step recipes; } +build_omarchy_packages() { step build; } +install_omarchy_packages() { step local-install; } +install_default_package_set() { step defaults; } +seed_user_defaults() { step seed; } +run_system_setup() { step setup; } +snapshot_factory_baseline() { step snapshot; } +pacman() { echo "$2 ${PAIR_VERSION:-4.0.3rc1-1}"; } +main "$@" +DRIVER +export FUNCTIONS="$work/functions" STAGE="$work/stage" CALLS="$work/calls" +mkdir "$STAGE" +run_case() { + : >"$CALLS" + bash "$work/driver" "$@" >"$work/out" 2>&1 +} diff --git a/test/shell.d/hermes-user-setup-test.sh b/test/shell.d/hermes-user-setup-test.sh index dfc6b92d0ee..3f45d848fc3 100644 --- a/test/shell.d/hermes-user-setup-test.sh +++ b/test/shell.d/hermes-user-setup-test.sh @@ -25,6 +25,7 @@ run_logged() { SH for command in xdg-user-dirs-update xdg-settings xdg-mime omarchy-refresh-applications; do printf '#!/bin/bash\nexit 0\n' >"$fixture/bin/$command" + chmod +x "$fixture/bin/$command" done cat >"$fixture/bin/omarchy-pkg-present" <<'SH' #!/bin/bash @@ -35,7 +36,7 @@ cat >"$fixture/bin/mise" <<'SH' printf '%s\n' "$*" >>"$TEST_MISE_CALLS" [[ $1 != where ]] SH -chmod +x "$fixture/bin/"* +chmod +x "$fixture/bin/omarchy-pkg-present" "$fixture/bin/mise" export TEST_MISE_CALLS="$test_tmp/mise.calls" marker='# Written by omarchy-install-hermes-cli.' diff --git a/test/shell.d/install-channel-test.sh b/test/shell.d/install-channel-test.sh index d8dfd15ef05..fcf95b65552 100755 --- a/test/shell.d/install-channel-test.sh +++ b/test/shell.d/install-channel-test.sh @@ -3,51 +3,7 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT -# Execute real main/option parsing with inert leaf stubs. In particular, never -# source an installed environment or run a real updater in these fixtures. -python3 - "$ROOT/install.sh" "$work/functions" <<'PY' -import re, sys -text = open(sys.argv[1]).read() -names = ['main', 'parse_install_options', 'verify_published_pair', 'run_system_setup'] -open(sys.argv[2], 'w').write('\n'.join(re.search(r'^' + name + r'\(\) \{.*?^}', text, re.M | re.S)[0] for name in names)) -PY -cat >"$work/driver" <<'DRIVER' -set -euo pipefail -source "$FUNCTIONS" -install_channel="${CHANNEL:-}" -channel_stage="" -log() { :; } -fail() { echo "$*" >&2; exit 1; } -step() { echo "$*" >>"$CALLS"; [[ ${FAIL_AT:-} != "$1" ]]; } -check_preconditions() { step preconditions; } -omarchy_arm_channel_stage_new() { echo "$STAGE"; } -omarchy_arm_channel_prepare() { step "prepare $2 $3"; printf '4.0.3rc1-1\n' >"$1/pair-version"; } -omarchy_arm_channel_apply_prepared() { step apply; } -cleanup_channel_install() { step cleanup; } -ensure_utf8_locale() { step locale; } -load_installed_environment() { step environment; } -protect_published_pair() { step protect; } -unprotect_published_pair() { step unprotect; } -omarchy_arm_prepare_package_sources() { step trust; } -ensure_arm_package_repo() { step repositories; } -ensure_gum() { step gum; } -ensure_aur_helper() { step aur; } -ensure_package_sources() { step recipes; } -build_omarchy_packages() { step build; } -install_omarchy_packages() { step local-install; } -install_default_package_set() { step defaults; } -seed_user_defaults() { step seed; } -run_system_setup() { step setup; } -snapshot_factory_baseline() { step snapshot; } -pacman() { echo "$2 ${PAIR_VERSION:-4.0.3rc1-1}"; } -main "$@" -DRIVER -export FUNCTIONS="$work/functions" STAGE="$work/stage" CALLS="$work/calls" -mkdir "$STAGE" -run_case() { - : >"$CALLS" - bash "$work/driver" "$@" >"$work/out" 2>&1 -} +source "$ROOT/test/shell.d/helpers/install-orchestration.sh" run_case --channel rc || fail 'published RC orchestration' [[ $(cat "$CALLS") == $'preconditions\nprepare rc fresh\nlocale\napply\nenvironment\nprotect\ntrust\ngum\naur\ndefaults\nseed\nsetup\nunprotect\nsnapshot\ncleanup' ]] || fail 'published preflight precedes mutations and never builds different bytes' pass 'explicit RC installs the preflighted pair and bypasses local builds' diff --git a/test/shell.d/locale-setup-test.sh b/test/shell.d/locale-setup-test.sh index 95679b01c3d..3158e71d25c 100755 --- a/test/shell.d/locale-setup-test.sh +++ b/test/shell.d/locale-setup-test.sh @@ -10,19 +10,33 @@ migration=$(/usr/bin/grep -rl 'Give the machine a UTF-8 locale' "$ROOT/migration [[ -f $leaf ]] || fail "the locale step ships" [[ -n $migration ]] || fail "existing installs get the locale repair" -# Asahi Alarm ships LANG=C, so the installer has to set the locale itself -- -# there is no ISO step here to do it. -/usr/bin/grep -q '^ ensure_utf8_locale$' "$ROOT/install.sh" || - fail "the installer sets a UTF-8 locale" -locale_call=$(/usr/bin/grep -n '^ ensure_utf8_locale$' "$ROOT/install.sh" | cut -d: -f1) -packages_call=$(/usr/bin/grep -n '^ install_default_package_set$' "$ROOT/install.sh" | cut -d: -f1) -(( locale_call < packages_call )) || - fail "the locale is set before the install starts printing package output" -pass "the installer sets a UTF-8 locale before the package pass" - test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT +# Execute both real installer branches with the same inert driver used by the +# channel tests. Read-only lane preflight can precede locale setup; package and +# system mutation must follow it, and a locale failure must stop those steps. +work="$test_tmp/installer" +mkdir "$work" +source "$ROOT/test/shell.d/helpers/install-orchestration.sh" +for path in source channel; do + options=() + [[ $path != "channel" ]] || options=(--channel rc) + CHANNEL="" run_case "${options[@]}" || fail "$path installer orchestration" + awk ' + $0 == "locale" { locale++; next } + /^(repositories|gum|aur|recipes|build|local-install|apply|environment|protect|trust|defaults|seed|setup|snapshot)$/ && !locale { exit 1 } + END { if (locale != 1) exit 1 } + ' "$CALLS" || fail "$path installer sets locale before package/system changes" "$(cat "$CALLS")" + if CHANNEL="" FAIL_AT=locale run_case "${options[@]}"; then + fail "$path installer must stop on locale failure" + fi + if grep -qE '^(repositories|gum|aur|recipes|build|local-install|apply|environment|protect|trust|defaults|seed|setup|snapshot)$' "$CALLS"; then + fail "$path installer mutated packages/system after locale failure" "$(cat "$CALLS")" + fi + pass "$path installer sets locale before package/system changes and stops on locale failure" +done + stub_bin="$test_tmp/bin" calls="$test_tmp/calls.log" locale_conf="$test_tmp/etc/locale.conf" diff --git a/test/shell.d/update-package-conflict-test.sh b/test/shell.d/update-package-conflict-test.sh index da8720b6442..307372431e4 100755 --- a/test/shell.d/update-package-conflict-test.sh +++ b/test/shell.d/update-package-conflict-test.sh @@ -31,11 +31,17 @@ exec "$@" STUB # Fails the first -Syu with the report under test, then succeeds. Every call -# records its arguments and which of its streams reached a terminal: pacman puts +# records upgrade arguments and which streams reached a terminal: pacman puts # its questions on stderr once it is not running --noconfirm, so a retry meant # for a person has to keep that stream. cat >"$stub_bin/pacman" <<'STUB' #!/bin/bash +# Optional-app source selection asks about installed state before upgrading. +# This fixture has neither optional app; queries cannot consume a transaction. +if [[ ${1:-} == "--config" && ${3:-} == "-Q" ]]; then + case "${4:-}" in asdcontrol | tobi-try) exit 1 ;; esac +fi +[[ " $* " == *" -Syu "* ]] || { echo "unexpected pacman call: $*" >&2; exit 2; } attempt=$(($(cat "$PACMAN_ATTEMPTS") + 1)) echo "$attempt" >"$PACMAN_ATTEMPTS" { From af4d69a5b9493efcc2d78d34a9274903605bb6b6 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 10:15:52 +0530 Subject: [PATCH 13/27] Describe ARM defaults as pending qualification exclusions --- bin/omarchy-upgrade-to-quattro-mac | 8 +-- docs/arm-package-sources.md | 6 ++- install.sh | 26 +++++----- install/omarchy-aarch64-unavailable.packages | 54 ++++++++------------ 4 files changed, 43 insertions(+), 51 deletions(-) diff --git a/bin/omarchy-upgrade-to-quattro-mac b/bin/omarchy-upgrade-to-quattro-mac index 637fe6eec1f..d0ceaeddad6 100755 --- a/bin/omarchy-upgrade-to-quattro-mac +++ b/bin/omarchy-upgrade-to-quattro-mac @@ -217,8 +217,8 @@ install_quattro_packages() { log "Installing the Quattro package set (this builds AUR packages and takes a while)" while read -r package; do - # OMARCHY_TRY_UNAVAILABLE=1 attempts them anyway: an AUR package can gain - # aarch64 support at any time, so a stale entry costs a flag, not a release. + # Preserve exclusions pending ARM qualification. The explicit opt-in may + # attempt currently available packages or providers despite that default. if [[ ${OMARCHY_TRY_UNAVAILABLE:-0} != "1" ]] && package_is_unavailable_here "$package"; then unbuildable+=("$package") continue @@ -234,11 +234,11 @@ install_quattro_packages() { yay -S --needed --noconfirm wf-recorder /dev/null 2>&1; then # --default=false to match the [y/N] fallback below: gum selects Yes - # otherwise, so Enter accepts -- and what this asks about is whether to - # spend three hours building packages that were measured to fail. + # otherwise, so Enter accepts an attempt at defaults whose current ARM + # installation and runtime behavior have not yet been qualified. gum confirm --default=false "$question" /dev/null || fail "Compatible package missing after system upgrade: $package" continue fi - # These compile a dependency chain for hours before failing an architecture - # check, so do not start them unless asked to. + # Keep unqualified defaults excluded unless explicitly requested, even + # where a package or provider is available in the current repositories. if (( ! attempt_unavailable )) && package_is_unavailable_here "$package"; then unbuildable+=("$package") continue @@ -254,7 +254,7 @@ install_default_package_set() { done < <(grep -vE '^\s*(#|$)' "$checkout/install/omarchy-base.packages") if (( ${#unbuildable[@]} )); then - warn "Not attempted, no known aarch64 build: ${unbuildable[*]}" + warn "Not attempted (excluded pending ARM qualification): ${unbuildable[*]}" echo "Try one later with: yay -S " fi @@ -262,7 +262,7 @@ install_default_package_set() { yay -S --needed --noconfirm wf-recorder Date: Sun, 13 Sep 2026 13:43:15 +0530 Subject: [PATCH 14/27] Preserve nested Snapper history across Mac root restores --- bin/omarchy-mac-snapper-backend | 371 +++++++++++++++++++++++++++ bin/omarchy-mac-snapshot-restore | 12 +- docs/btrfs.md | 18 +- install/config/snapper.sh | 10 + manual/30-updates.md | 2 +- manual/47-system-snapshots.md | 6 +- migrations/1789285718.sh | 10 + test/shell.d/snapper-backend-test.sh | 259 +++++++++++++++++++ 8 files changed, 669 insertions(+), 19 deletions(-) create mode 100755 bin/omarchy-mac-snapper-backend create mode 100644 migrations/1789285718.sh create mode 100755 test/shell.d/snapper-backend-test.sh diff --git a/bin/omarchy-mac-snapper-backend b/bin/omarchy-mac-snapper-backend new file mode 100755 index 00000000000..fbf256e8090 --- /dev/null +++ b/bin/omarchy-mac-snapper-backend @@ -0,0 +1,371 @@ +#!/bin/bash +# omarchy:summary=Maintain nested Snapper history during Apple Silicon recovery +# omarchy:group=system +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# Internal recovery helper. The same file can be run with bash from a retained +# root, including after restoring a baseline without Omarchy or Python. +# Administrators must not start direct --no-dbus/Btrfs writers during this +# maintenance window: those tools do not participate in our operation lock. +set -euo pipefail + +backend_error() { echo "Error: $*" >&2; return 1; } +backend_info() { + local output + output=$(LC_ALL=C btrfs subvolume show "$1") || return $? + awk -v key="$2" 'index($0, ":") { n=$0; sub(/^[ \t]+/, "", n); k=n; sub(/:.*/, "", k); if(k==key) {sub(/^[^:]*:[ \t]*/, "", n); print n; exit}}' <<<"$output" +} +backend_uuid() { backend_info "$1" UUID; } +backend_require_uuid() { + local identity + identity=$(backend_uuid "$1") || return $? + [[ $identity =~ ^[a-fA-F0-9-]{36}$ ]] || { backend_error "Cannot identify subvolume $1"; return 1; } + printf '%s\n' "$identity" +} +backend_is_volume() { btrfs subvolume show "$1" >/dev/null 2>&1; } +backend_plain_dir() { [[ ! -L $1 && -d $1 ]]; } +backend_empty_dir() { + local contents + backend_plain_dir "$1" || return 1 + contents=$(find "$1" -mindepth 1 -maxdepth 1 -print -quit) || return $? + [[ -z $contents ]] +} +backend_config_valid() { + local root=$1 path="$1/etc/snapper/configs/root" + [[ ! -L $root/etc && ! -L $root/etc/snapper && ! -L $root/etc/snapper/configs && ! -L $path ]] || + { backend_error "Symlinked Snapper configuration in $root; preserving it"; return 1; } + [[ -e $path ]] || return 0 # @fresh may not have Snapper at all. + [[ -f $path ]] && awk ' + /^[ \t]*(SUBVOLUME|FSTYPE)[ \t]*=/ { + key=$0; sub(/^[ \t]*/, "", key); sub(/[ \t]*=.*/, "", key) + value=$0; sub(/^[^=]*=[ \t]*/, "", value); sub(/[ \t]*#.*/, "", value); sub(/[ \t]*$/, "", value) + if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) value=substr(value, 2, length(value)-2) + count[key]++; values[key]=value + } + END {exit !(count["SUBVOLUME"]==1 && values["SUBVOLUME"]=="/" && count["FSTYPE"]==1 && values["FSTYPE"]=="btrfs")} + ' "$path" || { backend_error "Unsupported Snapper root configuration in $root"; return 1; } +} +backend_no_mount_entry() { + local root=$1 count + [[ ! -L $root/etc && ! -L $root/etc/fstab && -f $root/etc/fstab ]] || + { backend_error "Missing regular fstab in $root"; return 1; } + count=$(awk '!/^[ \t]*#/ && $2=="/.snapshots" {found++} END {print found+0}' "$root/etc/fstab") || return $? + if (( count )); then + backend_error "A separate /.snapshots mount is configured in $root; preserve this custom layout and recover manually" + return 1 + fi +} +backend_busy() { + local unit state + for unit in snapper-cleanup.service snapper-timeline.service snapper-boot.service snapper-backup.service; do + state=$(systemctl show "$unit" --property=ActiveState --value) || return 0 + [[ $state == inactive || $state == failed || -z $state ]] || return 0 + done + # These names exclude this bash helper and snapperd. Never stop a running + # cleanup/snapshot/backup job; wait for completion or leave maintenance failed. + local status=0 + pgrep -x 'snapper|snbk|systemd-helper' >/dev/null || status=$? + # Only 1 proves there were no matches; errors are not evidence of quiescence. + (( status != 1 )) +} +backend_service_intent() { + [[ -z ${BACKEND_SERVICE_RECEIPT:-} ]] && return 0 # sourced unit fixtures + printf '%s %s\n' "$1" "$2" >>"$BACKEND_SERVICE_RECEIPT" || return $? + sync -f "$BACKEND_SERVICE_RECEIPT" +} +backend_service_load() { + [[ -e $BACKEND_SERVICE_RECEIPT || -L $BACKEND_SERVICE_RECEIPT ]] || return 0 + [[ -f $BACKEND_SERVICE_RECEIPT && ! -L $BACKEND_SERVICE_RECEIPT && $(stat -c %u "$BACKEND_SERVICE_RECEIPT") == 0 && $(stat -c %a "$BACKEND_SERVICE_RECEIPT") == 600 ]] || return 1 + local kind unit extra daemon=0 + local -a masks=() timers=() + while read -r kind unit extra; do + [[ -z $extra ]] || return 1 + case "$kind $unit" in + 'mask snapper-cleanup.service'|'mask snapper-timeline.service'|'mask snapper-boot.service'|'mask snapper-backup.service'|'mask snapperd.service') masks+=("$unit");; + 'timer snapper-cleanup.timer'|'timer snapper-timeline.timer'|'timer snapper-backup.timer') timers+=("$unit");; + 'daemon snapperd.service') daemon=1;; + *) backend_error "Ambiguous service maintenance receipt; inspect $BACKEND_SERVICE_RECEIPT"; return 1;; + esac + done <"$BACKEND_SERVICE_RECEIPT" + BACKEND_MASKED=("${masks[@]}") BACKEND_TIMERS=("${timers[@]}") BACKEND_DAEMON=$daemon +} +backend_service_recover() { + backend_service_load || return $? + backend_resume_services || return $? + BACKEND_MASKED=() BACKEND_TIMERS=() BACKEND_DAEMON=0 +} +backend_quiesce() { + local unit state deadline=$((SECONDS + 30)) + for unit in snapper-cleanup.service snapper-timeline.service snapper-boot.service snapper-backup.service snapperd.service; do + state=$(systemctl show "$unit" --property=LoadState --value) || return $? + [[ $state != not-found ]] || continue + state=$(systemctl is-enabled "$unit" 2>/dev/null || true) + if [[ $state != masked* ]]; then + backend_service_intent mask "$unit" || return $? + BACKEND_MASKED+=("$unit") + systemctl mask --runtime "$unit" || return $? + fi + done + for unit in snapper-cleanup.timer snapper-timeline.timer snapper-backup.timer; do + if systemctl is-active --quiet "$unit"; then + backend_service_intent timer "$unit" || return $? + BACKEND_TIMERS+=("$unit") + systemctl stop "$unit" || return $? + fi + done + while backend_busy; do + (( SECONDS < deadline )) || { backend_error "Snapper writers are busy; finish their work and retry"; return 1; } + sleep 0.2 + done + if systemctl is-active --quiet snapperd.service; then + backend_service_intent daemon snapperd.service || return $? + BACKEND_DAEMON=1 + systemctl stop snapperd.service || return $? + fi + ! backend_busy || { backend_error "A writer started during maintenance; retry with exclusive access"; return 1; } +} +backend_resume_services() { + local unit failed=0 + for unit in "${BACKEND_MASKED[@]}"; do systemctl unmask --runtime "$unit" || failed=1; done + for unit in "${BACKEND_TIMERS[@]}"; do systemctl start "$unit" || failed=1; done + if (( BACKEND_DAEMON )); then systemctl start snapperd.service || failed=1; fi + (( ! failed )) || { backend_error "Filesystem state retained; restore the reported Snapper services manually"; return 1; } + [[ -z ${BACKEND_SERVICE_RECEIPT:-} ]] || rm -f -- "$BACKEND_SERVICE_RECEIPT" +} +backend_receipt_write() { + [[ ! -e $BACKEND_RECEIPT && ! -L $BACKEND_RECEIPT ]] || { backend_error "An unfinished recovery receipt exists"; return 1; } + (umask 077; set -o noclobber; printf '%s\n' "$BACKEND_ROOT_UUID" "$BACKEND_NEW_UUID" "$BACKEND_HISTORY_UUID" "$BACKEND_STAMP" "$BACKEND_HISTORY_MODE" >"$BACKEND_RECEIPT") || return $? + sync -f "$BACKEND_RECEIPT" +} +backend_receipt_read() { + local -a fields + [[ -f $BACKEND_RECEIPT && ! -L $BACKEND_RECEIPT && $(stat -c %u "$BACKEND_RECEIPT") == 0 && $(stat -c %a "$BACKEND_RECEIPT") == 600 ]] || + { backend_error "Unexpected recovery receipt; preserve it for manual inspection"; return 1; } + mapfile -t fields <"$BACKEND_RECEIPT" + (( ${#fields[@]} == 5 )) && [[ ${fields[0]} =~ ^[a-fA-F0-9-]{36}$ && ${fields[1]} =~ ^[a-fA-F0-9-]{36}$ && (${fields[2]} =~ ^[a-fA-F0-9-]{36}$ || ${fields[2]} == none) && ${fields[3]} =~ ^[0-9]+$ && ${fields[4]} =~ ^(existing|created|absent)$ ]] || + { backend_error "Malformed recovery receipt; no state changed"; return 1; } + BACKEND_ROOT_UUID=${fields[0]} BACKEND_NEW_UUID=${fields[1]} BACKEND_HISTORY_UUID=${fields[2]} BACKEND_STAMP=${fields[3]} BACKEND_HISTORY_MODE=${fields[4]} + [[ ($BACKEND_HISTORY_MODE == absent && $BACKEND_HISTORY_UUID == none) || ($BACKEND_HISTORY_MODE != absent && $BACKEND_HISTORY_UUID != none) ]] +} +backend_recover_transaction() { + [[ -e $BACKEND_RECEIPT || -L $BACKEND_RECEIPT ]] || return 0 + backend_receipt_read || return $? + local current staged old history running + current=$(backend_uuid "$BACKEND_TOP/@" 2>/dev/null || true) + staged=$(backend_uuid "$BACKEND_TOP/@new" 2>/dev/null || true) + old=$(backend_uuid "$BACKEND_TOP/@old-$BACKEND_STAMP" 2>/dev/null || true) + # A completed exchange is recognized only by all three identities. No root + # is selected by time/name alone, including after a power loss and reboot. + if [[ $current == "$BACKEND_NEW_UUID" && $old == "$BACKEND_ROOT_UUID" && -z $staged ]]; then + [[ $BACKEND_HISTORY_MODE == absent || $(backend_require_uuid "$BACKEND_TOP/@/.snapshots") == "$BACKEND_HISTORY_UUID" ]] || return 1 + running=$(backend_uuid /) || return $? + if [[ $running == "$BACKEND_NEW_UUID" || $BACKEND_HISTORY_MODE != existing || $(backend_uuid /.snapshots 2>/dev/null || true) == "$BACKEND_HISTORY_UUID" ]]; then + rm -- "$BACKEND_RECEIPT" + sync -f "$BACKEND_TOP" + return 0 + fi + [[ $running == "$BACKEND_ROOT_UUID" ]] || return 1 + # Attachment failed before reboot. Put both root names and the backend + # back, rather than leave the running root unable to make snapshots. + mv -T -- "$BACKEND_TOP/@" "$BACKEND_TOP/@new" || return $? + mv -T -- "$BACKEND_TOP/@old-$BACKEND_STAMP" "$BACKEND_TOP/@" || return $? + current=$BACKEND_ROOT_UUID staged=$BACKEND_NEW_UUID old="" + fi + [[ $staged == "$BACKEND_NEW_UUID" ]] || { backend_error "Staged root identity differs from receipt; inspect recovery manually"; return 1; } + if [[ -z $current && $old == "$BACKEND_ROOT_UUID" ]]; then + mv -T -- "$BACKEND_TOP/@old-$BACKEND_STAMP" "$BACKEND_TOP/@" || return $? + current=$BACKEND_ROOT_UUID + fi + [[ $current == "$BACKEND_ROOT_UUID" && ! -e $BACKEND_TOP/@old-$BACKEND_STAMP ]] || + { backend_error "Root identities differ from receipt; no automatic rollback"; return 1; } + if [[ $BACKEND_HISTORY_MODE == existing ]]; then + history=$(backend_uuid "$BACKEND_TOP/@/.snapshots" 2>/dev/null || true) + if [[ $history != "$BACKEND_HISTORY_UUID" ]]; then + [[ $(backend_require_uuid "$BACKEND_TOP/@new/.snapshots") == "$BACKEND_HISTORY_UUID" ]] && + { [[ ! -e $BACKEND_TOP/@/.snapshots ]] || backend_empty_dir "$BACKEND_TOP/@/.snapshots"; } || return 1 + [[ ! -e $BACKEND_TOP/@/.snapshots ]] || rmdir -- "$BACKEND_TOP/@/.snapshots" || return $? + mv -T -- "$BACKEND_TOP/@new/.snapshots" "$BACKEND_TOP/@/.snapshots" || return $? + fi + [[ $(backend_require_uuid "$BACKEND_TOP/@/.snapshots") == "$BACKEND_HISTORY_UUID" ]] || return 1 + elif [[ $BACKEND_HISTORY_MODE == created ]]; then + [[ $(backend_require_uuid "$BACKEND_TOP/@new/.snapshots") == "$BACKEND_HISTORY_UUID" ]] && + backend_empty_dir "$BACKEND_TOP/@new/.snapshots" || return 1 + btrfs subvolume delete "$BACKEND_TOP/@new/.snapshots" || return $? + fi + # Only our UUID-bound staged snapshot is removed, nonrecursively. Unknown + # nested subvolumes make btrfs refuse; history is already back in the live @. + btrfs subvolume delete "$BACKEND_TOP/@new" || return $? + rm -- "$BACKEND_RECEIPT" + sync -f "$BACKEND_TOP" +} +backend_repair() { + local parent candidate detail identity match="" + backend_plain_dir "$BACKEND_TOP/@/.snapshots" && backend_is_volume "$BACKEND_TOP/@/.snapshots" && return 0 + [[ ! -e $BACKEND_TOP/@/.snapshots && ! -L $BACKEND_TOP/@/.snapshots ]] || backend_empty_dir "$BACKEND_TOP/@/.snapshots" || + { backend_error "Incomplete snapshot path is not an empty directory; preserving it"; return 1; } + parent=$(backend_info "$BACKEND_TOP/@" 'Parent UUID') || return $? + [[ $parent =~ ^[a-fA-F0-9-]{36}$ ]] || { backend_error "Missing backend with no source snapshot identity; inspect retained roots manually"; return 1; } + local -A matches=() + for candidate in "$BACKEND_TOP"/@old-*/.snapshots; do + backend_plain_dir "${candidate%/.snapshots}" && backend_plain_dir "$candidate" && backend_is_volume "$candidate" || continue + identity=$(backend_require_uuid "$candidate") || return $? + for detail in "$candidate"/[0-9]*/snapshot; do + backend_plain_dir "${detail%/snapshot}" && backend_plain_dir "$detail" || continue + if [[ $(backend_uuid "$detail" 2>/dev/null || true) == "$parent" ]]; then matches[$identity]=$candidate; fi + done + done + (( ${#matches[@]} == 1 )) || { backend_error "Cannot prove exactly one retained backend belongs to this root. Inspect Btrfs UUIDs; all history was preserved"; return 1; } + for identity in "${!matches[@]}"; do match=${matches[$identity]}; done + [[ ! -e $BACKEND_TOP/@/.snapshots ]] || rmdir -- "$BACKEND_TOP/@/.snapshots" || return $? + mv -T -- "$match" "$BACKEND_TOP/@/.snapshots" || { mkdir -p "$BACKEND_TOP/@/.snapshots"; return 1; } + mkdir -- "$match" || return $? + [[ $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$identity" ]] || return 1 + sync -f "$BACKEND_TOP" +} +backend_restore_preflight() { + local source=$1 stamp=$2 selected + [[ $stamp =~ ^[0-9]+$ ]] || return 1 + [[ $source =~ ^(@fresh|@factory|@old-[0-9]+|@/\.snapshots/[0-9]+/snapshot)$ ]] || + { backend_error "Unsupported restore source"; return 1; } + selected="$BACKEND_TOP/$source" + local component="$selected" + while [[ $component != "$BACKEND_TOP" ]]; do [[ ! -L $component ]] || return 1; component=${component%/*}; done + backend_is_volume "$selected" || { backend_error "Selected source is not a subvolume"; return 1; } + backend_config_valid "$selected" && backend_no_mount_entry "$selected" || return $? + if [[ -e $selected/.snapshots || -L $selected/.snapshots ]]; then + backend_empty_dir "$selected/.snapshots" || { backend_error "Selected root has an independent nonempty backend; preserving it"; return 1; } + fi + [[ ! -e $BACKEND_TOP/@new && ! -L $BACKEND_TOP/@new && ! -e $BACKEND_TOP/@old-$stamp && ! -L $BACKEND_TOP/@old-$stamp ]] || + { backend_error "@new or @old-$stamp exists; inspect it before retrying"; return 1; } + [[ ! -L $BACKEND_RECEIPT && ! -e $BACKEND_RECEIPT ]] || { backend_error "Recovery transaction is unfinished"; return 1; } +} +backend_restore() { + local source=$1 stamp=$2 selected="$BACKEND_TOP/$1" + backend_restore_preflight "$source" "$stamp" || return $? + BACKEND_ROOT_UUID=$(backend_require_uuid "$BACKEND_TOP/@") || return $? + BACKEND_HISTORY_MODE=existing + if (( ${BACKEND_NO_HISTORY:-0} )); then + BACKEND_HISTORY_UUID=none BACKEND_HISTORY_MODE=absent + else + BACKEND_HISTORY_UUID=$(backend_require_uuid "$BACKEND_TOP/@/.snapshots") || return $? + fi + BACKEND_STAMP=$stamp + btrfs subvolume snapshot "$selected" "$BACKEND_TOP/@new" || return $? + BACKEND_NEW_UUID=$(backend_require_uuid "$BACKEND_TOP/@new") || return $? + if [[ $BACKEND_HISTORY_MODE == absent && -f $selected/etc/snapper/configs/root ]]; then + [[ ! -e $BACKEND_TOP/@new/.snapshots ]] || rmdir -- "$BACKEND_TOP/@new/.snapshots" || return $? + btrfs subvolume create "$BACKEND_TOP/@new/.snapshots" || return $? + chmod 750 "$BACKEND_TOP/@new/.snapshots" || return $? + BACKEND_HISTORY_UUID=$(backend_require_uuid "$BACKEND_TOP/@new/.snapshots") || return $? + BACKEND_HISTORY_MODE=created + fi + backend_receipt_write || return $? + if [[ $BACKEND_HISTORY_MODE == existing ]]; then + [[ ! -e $BACKEND_TOP/@new/.snapshots ]] || rmdir -- "$BACKEND_TOP/@new/.snapshots" || return $? + mv -T -- "$BACKEND_TOP/@/.snapshots" "$BACKEND_TOP/@new/.snapshots" || return $? + mkdir -- "$BACKEND_TOP/@/.snapshots" || return $? + fi + mv -T -- "$BACKEND_TOP/@" "$BACKEND_TOP/@old-$stamp" || return $? + mv -T -- "$BACKEND_TOP/@new" "$BACKEND_TOP/@" || return $? + [[ $BACKEND_HISTORY_MODE == absent || $(backend_require_uuid "$BACKEND_TOP/@/.snapshots") == "$BACKEND_HISTORY_UUID" ]] || return 1 + # This is a transient mount only in the running displaced root. On reboot + # the selected @ contains the original nested backend, with no fstab change. + local id + if [[ $BACKEND_HISTORY_MODE == existing ]]; then + id=$(backend_info "$BACKEND_TOP/@/.snapshots" 'Subvolume ID') || return $? + [[ $id =~ ^[0-9]+$ ]] || return 1 + mount -t btrfs -o "subvolid=$id" "$BACKEND_DEVICE" /.snapshots || return $? + [[ $(backend_require_uuid /.snapshots) == "$BACKEND_HISTORY_UUID" ]] || return 1 + fi + sync -f "$BACKEND_TOP" + rm -- "$BACKEND_RECEIPT" + echo "Root restored. Reboot before another restore." +} +backend_cleanup() { + local status=$? cleanup_status=0 + trap - EXIT + if [[ -n ${BACKEND_TOP:-} ]] && [[ -e ${BACKEND_RECEIPT:-/nonexistent} || -L ${BACKEND_RECEIPT:-/nonexistent} ]]; then + if (( ${BACKEND_QUIESCED:-0} )); then + backend_recover_transaction || cleanup_status=1 + else + cleanup_status=1 # Never repair under writers that failed quiescence. + fi + elif (( ! ${BACKEND_MOUNT_READY:-0} )) && + (( ${#BACKEND_MASKED[@]} || ${#BACKEND_TIMERS[@]} || BACKEND_DAEMON )); then + cleanup_status=1 # Previous intent cannot be resolved without its filesystem. + fi + if (( ! cleanup_status )); then + backend_resume_services || cleanup_status=1 + else + backend_error "Recovery needs inspection; owned writer masks and service receipt retained until repair" || true + fi + if [[ -n ${BACKEND_TOP:-} ]]; then + umount "$BACKEND_TOP" || cleanup_status=1 + # Never recursively remove a mountpoint, including when unmount failed. + rmdir "$BACKEND_TOP" 2>/dev/null || true + fi + (( status || cleanup_status )) && exit 1 + exit 0 +} +backend_main() { + (( EUID == 0 )) || { backend_error "Run as root"; return 1; } + [[ ${1:-} == repair || ${1:-} == restore ]] || { backend_error "Usage: $0 repair | restore SOURCE TIMESTAMP"; return 1; } + local mode=$1 required + for required in btrfs findmnt mount umount systemctl pgrep flock awk find stat sync mktemp; do + type -P "$required" >/dev/null || { backend_error "Recovery requires $required; install its package before retrying"; return 127; } + done + local root_path + root_path=$(findmnt -no FSROOT /) || return $? + [[ $(findmnt -no FSTYPE /) == btrfs && ($root_path == /@ || ($mode == repair && $root_path =~ ^/@old-[0-9]+$)) ]] || + { backend_error "Recovery requires root mounted from /@. Reboot after a restore before trying again"; return 1; } + [[ ! -e /var/lib/omarchy/provisioning/wipe-pending ]] || { backend_error "Factory reset is pending; finish it before recovery"; return 1; } + backend_no_mount_entry / && backend_config_valid / || return $? + local snapshot_mount + if [[ -e /.snapshots || -L /.snapshots ]]; then + snapshot_mount=$(findmnt -no TARGET -T /.snapshots) || return $? + [[ $snapshot_mount != /.snapshots ]] || { backend_error "Custom snapshot mount is active; preserving it"; return 1; } + fi + BACKEND_DEVICE=$(findmnt -no SOURCE / | sed 's/\[.*\]//') || return $? + [[ $BACKEND_DEVICE == /* ]] || { backend_error "Cannot identify root device"; return 1; } + exec 9>/run/omarchy-snapper-layout.lock + flock -n 9 || { backend_error "Another recovery operation is active"; return 1; } + BACKEND_MASKED=() BACKEND_TIMERS=() BACKEND_DAEMON=0 BACKEND_TOP="" BACKEND_MOUNT_READY=0 BACKEND_QUIESCED=0 + BACKEND_SERVICE_RECEIPT=/run/omarchy-snapper-services + # Load unfinished owned service intent, but keep its writers stopped until + # the filesystem receipt has been reconciled. Never resume them first. + backend_service_load || return $? + if [[ ! -e $BACKEND_SERVICE_RECEIPT ]]; then + (umask 077; set -o noclobber; : >"$BACKEND_SERVICE_RECEIPT") || return $? + fi + trap backend_cleanup EXIT + BACKEND_TOP=$(mktemp -d -t omarchy-snapper.XXXXXXXX) || return $? + mount -t btrfs -o subvolid=5 "$BACKEND_DEVICE" "$BACKEND_TOP" || return $? + BACKEND_MOUNT_READY=1 + BACKEND_RECEIPT="$BACKEND_TOP/.omarchy-snapper-restore" + [[ $root_path == /@ || -f $BACKEND_RECEIPT ]] || { backend_error "No interrupted transaction proves this displaced root; reboot first"; return 1; } + backend_quiesce || return $? + BACKEND_QUIESCED=1 + backend_recover_transaction || return $? + if [[ $mode == restore ]]; then + (( $# == 3 )) || { backend_error "restore requires a source and timestamp"; return 1; } + backend_restore_preflight "$2" "$3" || return $? + fi + BACKEND_NO_HISTORY=0 + if [[ $mode == restore && ! -e /etc/snapper/configs/root && ! -L /etc/snapper/configs/root ]] && + { [[ ! -e $BACKEND_TOP/@/.snapshots && ! -L $BACKEND_TOP/@/.snapshots ]] || backend_empty_dir "$BACKEND_TOP/@/.snapshots"; }; then + local candidate + for candidate in "$BACKEND_TOP"/@old-*/.snapshots; do + [[ ! -e $candidate && ! -L $candidate ]] || { backend_error "Retained snapshot state exists; inspect it before a restore without a root config"; return 1; } + done + BACKEND_NO_HISTORY=1 + else + backend_repair || return $? + fi + if [[ $mode == restore ]]; then + (( $# == 3 )) || { backend_error "restore requires a source and timestamp"; return 1; } + backend_restore "$2" "$3" || return $? + fi +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then backend_main "$@"; fi diff --git a/bin/omarchy-mac-snapshot-restore b/bin/omarchy-mac-snapshot-restore index 9254be12cce..a2c040925fc 100755 --- a/bin/omarchy-mac-snapshot-restore +++ b/bin/omarchy-mac-snapshot-restore @@ -180,9 +180,10 @@ main() { read -rp "Type 'restore' to continue: " typed [[ $typed == "restore" ]] || fail "not confirmed" - btrfs subvolume snapshot "$TOP/$source_path" "$TOP/@new" >/dev/null - mv "$TOP/@" "$TOP/@old-$stamp" - mv "$TOP/@new" "$TOP/@" + warn "Do not start other Snapper or Btrfs writers during recovery." + local backend_helper + backend_helper="$(dirname -- "$(realpath -- "${BASH_SOURCE[0]}")")/omarchy-mac-snapper-backend" + bash "$backend_helper" restore "$source_path" "$stamp" # /boot is the ESP and outside every snapshot, so the restored root is now # paired with whatever kernel is on it. If the install updated linux-asahi @@ -202,7 +203,10 @@ main() { log "Restored. Reboot to run from it." log "If it is not what you wanted, from a shell on the restored system:" log " sudo mount -o subvolid=5 $root_source /mnt" - log " sudo mv /mnt/@ /mnt/@discard && sudo mv /mnt/@old-$stamp /mnt/@ && sudo reboot" + log " sudo bash /mnt/@old-$stamp/usr/share/omarchy/bin/omarchy-mac-snapper-backend restore @old-$stamp \$(date +%s)" + log " sudo umount /mnt && sudo reboot" + warn "The undo uses the retained recovery helper; it also preserves snapshot history." + warn "After restoring older software, update before another recovery operation." } # Sourcing the script exposes its functions to the tests without running diff --git a/docs/btrfs.md b/docs/btrfs.md index 64decaaa0a7..51979a9ac90 100644 --- a/docs/btrfs.md +++ b/docs/btrfs.md @@ -142,21 +142,13 @@ which every Mac does. ## Rolling back to the pre-Omarchy state -`@fresh` is the fresh Asahi Alarm system from just after the migration. To -rewind the whole install (this discards `/`, keeps `@home` and `@log`): +`@fresh` is the fresh Asahi Alarm system from just after the migration. Run `omarchy-snapshot restore` and select `@fresh` to restore that root while retaining `@home` and `@log`. The Mac recovery helper transfers the existing nested Snapper backend into the restored root, preserving its history and subvolume identity. Raw snapshot-and-rename commands omit nested subvolumes and leave future snapshots broken. -```bash -sudo mkdir -p /mnt/top -sudo mount -o subvolid=5 "$(findmnt -no SOURCE / | sed 's/\[.*\]//')" /mnt/top -sudo btrfs subvolume snapshot /mnt/top/@fresh /mnt/top/@new # writable clone -sudo mv /mnt/top/@ /mnt/top/@old-$(date +%s) -sudo mv /mnt/top/@new /mnt/top/@ -sudo reboot -``` +Finish other snapshot, backup and Btrfs maintenance first, and do not start concurrent direct Snapper writers during recovery. Reboot before another restore. The command prints the exact undo route through the recovery helper retained under `@old-`; keep that root until recovery and undo are verified. Undo does not require the restored baseline to contain Omarchy or Python, but does require the existing Bash, Btrfs, mount and systemd tools. A baseline without a Snapper configuration stays unconfigured, with the history retained. Update older Omarchy software before using its own recovery commands again. + +The root exchange uses two renames. A power loss between them can leave `@` absent and require a rescue boot of the retained root before running the retained helper with `repair`. The UUID-bound transaction receipt permits verified rollback; it is not a bootloader recovery mechanism. Do not interrupt recovery or assume the ESP is covered. -After verifying the reboot, delete the parked `@old-*` subvolume from -`/mnt/top`. Note `@home` survives the rollback — delete and recreate it too if -you want the full fresh state. +Recovery can automatically reattach history lost by an earlier restore only when the current root's Btrfs parent UUID identifies a snapshot inside exactly one retained backend. Ambiguous state, custom snapshot mounts and conflicting paths are preserved for manual inspection. ## Limitations diff --git a/install/config/snapper.sh b/install/config/snapper.sh index aa7379c6b63..758033b7e08 100644 --- a/install/config/snapper.sh +++ b/install/config/snapper.sh @@ -31,6 +31,16 @@ configure_snapper_root() { echo "Error: Snapper root config does not describe the btrfs root; preserving existing state." >&2 return 1 fi + # Restore of a nested Btrfs snapshot can leave only an empty placeholder. + # Repair only the supported Mac @ layout; other working Btrfs layouts and + # custom mounts retain their existing setup behavior. + if [[ $(uname -m) == aarch64 && $(findmnt -no FSROOT /) == /@ ]] && + [[ ! -L $snapshots_path ]] && ! btrfs subvolume show "$snapshots_path" >/dev/null 2>&1; then + local backend_helper + # This leaf also supports the documented standalone sudo bash invocation. + backend_helper="${OMARCHY_PATH:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)}/bin/omarchy-mac-snapper-backend" + bash "$backend_helper" repair || return $? + fi # A config file alone is not a working backend. Never recreate a partial # backend: it may contain snapshots or administrator-managed mounts. if [[ -L $snapshots_path ]] || ! btrfs subvolume show "$snapshots_path" >/dev/null; then diff --git a/manual/30-updates.md b/manual/30-updates.md index 31152842249..23eaf41573a 100644 --- a/manual/30-updates.md +++ b/manual/30-updates.md @@ -32,7 +32,7 @@ If you're already familiar with Arch, you might be tempted to just run `pacman - ### Rolling back bad updates -If you ever have a problem after doing an update, you can rollback your system to the snapshot taken before the update. Just restart and pick the snapshot in the boot loading menu from before you started the update. +If you ever have a problem after doing an update, you can rollback your system to the snapshot taken before the update. On Limine, restart and pick the snapshot in the boot loading menu from before you started the update. On Apple Silicon with Btrfs root `@`, run `omarchy-snapshot restore` from a terminal and follow its confirmation, reboot and retained-root undo instructions. See [system snapshots](47-system-snapshots.md) for scope and boot-file limitations. ![bootloader](images/bootloader.webp) diff --git a/manual/47-system-snapshots.md b/manual/47-system-snapshots.md index 02f592f1a70..3b67b6656c1 100644 --- a/manual/47-system-snapshots.md +++ b/manual/47-system-snapshots.md @@ -16,7 +16,11 @@ This will restore your root filesystem, but not your `/home`. So it works for re This also means that your `~/.config` directory is kept as-is. So if you're rolling back to an earlier version of a library or application that stores configuration files in a new format, you'll have to sort that out manually. -_Note: This feature is only available on installations using the Limine boot loader, which has been the default since Omarchy 2.0. It's not available if you're on GRUB or systemd-boot._ +### Apple Silicon + +Macs using the standard Btrfs root mounted from `@` use `omarchy-snapshot restore` from a running terminal. Choose a Snapper snapshot, `@fresh`, or `@factory`, confirm, and reboot. The helper retains the displaced root and transfers the nested snapshot backend so history remains usable after reboot. It prints an undo command using the helper in the retained root; keep that root until verified. Do not use raw root rename commands or start concurrent Snapper/Btrfs maintenance. After restoring older software, update before another recovery operation. + +This restores the root only. The Asahi kernel, initramfs, ESP and firmware require separate recovery; check that the restored modules match the booted kernel. Other GRUB/systemd-boot layouts are not supported by this Mac helper. ### Skipping the boot menu diff --git a/migrations/1789285718.sh b/migrations/1789285718.sh new file mode 100644 index 00000000000..2b533eb3528 --- /dev/null +++ b/migrations/1789285718.sh @@ -0,0 +1,10 @@ +echo "Repair nested Snapper history after an earlier Mac root restore" + +# The earlier missing-root marker may already be complete. The shared setup +# leaf verifies ancestry before reattaching a retained backend and keeps all +# history/custom retention when the repair cannot be proven safe. +if (( EUID == 0 )); then + bash -euo pipefail "$OMARCHY_PATH/install/config/snapper.sh" +else + sudo env OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$OMARCHY_PATH/install/config/snapper.sh" +fi diff --git a/test/shell.d/snapper-backend-test.sh b/test/shell.d/snapper-backend-test.sh new file mode 100755 index 00000000000..f217d3169d6 --- /dev/null +++ b/test/shell.d/snapper-backend-test.sh @@ -0,0 +1,259 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/bin/omarchy-mac-snapper-backend" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +real_mv=$(command -v mv) +fixture_volume() { mkdir -p "$1"; printf '%s\n' "$2" >"$1/.uuid"; } +backend_uuid() { + [[ ${FAIL_UUID:-} != "$1" ]] || return 65 + case "$1" in /) echo "$TEST_RUNNING_UUID";; /.snapshots) [[ -n ${TEST_ATTACHED:-} ]] && echo "$TEST_ATTACHED";; *) [[ -f $1/.uuid ]] && cat "$1/.uuid";; esac +} +backend_info() { + case "$2" in UUID) backend_uuid "$1";; 'Parent UUID') cat "$1/.parent";; 'Subvolume ID') echo 258;; esac +} +backend_is_volume() { [[ -f $1/.uuid && ! -L $1 ]]; } +mount() { [[ ${FAIL_MOUNT:-0} == 0 ]] || return 71; TEST_ATTACHED=$BACKEND_HISTORY_UUID; } +sync() { :; } +mv() { + MOVE_NUMBER=$((MOVE_NUMBER + 1)) + [[ ${FAIL_MOVE:-0} != "$MOVE_NUMBER" ]] || return 72 + "$real_mv" "$@" +} +btrfs() { + case "$1 $2" in + 'subvolume snapshot') + cp -a "$3" "$4" + echo "$NEW_UUID" >"$4/.uuid" ;; + 'subvolume delete') + [[ $(backend_uuid "$3") == "$NEW_UUID" ]] || return 79 + [[ ! -f $3/.snapshots/.uuid ]] || return 78 + rm -r "$3" ;; + *) return 99 ;; + esac +} +ROOT_UUID=11111111-1111-1111-1111-111111111111 +NEW_UUID=22222222-2222-2222-2222-222222222222 +HISTORY_UUID=33333333-3333-3333-3333-333333333333 +SNAPSHOT_UUID=44444444-4444-4444-4444-444444444444 +new_fixture() { + BACKEND_TOP="$test_tmp/$1" + BACKEND_RECEIPT="$BACKEND_TOP/.omarchy-snapper-restore" + BACKEND_DEVICE=/test-only + TEST_RUNNING_UUID=$ROOT_UUID TEST_ATTACHED="" MOVE_NUMBER=0 FAIL_MOVE=0 FAIL_MOUNT=0 FAIL_UUID="" BACKEND_NO_HISTORY=0 + fixture_volume "$BACKEND_TOP/@" "$ROOT_UUID" + fixture_volume "$BACKEND_TOP/@/.snapshots" "$HISTORY_UUID" + fixture_volume "$BACKEND_TOP/@/.snapshots/1/snapshot" "$SNAPSHOT_UUID" + mkdir -p "$BACKEND_TOP/@/.snapshots/1/snapshot/etc/snapper/configs" "$BACKEND_TOP/@/.snapshots/1/snapshot/.snapshots" + printf 'UUID=test / btrfs subvol=@ 0 0\n' >"$BACKEND_TOP/@/.snapshots/1/snapshot/etc/fstab" + printf 'SUBVOLUME="/"\nFSTYPE="btrfs"\nNUMBER_LIMIT="7"\n' >"$BACKEND_TOP/@/.snapshots/1/snapshot/etc/snapper/configs/root" + echo history-one >"$BACKEND_TOP/@/.snapshots/1/info.xml" +} +# Receipt ownership is checked in production. This fixture runs unprivileged; +# delegate only the receipt uid probe, preserving all parser/type/mode checks. +stat() { + if [[ $* == '-c %u '* ]]; then echo 0; else command stat "$@"; fi +} +new_fixture success +backend_restore @/.snapshots/1/snapshot 123 +[[ $(backend_uuid "$BACKEND_TOP/@") == "$NEW_UUID" && $(backend_uuid "$BACKEND_TOP/@old-123") == "$ROOT_UUID" ]] || fail 'root exchange' +[[ $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" && $TEST_ATTACHED == "$HISTORY_UUID" ]] || fail 'exact backend transfer and temporary attachment' +[[ $(cat "$BACKEND_TOP/@/.snapshots/1/info.xml") == history-one ]] || fail 'history metadata retained' +[[ ! -e $BACKEND_RECEIPT && ! -e $BACKEND_TOP/@old-123/.snapshots/.uuid ]] || fail 'no external persistent backend' +pass 'restore transfers exact nested history and retains old root' + +for move in 1 2 3; do + new_fixture "failure-$move" + FAIL_MOVE=$move + if backend_restore @/.snapshots/1/snapshot 123; then fail 'injected move failed'; fi + FAIL_MOVE=0 + backend_recover_transaction + [[ $(backend_uuid "$BACKEND_TOP/@") == "$ROOT_UUID" && $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'rollback restores original identities' + [[ ! -e $BACKEND_TOP/@new && ! -e $BACKEND_RECEIPT ]] || fail 'own empty staging cleaned' + [[ $(cat "$BACKEND_TOP/@/.snapshots/1/info.xml") == history-one ]] || fail 'rollback retains history' + pass "move $move failure recovers without deleting history" +done +new_fixture attachment-failure +FAIL_MOUNT=1 +if backend_restore @/.snapshots/1/snapshot 123; then fail 'attachment must fail'; fi +backend_recover_transaction +[[ $(backend_uuid "$BACKEND_TOP/@") == "$ROOT_UUID" && $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'failed attachment rolls roots and history back' +pass 'failed live attachment restores running root backend' + +for conflict in target-mount target-config duplicate-config target-backend target-symlink existing-new existing-old; do + new_fixture "$conflict" + target="$BACKEND_TOP/@/.snapshots/1/snapshot" + case "$conflict" in + target-mount) echo 'UUID=custom /.snapshots btrfs subvol=custom 0 0' >>"$target/etc/fstab";; + target-config) echo 'SUBVOLUME="/home"' >"$target/etc/snapper/configs/root";; + duplicate-config) echo 'SUBVOLUME="/home"' >>"$target/etc/snapper/configs/root";; + target-backend) echo private >"$target/.snapshots/keep";; + target-symlink) rmdir "$target/.snapshots"; ln -s /unrelated "$target/.snapshots";; + existing-new) mkdir "$BACKEND_TOP/@new";; + existing-old) mkdir "$BACKEND_TOP/@old-123";; + esac + if backend_restore @/.snapshots/1/snapshot 123; then fail "refuse $conflict"; fi + [[ $MOVE_NUMBER == 0 && ! -e $BACKEND_RECEIPT && $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'conflict preflight has no history mutation' + pass "$conflict preserves prior state" +done + +new_fixture baseline +mkdir -p "$BACKEND_TOP/@factory/etc" +echo 'UUID=test / btrfs subvol=@ 0 0' >"$BACKEND_TOP/@factory/etc/fstab" +echo "$SNAPSHOT_UUID" >"$BACKEND_TOP/@factory/.uuid" +backend_restore @factory 123 +[[ ! -e $BACKEND_TOP/@/etc/snapper/configs/root && $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'baseline preserves history without inventing configuration' +pass 'baseline without Snapper config remains supported' + +for kind in proven ambiguous unmatched nonempty symlink; do + new_fixture "repair-$kind" + mkdir "$BACKEND_TOP/@old-100" + "$real_mv" "$BACKEND_TOP/@/.snapshots" "$BACKEND_TOP/@old-100/.snapshots" + mkdir "$BACKEND_TOP/@/.snapshots" + echo "$SNAPSHOT_UUID" >"$BACKEND_TOP/@/.parent" + case "$kind" in + ambiguous) cp -a "$BACKEND_TOP/@old-100" "$BACKEND_TOP/@old-200"; echo "$NEW_UUID" >"$BACKEND_TOP/@old-200/.snapshots/.uuid";; + unmatched) echo "$NEW_UUID" >"$BACKEND_TOP/@/.parent";; + nonempty) touch "$BACKEND_TOP/@/.snapshots/keep";; + symlink) rmdir "$BACKEND_TOP/@/.snapshots"; ln -s /unrelated "$BACKEND_TOP/@/.snapshots";; + esac + if [[ $kind == proven ]]; then + backend_repair + [[ $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'proven backend reattached' + backend_repair + [[ $MOVE_NUMBER == 1 ]] || fail 'repeat repair idempotent' + else + if backend_repair; then fail "$kind must refuse"; fi + [[ $MOVE_NUMBER == 0 ]] || fail 'unproven state untouched' + fi + pass "$kind ancestry repair contract" +done + +new_fixture changed-receipt +FAIL_MOVE=1 +backend_restore @/.snapshots/1/snapshot 123 >/dev/null 2>&1 || true +FAIL_MOVE=0 +printf '%s\n' "$SNAPSHOT_UUID" >"$BACKEND_TOP/@new/.uuid" +if backend_recover_transaction; then fail 'changed staged identity refuses'; fi +[[ -e $BACKEND_RECEIPT && $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'receipt failure retains history and evidence' +pass 'changed transaction identity preserves evidence for manual inspection' + +# Real service calls are covered in the guest. This fixture verifies policy +# ownership on success and refusal, including pre-existing runtime masks. +systemctl() { + printf '%s\n' "$*" >>"$SERVICE_LOG" + local unit=${2:-} + case "$1" in + show) + if [[ $3 == --property=LoadState ]]; then echo loaded + elif [[ ${TEST_BUSY:-0} == 1 && $unit == snapper-cleanup.service ]]; then echo active + else echo inactive; fi ;; + is-enabled) [[ $unit != snapper-boot.service ]] || { echo masked-runtime; return; }; echo static ;; + is-active) [[ ${3:-} == snapper-cleanup.timer || ${3:-} == snapperd.service ]] ;; + mask) [[ ${FAIL_MASK:-0} != 1 || $3 != snapper-timeline.service ]] ;; + stop|start|unmask) : ;; + *) return 99 ;; + esac +} +pgrep() { return 1; } +sleep() { SECONDS=$((SECONDS + 31)); } +for scenario in success busy mask-failure; do + SERVICE_LOG="$test_tmp/services-$scenario" + BACKEND_MASKED=() BACKEND_TIMERS=() BACKEND_DAEMON=0 TEST_BUSY=0 FAIL_MASK=0 + [[ $scenario != busy ]] || TEST_BUSY=1 + [[ $scenario != mask-failure ]] || FAIL_MASK=1 + result=0 + backend_quiesce || result=$? + backend_resume_services + if [[ $scenario == success ]]; then + (( result == 0 )) || fail 'idle services permit maintenance' + grep -Fx 'stop snapperd.service' "$SERVICE_LOG" >/dev/null || fail 'idle daemon stopped' + grep -Fx 'start snapperd.service' "$SERVICE_LOG" >/dev/null || fail 'prior daemon restored' + else (( result != 0 )) || fail 'busy/failure refuses maintenance'; fi + ! grep -Fx 'unmask --runtime snapper-boot.service' "$SERVICE_LOG" || fail 'pre-existing mask preserved' + ! grep -E '^stop snapper-(cleanup|timeline|boot|backup)\.service$' "$SERVICE_LOG" || fail 'in-flight writer never killed' + ! grep -E '^(enable|disable)' "$SERVICE_LOG" || fail 'persistent service policy unchanged' + pass "$scenario restores only owned service state" +done + +for identity in @ @/.snapshots @new; do + new_fixture "identity-${identity//\//-}" + FAIL_UUID="$BACKEND_TOP/$identity" + if backend_restore @/.snapshots/1/snapshot 123; then fail 'identity failure refuses'; fi + FAIL_UUID="" + [[ $MOVE_NUMBER == 0 && ! -e $BACKEND_RECEIPT && $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'identity failure cannot move history or publish invalid receipt' + pass "$identity identity failure precedes dependent mutation" +done +new_fixture unquoted-config +printf ' SUBVOLUME = / # root\nFSTYPE = btrfs\nNUMBER_LIMIT="7"\n' >"$BACKEND_TOP/@/.snapshots/1/snapshot/etc/snapper/configs/root" +backend_restore @/.snapshots/1/snapshot 123 +pass 'valid whitespace and unquoted root config preserved' + +# Optional-history mode covers genuinely unconfigured baseline restores. +# .uuid is test-only subvolume metadata, excluded from the emptiness predicate. +backend_empty_dir() { backend_plain_dir "$1" && [[ -z $(find "$1" -mindepth 1 -maxdepth 1 ! -name .uuid -print -quit) ]]; } +btrfs() { + case "$1 $2" in + 'subvolume snapshot') cp -a "$3" "$4"; echo "$NEW_UUID" >"$4/.uuid";; + 'subvolume create') fixture_volume "$3" "$HISTORY_UUID";; + 'subvolume delete') + if [[ $3 == */.snapshots ]]; then + [[ $(backend_uuid "$3") == "$HISTORY_UUID" ]] && backend_empty_dir "$3" || return 79 + else + [[ $(backend_uuid "$3") == "$NEW_UUID" && ! -f $3/.snapshots/.uuid ]] || return 78 + fi + rm -r "$3";; + *) return 99;; + esac +} +for configured in yes no; do + for outcome in success rollback; do + new_fixture "no-history-$configured-$outcome" + cp -a "$BACKEND_TOP/@/.snapshots/1/snapshot" "$BACKEND_TOP/@fresh" + [[ $configured == yes ]] || rm "$BACKEND_TOP/@fresh/etc/snapper/configs/root" + rm -r "$BACKEND_TOP/@/.snapshots" + BACKEND_NO_HISTORY=1 + if [[ $outcome == rollback ]]; then + FAIL_MOVE=1 + if backend_restore @fresh 123; then fail 'no-history move must fail'; fi + FAIL_MOVE=0 + backend_recover_transaction + [[ $(backend_uuid "$BACKEND_TOP/@") == "$ROOT_UUID" && ! -e $BACKEND_TOP/@/.snapshots && ! -e $BACKEND_TOP/@new ]] || fail 'no-history rollback preserves absent backend' + else + backend_restore @fresh 123 + if [[ $configured == yes ]]; then + [[ $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" && $(command stat -c %a "$BACKEND_TOP/@/.snapshots") == 750 ]] || fail 'existing baseline config gets new private empty backend' + else + [[ ! -e $BACKEND_TOP/@/etc/snapper/configs/root && ! -e $BACKEND_TOP/@/.snapshots/.uuid ]] || fail 'unconfigured baseline stays unconfigured' + fi + fi + pass "no-history $configured config $outcome" + done +done + +SERVICE_LOG="$test_tmp/services-resume" +BACKEND_SERVICE_RECEIPT="$test_tmp/service-receipt" +BACKEND_MASKED=() BACKEND_TIMERS=() BACKEND_DAEMON=0 TEST_BUSY=0 FAIL_MASK=0 +(umask 077; printf 'mask snapper-cleanup.service\ntimer snapper-cleanup.timer\n' >"$BACKEND_SERVICE_RECEIPT") +backend_service_recover +[[ ! -e $BACKEND_SERVICE_RECEIPT ]] || fail 'owned service intent cleared after restoration' +grep -Fx 'unmask --runtime snapper-cleanup.service' "$SERVICE_LOG" >/dev/null || fail 'interrupted own mask restored' +grep -Fx 'start snapper-cleanup.timer' "$SERVICE_LOG" >/dev/null || fail 'interrupted own timer restored' +(umask 077; printf 'mask unrelated.service\n' >"$BACKEND_SERVICE_RECEIPT") +if backend_service_recover; then fail 'ambiguous service receipt refuses'; fi +[[ -e $BACKEND_SERVICE_RECEIPT ]] || fail 'ambiguous service intent retained' +pass 'service intent retry restores owned changes and rejects unrelated state' +pgrep() { return 2; } +if backend_busy; then pass 'pgrep error refuses to claim idle writers'; else fail 'pgrep error must fail closed'; fi +new_fixture dangling-receipt +ln -s missing "$BACKEND_RECEIPT" +if ( + BACKEND_QUIESCED=1 BACKEND_MOUNT_READY=1 + backend_resume_services() { touch "$test_tmp/unsafe-service-resume"; } + umount() { :; } + backend_cleanup +); then fail 'dangling receipt must fail cleanup'; fi +[[ -L $BACKEND_RECEIPT && ! -e $test_tmp/unsafe-service-resume ]] || fail 'dangling receipt retains inspection and writer gate' +pass 'dangling receipt never resumes writers after failed reconciliation' From 23e1e7cbd8d09442c04acdee1d15ea155e2c17aa Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 13:46:22 +0530 Subject: [PATCH 15/27] Require a retained root subvolume before repairing snapshot history --- bin/omarchy-mac-snapper-backend | 3 ++- test/shell.d/snapper-backend-test.sh | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-mac-snapper-backend b/bin/omarchy-mac-snapper-backend index fbf256e8090..b21cf28b75c 100755 --- a/bin/omarchy-mac-snapper-backend +++ b/bin/omarchy-mac-snapper-backend @@ -208,7 +208,8 @@ backend_repair() { [[ $parent =~ ^[a-fA-F0-9-]{36}$ ]] || { backend_error "Missing backend with no source snapshot identity; inspect retained roots manually"; return 1; } local -A matches=() for candidate in "$BACKEND_TOP"/@old-*/.snapshots; do - backend_plain_dir "${candidate%/.snapshots}" && backend_plain_dir "$candidate" && backend_is_volume "$candidate" || continue + backend_plain_dir "${candidate%/.snapshots}" && backend_is_volume "${candidate%/.snapshots}" && + backend_plain_dir "$candidate" && backend_is_volume "$candidate" || continue identity=$(backend_require_uuid "$candidate") || return $? for detail in "$candidate"/[0-9]*/snapshot; do backend_plain_dir "${detail%/snapshot}" && backend_plain_dir "$detail" || continue diff --git a/test/shell.d/snapper-backend-test.sh b/test/shell.d/snapper-backend-test.sh index f217d3169d6..eeeac8d1f46 100755 --- a/test/shell.d/snapper-backend-test.sh +++ b/test/shell.d/snapper-backend-test.sh @@ -107,15 +107,16 @@ backend_restore @factory 123 [[ ! -e $BACKEND_TOP/@/etc/snapper/configs/root && $(backend_uuid "$BACKEND_TOP/@/.snapshots") == "$HISTORY_UUID" ]] || fail 'baseline preserves history without inventing configuration' pass 'baseline without Snapper config remains supported' -for kind in proven ambiguous unmatched nonempty symlink; do +for kind in proven ambiguous unmatched nonempty symlink plain-container; do new_fixture "repair-$kind" - mkdir "$BACKEND_TOP/@old-100" + fixture_volume "$BACKEND_TOP/@old-100" "$NEW_UUID" "$real_mv" "$BACKEND_TOP/@/.snapshots" "$BACKEND_TOP/@old-100/.snapshots" mkdir "$BACKEND_TOP/@/.snapshots" echo "$SNAPSHOT_UUID" >"$BACKEND_TOP/@/.parent" case "$kind" in ambiguous) cp -a "$BACKEND_TOP/@old-100" "$BACKEND_TOP/@old-200"; echo "$NEW_UUID" >"$BACKEND_TOP/@old-200/.snapshots/.uuid";; unmatched) echo "$NEW_UUID" >"$BACKEND_TOP/@/.parent";; + plain-container) rm "$BACKEND_TOP/@old-100/.uuid";; nonempty) touch "$BACKEND_TOP/@/.snapshots/keep";; symlink) rmdir "$BACKEND_TOP/@/.snapshots"; ln -s /unrelated "$BACKEND_TOP/@/.snapshots";; esac From 7585a5fdb6a9eb8c7ed82a23e78111bb280cba7e Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 15:15:57 +0530 Subject: [PATCH 16/27] Fix factory reset boot and retained-root handoff --- bin/omarchy-provision-owner | 98 ++-- bin/omarchy-system-factory-reset | 247 +++++++--- bin/omarchy-system-factory-reset-finish | 45 +- bin/omarchy-update-lock | 16 + docs/btrfs.md | 17 +- install/helpers/factory-reset.sh | 463 ++++++++++++++++++ install/helpers/owner-rekey.sh | 134 +++++ install/helpers/reset-boot.sh | 408 +++++++++++++++ test/shell.d/factory-reset-closure-test.sh | 27 + test/shell.d/factory-reset-inventory-test.sh | 108 ++++ test/shell.d/factory-reset-services-test.sh | 44 ++ .../shell.d/factory-reset-transaction-test.sh | 56 +++ test/shell.d/factory-update-exclusion-test.sh | 40 ++ test/shell.d/owner-rekey-test.sh | 103 ++++ test/shell.d/reset-boot-test.sh | 86 ++++ 15 files changed, 1743 insertions(+), 149 deletions(-) create mode 100644 install/helpers/factory-reset.sh create mode 100644 install/helpers/owner-rekey.sh create mode 100644 install/helpers/reset-boot.sh create mode 100644 test/shell.d/factory-reset-closure-test.sh create mode 100644 test/shell.d/factory-reset-inventory-test.sh create mode 100644 test/shell.d/factory-reset-services-test.sh create mode 100644 test/shell.d/factory-reset-transaction-test.sh create mode 100644 test/shell.d/factory-update-exclusion-test.sh create mode 100644 test/shell.d/owner-rekey-test.sh create mode 100644 test/shell.d/reset-boot-test.sh diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 8e47ae076d3..163ed84354a 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -325,7 +325,7 @@ FINALIZE_TOTAL=$(grep -c '^run_logged' "$OMARCHY_PATH/install/user/all.sh" 2>/de # The re-key rebuilds the UKI and is the slowest single step, so it needs a wide # band of its own; unencrypted installs skip it and let finalize take the room. REKEY_PENDING=false -[[ -f $PROVISIONING_DIR/luks-key ]] && REKEY_PENDING=true +[[ -e $PROVISIONING_DIR/luks-key || -L $PROVISIONING_DIR/luks-key || -e $PROVISIONING_DIR/owner-rekey || -L $PROVISIONING_DIR/owner-rekey ]] && REKEY_PENDING=true # Per-mille bands per phase: "lo hi tau". tau shapes the asymptotic time floor; # it is not a duration prediction. A wide band moves visibly; a narrow one looks @@ -888,70 +888,50 @@ finalize_user() { # the staged auto-unlock keyfile would leave the disk effectively unencrypted # forever. rekey_luks() { - [[ -f $PROVISIONING_DIR/luks-key ]] || return 0 - - local device - if ! device=$(luks_device) || [[ ! -e $device ]]; then - log_step "cannot locate the LUKS device from /proc/cmdline: $(cat /proc/cmdline)" - say --foreground 1 "Could not locate the LUKS device to re-key." - return 1 + local state="$PROVISIONING_DIR/owner-rekey" device + # A valid in-progress receipt survives removal of the throwaway slot/key. + # Missing staged material must not bypass unfinished retirement. + if [[ ! -e $PROVISIONING_DIR/luks-key && ! -L $PROVISIONING_DIR/luks-key && ! -e $state && ! -L $state ]]; then + [[ ! -e /etc/omarchy/provisioning.key ]] || return 1 + return 0 fi - - if ! cryptsetup open --test-passphrase --key-file "$PROVISIONING_DIR/luks-key" "$device" 2>>"$LOG_FILE"; then - log_step "staged LUKS key does not unlock $device" - say --foreground 1 "The staged LUKS key no longer unlocks $device." + source "$OMARCHY_PATH/install/helpers/owner-rekey.sh" + device=$(luks_device) || return $? + if ! owner_rekey_run "$device" "$PROVISIONING_DIR/luks-key" <(printf '%s' "$password") "$state" >>"$LOG_FILE" 2>&1; then + say --foreground 1 "Disk re-key remains pending. Retry with the same confirmed owner disk password; do not discard its receipt." return 1 fi +} - # Add the user's key (a retry with a different password just adds another - # slot; all but the current one are killed once the rebuild succeeds). - cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password") - - # Rebuild the no-auto-unlock UKI FIRST, keeping the throwaway key and slot as - # a fallback. Only once that succeeds do we kill the other slots and destroy - # the staged key — so a limine-update failure leaves a recoverable, - # still-auto-unlocking state to retry, never a disk locked to a password the - # user may have just changed. - rm -f /etc/omarchy/provisioning.key \ - /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf \ - /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf +owner_rekey_limine_boot() { + local state=$1 esp path line hash reset_limine_config if ! limine-update >>"$LOG_FILE" 2>&1; then - log_step "limine-update failed during re-key; restoring auto-unlock for retry" - install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key - echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ - >/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf - echo 'FILES+=(/etc/omarchy/provisioning.key)' >/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf - limine-update >>"$LOG_FILE" 2>&1 || true - return 1 - fi - - local new_slot slot other_slots - new_slot=$(cryptsetup open --test-passphrase --verbose --key-file <(printf '%s' "$password") "$device" 2>&1 | - grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' | head -1) - # Retiring the throwaway/seller slots must be all-or-nothing: if we can't - # identify the user's slot or a kill fails, keep the staged key and retry — - # never shred it while a slot the seller knows still unlocks the disk. - if [[ -z $new_slot ]]; then - log_step "could not identify the user's LUKS slot after re-key; keeping the staged key for retry" - say --foreground 1 "Could not confirm the LUKS re-key; will retry." - return 1 - fi - if ! other_slots=$(cryptsetup luksDump "$device" | awk '/^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }'); then - log_step "luksDump failed while retiring slots; keeping the staged key for retry" - say --foreground 1 "Could not enumerate LUKS slots; will retry." + # Preserve the established Limine retry behavior; the owner slot has + # already been tested and no previous slots have been retired yet. + if [[ -f $PROVISIONING_DIR/luks-key ]]; then + install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key || return $? + install -d /etc/limine-entry-tool.d /etc/mkinitcpio.conf.d || return $? + echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' > /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf + echo 'FILES+=(/etc/omarchy/provisioning.key)' > /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf + limine-update >>"$LOG_FILE" 2>&1 || true + fi return 1 fi - for slot in $other_slots; do - [[ $slot == "$new_slot" ]] && continue - if ! cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot"; then - log_step "failed to kill LUKS slot $slot; keeping the staged key for retry" - say --foreground 1 "Could not remove the throwaway LUKS key; will retry." - return 1 - fi - done - - shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key" + esp=$(esp_path) + [[ $esp == /boot || $esp == /efi ]] || return 1 + sha256sum "$esp/limine.conf" >"$state/boot-manifest" || return $? + local entries + entries=$(grep -o 'boot():/EFI/Linux/[^#]*#[0-9a-f]*' "$esp/limine.conf") || return $? + [[ -n $entries ]] || return 1 + while IFS= read -r line; do + path=${line#*boot():}; path=${path%%#*}; hash=${line##*#} + [[ $path == /EFI/Linux/* && $path != *'/../'* && $hash =~ ^[0-9a-f]{128}$ && -f $esp$path && ! -L $esp$path ]] || return 1 + [[ $(b2sum "$esp$path" | cut -d' ' -f1) == "$hash" ]] || return 1 + sha256sum "$esp$path" >>"$state/boot-manifest" || return $? + done <<<"$entries" + # Limine may reference the same UKI in several entries. + LC_ALL=C sort -u "$state/boot-manifest" -o "$state/boot-manifest" } # Start the ESP's limine.conf over from the shipped template and drop foreign @@ -1045,10 +1025,10 @@ run_provisioning() { touch "$FINALIZE_WARNING_FLAG" fi - if [[ -f $PROVISIONING_DIR/luks-key ]]; then + if [[ -e $PROVISIONING_DIR/luks-key || -L $PROVISIONING_DIR/luks-key || -e $PROVISIONING_DIR/owner-rekey || -L $PROVISIONING_DIR/owner-rekey ]]; then log_step "re-keying LUKS to the user's password" echo rekey >"$STATE_FILE" - rekey_luks + rekey_luks || return $? log_step "LUKS re-key complete" fi diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca40110a..e39da6ff4f4 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -43,6 +43,9 @@ if (( EUID != 0 )); then fi export PATH="$OMARCHY_PATH/bin:$PATH" +source "$OMARCHY_PATH/install/helpers/factory-reset.sh" +source "$OMARCHY_PATH/install/helpers/owner-rekey.sh" +RESET_STATE="$TOP_MNT/.omarchy-factory-reset" log() { echo "$1" | tee -a "$LOG_FILE" >/dev/null @@ -99,13 +102,29 @@ generate_passphrase() { } cleanup() { + local status=$? rollback_ok=1 if mountpoint -q "$TOP_MNT" 2>/dev/null; then - if [[ -d $TOP_MNT/$NEXT_NAME && ${swap_done:-0} == 0 ]]; then - btrfs subvolume delete --recursive "$TOP_MNT/$NEXT_NAME" >/dev/null 2>&1 || true + if [[ ${reset_started:-0} == 1 && ${swap_done:-0} == 0 ]]; then + if ! reset_transaction_rollback "$TOP_MNT" "$RESET_STATE"; then + rollback_ok=0 + echo "Reset rollback is incomplete. Preserve $RESET_STATE and do not reboot until its root/boot identities are reconciled." >&2 + else + : >/var/lib/omarchy/factory-reset.lock + echo "Original root and baseline restored. Failed staging is retained at $RESET_STATE and @omarchy-reset-* for inspection." >&2 + fi + fi + if [[ ${reset_started:-0} == 0 && ${reset_journal_created:-0} == 1 ]]; then + # This invocation has created only its private pre-confirm journal. + # Canceling the existing prompt must leave the command retryable. + reset_cancel_journal "$RESET_STATE" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY - -" || echo "Pre-confirm reset journal needs inspection: $RESET_STATE" >&2 + elif [[ ${swap_done:-0} == 0 && ${reset_services_started:-0} == 1 && $rollback_ok == 1 ]]; then + reset_limine_resume "$RESET_STATE" || echo "Limine service restoration needs inspection: $RESET_STATE/limine-services" >&2 + backend_resume_services || echo "Reset service restoration needs inspection: $BACKEND_SERVICE_RECEIPT" >&2 fi umount -R "$TOP_MNT" 2>/dev/null || true fi rmdir "$TOP_MNT" 2>/dev/null || true + return "$status" } trap cleanup EXIT @@ -122,6 +141,8 @@ confirm_reset() { gum style --foreground 8 "Note: on disks without encryption this is deletion, not secure erasure." echo + reset_inventory_display "$RESET_STATE/inventory" + echo local typed typed=$(gum input --placeholder "Type 'reset' to continue" --prompt "> ") || exit 1 [[ $typed == "reset" ]] || fail "Reset not confirmed." @@ -147,7 +168,14 @@ stage_luks_rekey() { done passphrase=$(generate_passphrase) - cryptsetup luksAddKey --key-file <(printf '%s' "$current") "$device" <(printf '%s' "$passphrase") + local old_slots new_slot luks_uuid + old_slots=$(owner_rekey_slots "$device") || fail "Could not enumerate original LUKS slots" + for (( new_slot=0; new_slot<32; new_slot++ )); do + grep -qx "$new_slot" <<<"$old_slots" || break + done + (( new_slot < 32 )) || fail "No free LUKS slot for provisioning" + luks_uuid=$(cryptsetup luksUUID "$device") || fail "Could not identify LUKS container" + [[ $luks_uuid =~ ^[a-fA-F0-9-]{36}$ ]] || fail "Malformed LUKS identity" install -d -m 755 "$next$PROVISIONING_DIR" printf '%s' "$passphrase" >"$next$PROVISIONING_DIR/luks-key" @@ -156,10 +184,20 @@ stage_luks_rekey() { install -d -m 755 "$next/etc/omarchy" printf '%s' "$passphrase" >"$next/etc/omarchy/provisioning.key" chmod 600 "$next/etc/omarchy/provisioning.key" - - install -d "$next/etc/limine-entry-tool.d" "$next/etc/mkinitcpio.conf.d" - echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ - >"$next/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf" + reset_state_write "$RESET_STATE/staged-luks" "$luks_uuid $new_slot ${old_slots//$'\n'/,}" || fail "Could not record staged LUKS slot intent" + cryptsetup luksAddKey --new-key-slot "$new_slot" --key-file <(printf '%s' "$current") "$device" "$next$PROVISIONING_DIR/luks-key" + cryptsetup open --test-passphrase --key-slot "$new_slot" --key-file "$next$PROVISIONING_DIR/luks-key" "$device" || fail "Could not verify provisioning slot" + + install -d "$next/etc/mkinitcpio.conf.d" + if [[ $RESET_BOOT_BACKEND == grub ]]; then + install -d "$next/etc/default/grub.d" + echo 'GRUB_CMDLINE_LINUX="${GRUB_CMDLINE_LINUX} cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ + >"$next/etc/default/grub.d/99-omarchy-provisioning-unlock.cfg" + else + install -d "$next/etc/limine-entry-tool.d" + echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ + >"$next/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf" + fi echo 'FILES+=(/etc/omarchy/provisioning.key)' \ >"$next/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf" } @@ -297,65 +335,55 @@ rebuild_next_boot() { umount "$next$esp_mount" } -# Remove the seller's account material and machine identity from the retained -# @factory baseline so it can neither be mounted for recovery nor restore the -# seller's account on a future reset. Idempotent (a scrubbed baseline has no -# uid>=1000 accounts left to remove). -sanitize_factory_baseline() { - local factory="$1" user - btrfs property set -ts "$factory" ro false - - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do - userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true - rm -rf "${factory:?}/home/$user" - done - passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true - rm -f "$factory"/etc/ssh/ssh_host_* - rm -f "$factory"/etc/NetworkManager/system-connections/* - rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" - rm -f "$factory/var/lib/sddm/state.conf" "$factory/etc/sddm.conf.d/autologin.conf" - : >"$factory/etc/machine-id" - - btrfs property set -ts "$factory" ro true -} - stage_full_reset() { - local top="$TOP_MNT" next="$TOP_MNT/$NEXT_NAME" - + local top="$TOP_MNT" next="$TOP_MNT/$NEXT_NAME" clean="$TOP_MNT/@omarchy-reset-factory" user + reset_inventory_verify_sources "$top" "$RESET_STATE/inventory" || fail "Confirmed reset inventory changed" + [[ ! -e $next && ! -L $next && ! -e $clean && ! -L $clean ]] || fail "A prior reset staging object needs inspection" + reset_started=1 + reset_state_write "$RESET_STATE/backend" "$RESET_BOOT_BACKEND" || fail "Could not record boot backend" + source "$OMARCHY_PATH/bin/omarchy-mac-snapper-backend" + # shellcheck disable=SC2034 # consumed by the sourced recovery maintenance helpers + BACKEND_MASKED=() BACKEND_TIMERS=() BACKEND_DAEMON=0 + BACKEND_SERVICE_RECEIPT="$RESET_STATE/services" + (umask 077; : >"$BACKEND_SERVICE_RECEIPT") + reset_services_started=1 + backend_quiesce || fail "Could not obtain exclusive Snapper maintenance" + reset_limine_quiesce "$RESET_STATE" || fail "Could not obtain exclusive Limine maintenance" + reset_state_write "$RESET_STATE/phase" preparing || fail "Could not record reset preparation" log "Cloning the factory snapshot" - [[ -d $next ]] && btrfs subvolume delete --recursive "$next" >/dev/null btrfs subvolume snapshot "$top/@factory" "$next" >>"$LOG_FILE" - - local unit_src="$next/usr/share/omarchy/install/provisioning" - [[ -f $unit_src/omarchy-provision-owner.service && -x $next/usr/bin/omarchy-provision-owner ]] || - fail "the factory snapshot predates provisioning support; cannot reset from it" - - log "Scrubbing machine identity from the factory system" - systemd-id128 new >"$next/etc/machine-id" - rm -f "$next"/etc/ssh/ssh_host_* - rm -f "$next"/etc/NetworkManager/system-connections/* - rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" - rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" - - # A factory snapshot from a normal (normal) install contains the original - # user account; first-boot setup must start from none. A leftover account - # would keep its password hash and group memberships (including wheel), so - # failure here has to abort the reset, not be shrugged off. - local user + RESET_TXN_NEXT=$(reset_uuid "$next") || fail "Could not identify staged root" + reset_transaction_record "$RESET_STATE" || fail "Could not record staged root identity" + + # Use exact current worker/owner/helper bytes even when the factory baseline + # predates the fix. The baseline's application payload stays at its version. + install -d -m 700 "$next$PROVISIONING_DIR" + # Reusable installer inputs are distinct from previous-owner state. Never + # deliver authorized_keys, setup-user or a prior completed re-key receipt. + find "$next$PROVISIONING_DIR" -mindepth 1 -maxdepth 1 ! -name packages ! -name groups -exec rm -rf -- {} + + reset_closure_install "$OMARCHY_PATH" "$next" "$next$PROVISIONING_DIR/reset-closure.sha256" || fail "Current reset closure could not be delivered" + log "Scrubbing identity from the staged factory system" for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do - log "Removing user $user from the factory system" - userdel --root "$next" "$user" 2>>"$LOG_FILE" || - fail "could not remove user $user from the factory system (see $LOG_FILE)" + userdel --root "$next" "$user" 2>>"$LOG_FILE" || fail "Could not remove factory user $user" done - passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true - - # @factory itself survives the wipe as the baseline for future resets. If it - # came from a normal install it still holds the seller's account and - # /etc/shadow, which the new wheel user could mount and read — and a second - # reset would restore that account. Scrub it once, in place. - sanitize_factory_baseline "$top/@factory" + # Locking prefixes the former hash with ! and retains it. Replace the root + # credential completely and remove shadow/passwd backups made by userdel. + usermod --root "$next" --password '!' root || fail "Could not clear former root credential" + rm -f "$next/etc/passwd-" "$next/etc/shadow-" "$next/etc/group-" "$next/etc/gshadow-" + rm -f "$next"/etc/ssh/ssh_host_* "$next"/etc/NetworkManager/system-connections/* + rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" "$next/var/lib/fprint" + rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" + # Root-local /home can hold old data too; external @home is separately in + # the displayed UUID inventory. Refuse a mounted/custom staging home. + [[ ! -L $next/home ]] || fail "Factory /home is an unsupported symlink" + find "$next/home" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + [[ -d $next/root && ! -L $next/root ]] || fail "Factory root home is unsupported" + find "$next/root" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + : >"$next/etc/machine-id" + rm -f "$next$PROVISIONING_DIR/pending" "$next$PROVISIONING_DIR/wipe-pending" "$next$PROVISIONING_DIR/luks-key" \ + "$next/etc/omarchy/provisioning.key" "$next/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf" \ + "$next/etc/default/grub.d/99-omarchy-provisioning-unlock.cfg" "$next/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf" - # Keep the Node tarball reachable for offline first-boot finalization. if ! compgen -G "$next$PROVISIONING_DIR/packages/node-v*.tar.gz" >/dev/null; then if compgen -G "$PROVISIONING_DIR/packages/node-v*.tar.gz" >/dev/null; then install -d -m 755 "$next$PROVISIONING_DIR/packages" @@ -363,23 +391,55 @@ stage_full_reset() { fi fi - install -d -m 755 "$next$PROVISIONING_DIR" + [[ ! -L $next/var/lib/omarchy/factory-reset.lock ]] || fail "Unsafe factory maintenance lock" + : >"$next/var/lib/omarchy/factory-reset.lock" + chmod 644 "$next/var/lib/omarchy/factory-reset.lock" + + # The sanitized baseline remains usable if explicitly restored later. + # It contains no auto-unlock key or wipe inventory, and asks for an owner. + touch "$next$PROVISIONING_DIR/pending" + install -Dm644 "$next/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" "$next/etc/systemd/system/omarchy-provision-owner.service" + install -d "$next/etc/systemd/system/multi-user.target.wants" + ln -sf /etc/systemd/system/omarchy-provision-owner.service "$next/etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service" + + # Capture a sanitized reusable factory BEFORE introducing an auto-unlock + # secret. The original factory remains untouched until the final exchange. + btrfs subvolume snapshot -r "$next" "$clean" >>"$LOG_FILE" + RESET_TXN_CLEAN=$(reset_uuid "$clean") || fail "Could not identify sanitized factory" + reset_transaction_record "$RESET_STATE" || fail "Could not record sanitized factory identity" + systemd-id128 new >"$next/etc/machine-id" + install -d -m 700 "$next$PROVISIONING_DIR" + cp "$RESET_STATE/inventory" "$next$PROVISIONING_DIR/reset-inventory" + printf '%s %s %s\n' "$RESET_TXN_FS" "$RESET_TXN_NEXT" "$RESET_TXN_CLEAN" >"$next$PROVISIONING_DIR/reset-identities" + chmod 600 "$next$PROVISIONING_DIR/reset-inventory" "$next$PROVISIONING_DIR/reset-identities" touch "$next$PROVISIONING_DIR/pending" "$next$PROVISIONING_DIR/wipe-pending" + printf 'pending %s %s\n' "$RESET_TXN_FS" "$RESET_TXN_NEXT" >"$next/var/lib/omarchy/factory-reset.lock" + install_provisioning_units "$next" "$next/usr/share/omarchy/install/provisioning" + if encrypted_install; then stage_luks_rekey "$next"; fi - install_provisioning_units "$next" "$unit_src" - - if encrypted_install; then - stage_luks_rekey "$next" + if [[ $RESET_BOOT_BACKEND == grub ]]; then + reset_boot_prepare "$next" "$RESET_STATE/boot" provision || fail "Factory GRUB generation or verification failed" + reset_boot_backup "$RESET_STATE/boot" provision || fail "Could not preserve scoped original boot files" + else + # Preserve the existing Limine/UKI generation path. Its validation remains + # separate from the new GRUB staged publication/rollback qualification. + rebuild_next_boot "$next" fi - - rebuild_next_boot "$next" - - log "Activating the factory system" - local old="@omarchy-old-$(date +%s)" - mv "$top/@" "$top/$old" - mv "$next" "$top/@" + reset_inventory_verify_sources "$top" "$RESET_STATE/inventory" || fail "Confirmed inventory changed during preparation" + reset_state_write "$RESET_STATE/phase" ready || fail "Could not record reset readiness" + if [[ $RESET_BOOT_BACKEND == grub ]]; then + reset_boot_publish "$RESET_STATE/boot" provision || fail "Could not publish verified factory boot files" + fi + reset_state_write "$RESET_STATE/phase" exchanging || fail "Could not record root exchange" + mv -T "$top/@factory" "$top/@omarchy-old-factory-$RESET_TXN_STAMP" + mv -T "$clean" "$top/@factory" + mv -T "$top/@" "$top/@omarchy-old-$RESET_TXN_STAMP" + mv -T "$next" "$top/@" + [[ $(reset_uuid "$top/@") == "$RESET_TXN_NEXT" && $(reset_uuid "$top/@factory") == "$RESET_TXN_CLEAN" ]] || fail "Root exchange identity check failed" + printf 'pending %s %s\n' "$RESET_TXN_FS" "$RESET_TXN_NEXT" >/var/lib/omarchy/factory-reset.lock + reset_state_write "$RESET_STATE/phase" committed || fail "Could not record completed exchange" + sync -f "$top" swap_done=1 - sync } # A reset is a clone of @factory, so a machine without one has nothing to @@ -406,7 +466,22 @@ main() { touch "$LOG_FILE" chmod 600 "$LOG_FILE" - swap_done=0 + swap_done=0 reset_started=0 reset_journal_created=0 reset_services_started=0 + install -d -m 755 /var/lib/omarchy + [[ ! -L /var/lib/omarchy/factory-reset.lock ]] || fail "Unsafe reset lock" + exec 8<>/var/lib/omarchy/factory-reset.lock + chmod 644 /var/lib/omarchy/factory-reset.lock + flock -n 8 || fail "An update or factory reset holds system maintenance" + [[ ! -s /var/lib/omarchy/factory-reset.lock ]] || fail "A staged reset is pending; reboot to finish it before another reset" + # Current updates hold the shared maintenance lock. This process check + # also rejects an older/already-running updater from before its creation. + local updater_status=0 + pgrep -f '(^|/|[[:space:]])omarchy-(update([[:space:]/-]|$)|channel-set([[:space:]]|$)|migrate([[:space:]]|$))' >/dev/null || updater_status=$? + (( updater_status == 1 )) || fail "Finish all Omarchy updates, channel changes and migrations before factory reset" + [[ ! -e /var/lib/pacman/db.lck && ! -L /var/lib/pacman/db.lck ]] || fail "Finish the package transaction before factory reset" + # Share the recovery exclusion lock while inventory/root identities move. + exec 9>/run/omarchy-snapper-layout.lock + flock -n 9 || fail "Another snapshot recovery operation is active" local device device=$(root_device) @@ -416,15 +491,25 @@ main() { mountpoint -q "$TOP_MNT" || mount -o subvolid=5 "$device" "$TOP_MNT" require_factory_snapshot - + [[ ! -e $TOP_MNT/.omarchy-snapper-restore && ! -L $TOP_MNT/.omarchy-snapper-restore ]] || fail "Finish pending snapshot recovery before reset" + [[ ! -e $RESET_STATE && ! -L $RESET_STATE ]] || fail "A prior reset journal exists at $RESET_STATE; inspect it before starting another reset" + for device in "$TOP_MNT/@omarchy-reset-next" "$TOP_MNT/@omarchy-reset-factory" "$TOP_MNT/@omarchy-reset-home" "$TOP_MNT/@omarchy-reset-log"; do + [[ ! -e $device && ! -L $device ]] || fail "Prior reset staging remains: $device" + done + reset_closure_preflight "$OMARCHY_PATH" "$TOP_MNT/@factory" || fail "Factory root cannot run the current reset closure" + reset_boot_probe "$TOP_MNT/@factory" || fail "Unsupported factory boot inputs or topology" + install -d -m 700 "$RESET_STATE" + reset_journal_created=1 + RESET_TXN_STAMP=$(date +%s) + RESET_TXN_FS=$(findmnt -rn -T "$TOP_MNT" -o UUID) + RESET_TXN_ROOT=$(reset_uuid "$TOP_MNT/@") || fail "Could not identify current root" + RESET_TXN_FACTORY=$(reset_uuid "$TOP_MNT/@factory") || fail "Could not identify factory baseline" + RESET_TXN_NEXT=- RESET_TXN_CLEAN=- + reset_transaction_record "$RESET_STATE" || fail "Could not record reset identities" + reset_inventory_build "$TOP_MNT" "$RESET_TXN_STAMP" "$RESET_STATE/inventory" || fail "Could not construct complete reset inventory" + reset_inventory_verify_sources "$TOP_MNT" "$RESET_STATE/inventory" || fail "Reset inventory is not stable" confirm_reset - # The running system's limine-snapper-sync must not rewrite the ESP's - # limine.conf behind the staged rebuild (subvolume changes below can - # trigger it). The runtime mask evaporates on the reboot that follows. - systemctl mask --runtime --now limine-snapper-sync.service >/dev/null 2>&1 || true - systemctl mask --runtime --now limine-snapper-sync.path >/dev/null 2>&1 || true - stage_full_reset umount -R "$TOP_MNT" 2>/dev/null || true diff --git a/bin/omarchy-system-factory-reset-finish b/bin/omarchy-system-factory-reset-finish index 08dc4fd647c..7133c484028 100755 --- a/bin/omarchy-system-factory-reset-finish +++ b/bin/omarchy-system-factory-reset-finish @@ -19,6 +19,8 @@ set -uo pipefail PROVISIONING_DIR=/var/lib/omarchy/provisioning TOP_MNT=/run/omarchy-system-factory-reset-finish-top +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +source "$OMARCHY_PATH/install/helpers/factory-reset.sh" [[ -f $PROVISIONING_DIR/wipe-pending ]] || exit 0 @@ -113,7 +115,7 @@ repair_snapshots_dir() { # (nested subvolumes are not part of snapshots). Snapper needs it to be a # subvolume again. if [[ -d /.snapshots ]] && ! btrfs subvolume show /.snapshots >/dev/null 2>&1; then - rm -rf /.snapshots + rmdir /.snapshots || return $? fi if [[ ! -d /.snapshots ]]; then btrfs subvolume create /.snapshots @@ -121,6 +123,28 @@ repair_snapshots_dir() { fi } +finish_confirmed_inventory() { + local filesystem_uuid current_uuid factory_uuid extra state="$PROVISIONING_DIR/reset-cleanup" journal="$TOP_MNT/.omarchy-factory-reset" + reset_private_file "$PROVISIONING_DIR/reset-identities" || abort "missing or unsafe reset identity receipt" + read -r filesystem_uuid current_uuid factory_uuid extra <"$PROVISIONING_DIR/reset-identities" + [[ -z $extra && $filesystem_uuid =~ ^[a-fA-F0-9-]{36}$ && $current_uuid =~ ^[a-fA-F0-9-]{36}$ && $factory_uuid =~ ^[a-fA-F0-9-]{36}$ ]] || abort "malformed reset identities" + [[ $(findmnt -rn -T / -o UUID) == "$filesystem_uuid" && $(reset_uuid "$TOP_MNT/@") == "$current_uuid" && $(reset_uuid "$TOP_MNT/@factory") == "$factory_uuid" && $(reset_uuid /) == "$current_uuid" ]] || abort "reset root or factory identity changed" + reset_private_file "$PROVISIONING_DIR/reset-closure.sha256" || abort "missing reset closure manifest" + (cd / && sha256sum -c "$PROVISIONING_DIR/reset-closure.sha256") || abort "current reset worker closure changed" + reset_cleanup_inventory "$TOP_MNT" "$PROVISIONING_DIR/reset-inventory" "$state" "$filesystem_uuid" || abort "confirmed cleanup remains incomplete" + reset_recreate_clean_subvolume "$TOP_MNT" @home "$state" || abort "could not recreate confirmed home" + reset_recreate_clean_subvolume "$TOP_MNT" @log "$state" || abort "could not recreate confirmed log" + repair_snapshots_dir || abort "snapshot backend has unexpected state" + if [[ -e $journal || -L $journal ]]; then + reset_transaction_read "$journal" || abort "reset staging journal is unsafe" + [[ $RESET_TXN_FS == "$filesystem_uuid" && $RESET_TXN_NEXT == "$current_uuid" && $RESET_TXN_CLEAN == "$factory_uuid" ]] || abort "staging journal belongs to another operation" + # Boot backups and generated provisioning images can contain auto-unlock + # material. They are operation-owned data, not a persistent recovery copy. + rm -rf -- "$journal" || abort "could not erase reset staging material" + fi + sync -f "$PROVISIONING_DIR" || abort "could not persist wipe completion" +} + main() { local device device=$(root_device) @@ -135,7 +159,16 @@ main() { exit 1 fi - [[ -f $PROVISIONING_DIR/wipe-degraded ]] && scrub_legacy_degraded_state + if [[ -e $PROVISIONING_DIR/reset-inventory || -L $PROVISIONING_DIR/reset-inventory ]]; then + finish_confirmed_inventory + else + # An already-staged legacy reset has no authorization for newly covered + # history. Preserve it visibly rather than claiming an incomplete erase. + local retained + for retained in "$TOP_MNT"/@old-* "$TOP_MNT"/@fresh; do + [[ ! -e $retained && ! -L $retained ]] || abort "legacy reset has unconfirmed retained history at $retained; re-stage with the current reset command and explicit inventory" + done + [[ -f $PROVISIONING_DIR/wipe-degraded ]] && scrub_legacy_degraded_state local old for old in "$TOP_MNT"/@omarchy-old-*; do @@ -150,13 +183,15 @@ main() { recreate_subvolume @home || abort "could not recreate @home" recreate_subvolume @log || abort "could not recreate @log" - repair_snapshots_dir + repair_snapshots_dir || abort "could not prepare snapshot backend" + fi umount "$TOP_MNT" rmdir "$TOP_MNT" 2>/dev/null || true - log "trimming free space" - fstrim -a 2>/dev/null || true + [[ ! -L /var/lib/omarchy/factory-reset.lock ]] || abort "unsafe maintenance lock" + : >/var/lib/omarchy/factory-reset.lock + chmod 644 /var/lib/omarchy/factory-reset.lock rm -f "$PROVISIONING_DIR/wipe-pending" "$PROVISIONING_DIR/wipe-degraded" rm -f /etc/systemd/system/sysinit.target.wants/omarchy-system-factory-reset-finish.service diff --git a/bin/omarchy-update-lock b/bin/omarchy-update-lock index ba3c97b6404..73b11d6f911 100755 --- a/bin/omarchy-update-lock +++ b/bin/omarchy-update-lock @@ -37,6 +37,22 @@ case "${1:-}" in exit 1 fi + # Factory reset creates this stable root-owned lock before its first + # operation. Keep a shared descriptor for the entire update, distinct + # from the recovery-layout lock used by migrations within this update. + maintenance_lock=/var/lib/omarchy/factory-reset.lock + if [[ -e $maintenance_lock || -L $maintenance_lock ]]; then + if [[ ! -f $maintenance_lock || -L $maintenance_lock || $(stat -c %u "$maintenance_lock") != 0 ]]; then + echo "Unsafe system maintenance lock; inspect $maintenance_lock." + exit 1 + fi + exec {OMARCHY_MAINTENANCE_FD}<"$maintenance_lock" + if [[ -s $maintenance_lock ]] || ! flock -sn "$OMARCHY_MAINTENANCE_FD"; then + echo "Factory reset is active; finish it before updating." + exit 1 + fi + fi + export OMARCHY_UPDATE_LOCK_FD exec "$@" ;; diff --git a/docs/btrfs.md b/docs/btrfs.md index 51979a9ac90..6dee5a3acab 100644 --- a/docs/btrfs.md +++ b/docs/btrfs.md @@ -162,10 +162,19 @@ Recovery can automatically reattach history lost by an earlier restore only when cared about, so treat a backup as mandatory. - Only the busybox `encrypt` hook is wired up. An initramfs built around the systemd hooks (`sd-encrypt`) is rejected rather than half-configured. -- On encrypted installs, `omarchy-system-factory-reset`'s provisioning-window - auto-unlock injects its kernel argument via Limine's entry tool, which does - not exist on the Mac's GRUB boot chain. The reset still works; the first - boot after it asks for the disk passphrase instead of unlocking itself. +- Factory reset supports the existing Asahi GRUB layout with the ESP mounted at `/boot`, the selected factory root's packaged kernel image/modules and its standard default-image preset. It stages and verifies matching kernel, initramfs, GRUB configuration and Asahi boot bundle before selecting the new root. Other boot topologies, ambiguous kernels and custom active preset options need explicit support and are refused before reset preparation. + +## Factory reset and retained history + +`sudo omarchy-system-factory-reset` displays every subvolume path and UUID it intends to erase before the existing `reset` confirmation. This includes the displaced root, previous `@old-*`/`@omarchy-old-*` roots, `@fresh`, home/log, and every nested snapshot. Legacy names alone do not establish ownership: confirm only if every displayed identity belongs to the reset. Unlisted administrator subvolumes remain outside the reset. Mounted descendants, new nested subvolumes or changed UUIDs stop cleanup and keep provisioning blocked. + +The command delivers the current reset worker, owner setup and required helpers into the selected historical factory root. It removes old account credentials, account database backups and previous provisioning state before capturing a sanitized replacement `@factory`. The first boot erases only the confirmed inventory and recreates home/log with recorded identities; a partial failure retains its receipt for retry. This is deletion, not forensic secure erase. + +On encrypted GRUB installations the verified provisioning initramfs includes a temporary auto-unlock key. Owner setup rebuilds the boot files without that key, verifies the owner's LUKS slot, then retires the previous slots. An interrupted retirement is retried with the same confirmed owner disk password, even if the temporary key's slot has already been removed. Do not discard a pending re-key receipt or substitute a different owner password during that retry. + +Finish package updates, snapshots and other disk maintenance before reset. Current Omarchy updates share the reset exclusion lock; direct administrator boot/key/subvolume writes must remain stopped. A staged reset blocks further updates until its reboot and first-boot wipe complete. Ordinary GRUB publication/root exchange failures reconcile the exact original boot bytes, root and factory identities; failed staging remains available for inspection. Do not manually delete an interrupted journal or rename unfamiliar roots. A power loss across the separate boot-file/root renames can still require a rescue boot and receipt-based manual reconciliation; the journal is not a firmware rollback mechanism. + +The existing Limine generator remains the x86 route. Its generator/UKI behavior is separate from the Asahi GRUB qualification. A generic ARM GRUB layout can use an already maintained `vmlinuz-linux` alias of the matching packaged `Image` for component testing; that alias is not created by reset and does not establish support for stock `linux-aarch64` package-update synchronization. A stale active alias is refused. ## Testing changes to the migration diff --git a/install/helpers/factory-reset.sh b/install/helpers/factory-reset.sh new file mode 100644 index 00000000000..4506c883541 --- /dev/null +++ b/install/helpers/factory-reset.sh @@ -0,0 +1,463 @@ +# Shared factory-reset inventory and current-code delivery. Sourced by the +# staging command and its first-boot worker; no action is performed on source. + +reset_error() { echo "Error: $*" >&2; return 1; } +reset_uuid() { + local info identity + [[ ! -L $1 && -d $1 ]] || return 1 + info=$(LC_ALL=C btrfs subvolume show "$1") || return $? + identity=$(awk '$1=="UUID:" {print $2; exit}' <<<"$info") + [[ $identity =~ ^[a-fA-F0-9-]{36}$ ]] || return 1 + printf '%s\n' "$identity" +} +reset_safe_path() { + [[ $1 =~ ^[a-zA-Z0-9@._/-]+$ && $1 != /* && $1 != */ && $1 != *//* && /$1/ != */../* && /$1/ != */./* ]] +} +reset_nested_paths() { + local listing line path + listing=$(LC_ALL=C btrfs subvolume list -o "$1") || return $? + while IFS= read -r line; do + [[ -n $line ]] || continue + [[ $line == ID\ *\ path\ * ]] || return 1 + path=${line#* path } + path=${path#/} + reset_safe_path "$path" || return 1 + printf '%s\n' "$path" + done <<<"$listing" +} +reset_inventory_add() { + local top=$1 source=$2 destination=$3 role=$4 manifest=$5 path identity relative + reset_safe_path "$source" && reset_safe_path "$destination" || return 1 + identity=$(reset_uuid "$top/$source") || return $? + printf '%s\t%s\t%s\t%s\n' "$identity" "$source" "$destination" "$role" >>"$manifest" || return $? + local nested + nested=$(reset_nested_paths "$top/$source") || return $? + while IFS= read -r path; do + [[ -n $path ]] || continue + [[ $path == "$source/"* ]] || { reset_error "Unexpected nested subvolume: $path"; return 1; } + relative=${path#"$source/"} + identity=$(reset_uuid "$top/$path") || return $? + printf '%s\t%s\t%s/%s\t%s\n' "$identity" "$path" "$destination" "$relative" "nested-$role" >>"$manifest" || return $? + done <<<"$nested" +} +reset_inventory_build() { + local top=$1 stamp=$2 manifest=$3 candidate + [[ $stamp =~ ^[0-9]+$ && ! -e $manifest && ! -L $manifest ]] || return 1 + (umask 077; set -o noclobber; : >"$manifest") || return $? + reset_inventory_add "$top" @ "@omarchy-old-$stamp" current-root "$manifest" || return $? + reset_inventory_add "$top" @factory "@omarchy-old-factory-$stamp" factory-baseline "$manifest" || return $? + for candidate in @home @log @fresh; do + [[ -e $top/$candidate || -L $top/$candidate ]] || continue + reset_inventory_add "$top" "$candidate" "$candidate" "$candidate" "$manifest" || return $? + done + for candidate in "$top"/@old-* "$top"/@omarchy-old-*; do + [[ -e $candidate || -L $candidate ]] || continue + reset_inventory_add "$top" "${candidate##*/}" "${candidate##*/}" legacy-retained-root "$manifest" || return $? + done + reset_inventory_validate "$manifest" || return $? + sync -f "$manifest" +} +reset_inventory_validate() { + local manifest=$1 identity source destination role extra + [[ -f $manifest && ! -L $manifest && $(stat -c %u "$manifest") == 0 && $(stat -c %a "$manifest") == 600 ]] || return 1 + local -A identities=() destinations=() + while IFS=$'\t' read -r identity source destination role extra; do + [[ $identity =~ ^[a-fA-F0-9-]{36}$ && -z $extra && -n $role ]] || return 1 + reset_safe_path "$source" && reset_safe_path "$destination" || return 1 + [[ ! ${identities[$identity]+present} && ! ${destinations[$destination]+present} ]] || return 1 + [[ $destination != @ && $destination != @factory ]] || return 1 + identities[$identity]=1 destinations[$destination]=1 + done <"$manifest" + (( ${#identities[@]} > 0 )) +} +reset_inventory_verify_sources() { + local top=$1 manifest=$2 identity source destination role nested path + local -A planned=() + reset_inventory_validate "$manifest" || return $? + while IFS=$'\t' read -r identity source destination role; do + [[ $(reset_uuid "$top/$source") == "$identity" ]] || { reset_error "Reset source identity changed: $source"; return 1; } + if [[ $source != "$destination" && (-e $top/$destination || -L $top/$destination) ]]; then + reset_error "Reset destination already exists: $destination" + return 1 + fi + planned[$source]=$identity + done <"$manifest" + while IFS=$'\t' read -r identity source destination role; do + nested=$(reset_nested_paths "$top/$source") || return $? + while IFS= read -r path; do + [[ -z $path ]] && continue + [[ ${planned[$path]+yes} && $(reset_uuid "$top/$path") == "${planned[$path]}" ]] || { + reset_error "Unconfirmed descendant appeared: $path"; return 1; + } + done <<<"$nested" + done <"$manifest" +} +reset_inventory_display() { + local manifest=$1 identity source destination role + echo "The following exact subvolumes, including every listed nested subvolume, will be erased:" + while IFS=$'\t' read -r identity source destination role; do + printf ' %-34s %s [%s]\n' "$source" "$identity" "$role" + done <"$manifest" + echo "Legacy names are not ownership proof: confirm only if every listed identity is intended for deletion." + echo "Other administrator subvolumes and filesystems are outside this reset and remain untouched." +} +reset_assert_unmounted() { + local top=$1 path=$2 filesystem_uuid=$3 mounts target uuid fsroot extra + mounts=$(findmnt -rn --raw -o TARGET,UUID,FSROOT) || return $? + while read -r target uuid fsroot extra; do + [[ $target != "$top/$path" && $target != "$top/$path/"* ]] || { reset_error "Mounted cleanup descendant: $target"; return 1; } + if [[ $uuid == "$filesystem_uuid" && ($fsroot == "/$path" || $fsroot == "/$path/"*) ]]; then + reset_error "Cleanup subvolume is mounted at $target: $path" + return 1 + fi + done <<<"$mounts" +} +reset_private_file() { + [[ -f $1 && ! -L $1 && $(stat -c %u "$1") == 0 && $(stat -c %a "$1") == 600 ]] +} +reset_state_write() { + local target=$1 value=$2 temporary + [[ ! -L $target && (! -e $target || -f $target) ]] || return 1 + temporary=$(mktemp "${target}.new.XXXXXXXX") || return $? + if ! { chmod 600 "$temporary" && printf '%s\n' "$value" >"$temporary" && sync -f "$temporary" && mv -T "$temporary" "$target" && sync -f "${target%/*}"; }; then + rm -f "$temporary" + return 1 + fi +} +reset_state_bind() { + local manifest=$1 state=$2 filesystem_uuid=$3 digest expected contents + [[ $filesystem_uuid =~ ^[a-fA-F0-9-]{36}$ ]] || return 1 + [[ ! -L $state && (! -e $state || -d $state) ]] || return 1 + if [[ -e $state ]]; then + [[ $(stat -c %u "$state") == 0 && $(stat -c %a "$state") == 700 ]] || return 1 + else + install -d -m 700 "$state" || return $? + fi + digest=$(sha256sum "$manifest") || return $? + expected="$filesystem_uuid ${digest%% *}" + if [[ -e $state/binding || -L $state/binding ]]; then + reset_private_file "$state/binding" && [[ $(cat "$state/binding") == "$expected" ]] || return 1 + else + # Refuse pre-existing progress without a binding, including a failed bind. + contents=$(find "$state" -mindepth 1 -maxdepth 1 -print -quit) || return $? + [[ -z $contents ]] || return 1 + reset_state_write "$state/binding" "$expected" || return $? + fi +} +reset_row_state() { + local top=$1 state=$2 identity=$3 destination=$4 marker="$2/$3" current replacement + RESET_ROW_PRESENT=0 + [[ ! -e $marker && ! -L $marker ]] || reset_private_file "$marker" || return 1 + if [[ ! -e $top/$destination && ! -L $top/$destination ]]; then + [[ -f $marker && ($(cat "$marker") == "intent $destination" || $(cat "$marker") == "done $destination") ]] || { + reset_error "Cleanup path vanished without its deletion receipt: $destination"; return 1; + } + return 0 + fi + current=$(reset_uuid "$top/$destination") || return $? + if [[ $current == "$identity" ]]; then + [[ ! -e $marker || $(cat "$marker") == "intent $destination" ]] || return 1 + RESET_ROW_PRESENT=1 + return 0 + fi + # Home/log replacements belong to this operation only after their UUID was + # durably recorded. An arbitrary replacement never inherits authorization. + [[ $destination == @home || $destination == @log ]] || return 1 + replacement="$state/replacement-${destination#@}" + reset_private_file "$marker" && [[ $(cat "$marker") == "done $destination" ]] || return 1 + reset_private_file "$replacement" && [[ $(cat "$replacement") == "$current $destination" ]] || { + reset_error "Cleanup identity changed: $destination"; return 1; + } +} +reset_cleanup_preflight() { + local top=$1 manifest=$2 state=$3 filesystem_uuid=$4 identity source destination role nested path + local -A planned=() + reset_inventory_validate "$manifest" && reset_state_bind "$manifest" "$state" "$filesystem_uuid" || return $? + while IFS=$'\t' read -r identity source destination role; do planned[$destination]=$identity; done <"$manifest" + while IFS=$'\t' read -r identity source destination role; do + reset_row_state "$top" "$state" "$identity" "$destination" || return $? + (( RESET_ROW_PRESENT )) || continue + reset_assert_unmounted "$top" "$destination" "$filesystem_uuid" || return $? + nested=$(reset_nested_paths "$top/$destination") || return $? + while IFS= read -r path; do + [[ -z $path ]] && continue + [[ ${planned[$path]+yes} && $(reset_uuid "$top/$path") == "${planned[$path]}" ]] || { + reset_error "Unconfirmed cleanup descendant: $path"; return 1; + } + done <<<"$nested" + done <"$manifest" +} +reset_cleanup_inventory() { + local top=$1 manifest=$2 state=$3 filesystem_uuid=$4 identity source destination role marker nested ordered + reset_cleanup_preflight "$top" "$manifest" "$state" "$filesystem_uuid" || return $? + # Inspect the whole inventory before deleting anything, then recheck each + # row immediately before its non-recursive deletion. New children stop it. + ordered=$(awk -F '\t' '{n=split($3,p,"/"); print n "\t" $0}' "$manifest" | LC_ALL=C sort -t $'\t' -k1,1nr | cut -f2-) || return $? + while IFS=$'\t' read -r identity source destination role; do + marker="$state/$identity" + reset_row_state "$top" "$state" "$identity" "$destination" || return $? + if (( ! RESET_ROW_PRESENT )); then + if [[ ! -e $top/$destination && ! -L $top/$destination ]]; then + reset_state_write "$marker" "done $destination" || return $? + fi + continue + fi + reset_assert_unmounted "$top" "$destination" "$filesystem_uuid" || return $? + nested=$(reset_nested_paths "$top/$destination") || return $? + [[ -z $nested ]] || { reset_error "Unremoved or unlisted child under $destination; preserving it"; return 1; } + reset_state_write "$marker" "intent $destination" || return $? + btrfs subvolume delete "$top/$destination" || return $? + reset_state_write "$marker" "done $destination" || return $? + done <<<"$ordered" +} +reset_empty_volume() { + local contents + contents=$(find "$1" -mindepth 1 -maxdepth 1 -print -quit) || return $? + [[ -z $contents ]] +} +reset_recreate_clean_subvolume() { + local top=$1 name=$2 state=$3 identity marker="$3/replacement-${2#@}" temporary="$1/@omarchy-reset-${2#@}" + [[ $name == @home || $name == @log ]] || return 1 + if [[ -e $top/$name || -L $top/$name ]]; then + identity=$(reset_uuid "$top/$name") || return $? + reset_private_file "$marker" && [[ $(cat "$marker") == "$identity $name" && ! -e $temporary && ! -L $temporary ]] || { + reset_error "Unrecorded replacement at $name; inspect before retry"; return 1; + } + return 0 + fi + if [[ -e $marker || -L $marker ]]; then + reset_private_file "$marker" || return 1 + identity=$(reset_uuid "$temporary") || return $? + [[ $(cat "$marker") == "$identity $name" ]] || return 1 + reset_empty_volume "$temporary" || return $? + else + [[ ! -e $temporary && ! -L $temporary ]] || return 1 + btrfs subvolume create "$temporary" || return $? + identity=$(reset_uuid "$temporary") || return $? + if ! reset_state_write "$marker" "$identity $name"; then + # Only this process's just-created, still-empty exact UUID can be + # removed on a receipt failure. Unknown leftover state is preserved. + if [[ $(reset_uuid "$temporary") == "$identity" ]] && reset_empty_volume "$temporary"; then + btrfs subvolume delete "$temporary" || return $? + fi + return 1 + fi + fi + mv -T "$temporary" "$top/$name" || return $? + [[ $(reset_uuid "$top/$name") == "$identity" ]] || return 1 + sync -f "$top" +} +reset_closure_files() { + printf '%s\n' \ + bin/omarchy-system-factory-reset \ + bin/omarchy-system-factory-reset-finish \ + bin/omarchy-mac-snapper-backend \ + bin/omarchy-update-lock \ + bin/omarchy-provision-owner \ + install/helpers/factory-reset.sh \ + install/helpers/browser-policy.sh \ + install/helpers/as-root.sh \ + install/helpers/reset-boot.sh \ + install/helpers/owner-rekey.sh \ + install/provisioning/setup-form.sh \ + install/provisioning/omarchy-system-factory-reset-finish.service \ + install/provisioning/omarchy-provision-owner.service \ + logo.txt +} +reset_closure_preflight() { + local source=$1 root=$2 file tool directory + for directory in etc var var/lib home root usr usr/bin usr/share usr/share/omarchy; do + [[ -d $root/$directory && ! -L $root/$directory ]] || return 1 + done + for directory in etc/ssh etc/NetworkManager etc/NetworkManager/system-connections etc/omarchy etc/sddm.conf.d etc/mkinitcpio.conf.d etc/default etc/default/grub.d etc/limine-entry-tool.d var/lib/omarchy var/lib/omarchy/provisioning var/lib/NetworkManager var/lib/sddm var/lib/tailscale var/lib/iwd var/lib/fprint; do + [[ ! -L $root/$directory && (! -e $root/$directory || -d $root/$directory) ]] || { + reset_error "Unsupported factory identity-state path: $directory"; return 1; + } + done + while IFS= read -r file; do + [[ -f $source/$file && ! -L $source/$file ]] || { reset_error "Missing current reset closure file: $file"; return 1; } + done < <(reset_closure_files) + for tool in bash btrfs findmnt mount umount systemctl chroot sha256sum awk sort cut install useradd userdel usermod groupadd passwd runuser gum cryptsetup jq flock pgrep; do + [[ -x $root/usr/bin/$tool ]] || { reset_error "Factory baseline lacks required tool: $tool"; return 1; } + done + [[ -x $root/usr/bin/omarchy-provision-user && -r $root/usr/share/omarchy/install/user/all.sh ]] || return 1 + grep -q omarchy-update-lock "$root/usr/bin/omarchy-update" || { reset_error "Factory updater lacks the maintenance-lock interface"; return 1; } + grep -q -- --first-install "$root/usr/bin/omarchy-provision-user" || { reset_error "Factory baseline predates required user provisioning interface"; return 1; } +} +reset_closure_install() { + local source=$1 root=$2 manifest=$3 file destination + (umask 077; : >"$manifest") || return $? + while IFS= read -r file; do + destination="$root/usr/share/omarchy/$file" + reset_closure_destination "$root" "usr/share/omarchy/$file" || return $? + install -Dm644 "$source/$file" "$destination" || return $? + if [[ $file == bin/* ]]; then + chmod 755 "$destination" || return $? + # The old package may use either a real /usr/bin file or a share alias. + # Replace both locations with the exact current closure deliberately. + reset_closure_destination "$root" "usr/bin/${file#bin/}" || return $? + install -Dm755 "$source/$file" "$root/usr/bin/${file#bin/}" || return $? + sha256sum "$source/$file" | awk -v path="/usr/bin/${file#bin/}" '{print $1 " " path}' >>"$manifest" || return $? + fi + sha256sum "$source/$file" | awk -v path="/usr/share/omarchy/$file" '{print $1 " " path}' >>"$manifest" || return $? + done < <(reset_closure_files) + sync -f "$manifest" +} + +reset_closure_destination() { + local root=$1 relative=$2 part current=$1 remaining=${2%/*} + reset_safe_path "$relative" || return 1 + while [[ -n $remaining ]]; do + part=${remaining%%/*} + if [[ $remaining == */* ]]; then remaining=${remaining#*/}; else remaining=; fi + current="$current/$part" + [[ ! -L $current && (! -e $current || -d $current) ]] || { + reset_error "Historical closure has an unsafe parent: $current"; return 1; + } + done + [[ ! -e $root/$relative || -f $root/$relative || -L $root/$relative ]] || return 1 + # Replace a package alias itself, never follow it outside the selected root. + if [[ -L $root/$relative ]]; then rm -- "$root/$relative" || return $?; fi +} + +# The reset command owns this journal outside both roots. A boot failure or +# rename failure can therefore restore the original root and baseline without +# relying on whichever /var/lib tree is selected for the next boot. +reset_transaction_read() { + local state=$1 extra + [[ -d $state && ! -L $state && $(stat -c %u "$state") == 0 && $(stat -c %a "$state") == 700 ]] || return 1 + reset_private_file "$state/identities" || return 1 + read -r RESET_TXN_FS RESET_TXN_STAMP RESET_TXN_ROOT RESET_TXN_FACTORY RESET_TXN_NEXT RESET_TXN_CLEAN extra <"$state/identities" + [[ -z $extra && $RESET_TXN_STAMP =~ ^[0-9]+$ ]] || return 1 + local identity + for identity in "$RESET_TXN_FS" "$RESET_TXN_ROOT" "$RESET_TXN_FACTORY"; do [[ $identity =~ ^[a-fA-F0-9-]{36}$ ]] || return 1; done + for identity in "$RESET_TXN_NEXT" "$RESET_TXN_CLEAN"; do [[ $identity == - || $identity =~ ^[a-fA-F0-9-]{36}$ ]] || return 1; done +} +reset_transaction_record() { + reset_state_write "$1/identities" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY $RESET_TXN_NEXT $RESET_TXN_CLEAN" +} +reset_transaction_rollback() { + local top=$1 state=$2 current + reset_transaction_read "$state" || return $? + [[ $(findmnt -rn -T "$top" -o UUID) == "$RESET_TXN_FS" ]] || return 1 + # Recognize exact identities at both sides of each rename. Never overwrite + # an occupied destination or clean an unknown interrupted staging object. + if [[ -e $top/@ || -L $top/@ ]]; then + current=$(reset_uuid "$top/@") || return $? + if [[ $current == "$RESET_TXN_NEXT" ]]; then + [[ ! -e $top/@omarchy-reset-next && ! -L $top/@omarchy-reset-next ]] || return 1 + mv -T "$top/@" "$top/@omarchy-reset-next" || return $? + else [[ $current == "$RESET_TXN_ROOT" ]] || return 1; fi + fi + if [[ ! -e $top/@ && ! -L $top/@ ]]; then + [[ $(reset_uuid "$top/@omarchy-old-$RESET_TXN_STAMP") == "$RESET_TXN_ROOT" ]] || return 1 + mv -T "$top/@omarchy-old-$RESET_TXN_STAMP" "$top/@" || return $? + fi + if [[ -e $top/@factory || -L $top/@factory ]]; then + current=$(reset_uuid "$top/@factory") || return $? + if [[ $current == "$RESET_TXN_CLEAN" ]]; then + [[ ! -e $top/@omarchy-reset-factory && ! -L $top/@omarchy-reset-factory ]] || return 1 + mv -T "$top/@factory" "$top/@omarchy-reset-factory" || return $? + else [[ $current == "$RESET_TXN_FACTORY" ]] || return 1; fi + fi + if [[ ! -e $top/@factory && ! -L $top/@factory ]]; then + [[ $(reset_uuid "$top/@omarchy-old-factory-$RESET_TXN_STAMP") == "$RESET_TXN_FACTORY" ]] || return 1 + mv -T "$top/@omarchy-old-factory-$RESET_TXN_STAMP" "$top/@factory" || return $? + fi + if [[ -e $state/boot/publication || -L $state/boot/publication ]]; then + reset_private_file "$state/boot/publication" || return 1 + if [[ $(cat "$state/boot/publication") != rolled-back ]]; then + reset_boot_rollback "$state/boot" provision || return $? + fi + fi + if [[ -e $state/staged-luks || -L $state/staged-luks ]]; then + if reset_private_file "$state/backend" && [[ $(cat "$state/backend") == grub ]]; then + reset_staged_luks_rollback "$top" "$state" || return $? + else + reset_error "Limine reset key-slot intent retained for manual boot/key reconciliation: $state/staged-luks" || true + fi + fi + [[ $(reset_uuid "$top/@") == "$RESET_TXN_ROOT" && $(reset_uuid "$top/@factory") == "$RESET_TXN_FACTORY" ]] || return 1 + reset_state_write "$state/phase" rolled-back || return $? + sync -f "$top" +} + +reset_cancel_journal() { + local state=$1 expected=$2 + reset_private_file "$state/identities" || return 1 + [[ $(cat "$state/identities") == "$expected" && ! -e $state/phase && ! -L $state/phase && ! -e $state/services && ! -L $state/services ]] || return 1 + [[ ! -L $state/inventory && (! -e $state/inventory || -f $state/inventory) ]] || return 1 + rm -f -- "$state/identities" "$state/inventory" || return $? + # Unknown additional state is preserved, never swept up on cancellation. + rmdir "$state" +} + +reset_staged_luks_rollback() { + local top=$1 state=$2 uuid slot old_slots extra device slots previous key="$1/@omarchy-reset-next/var/lib/omarchy/provisioning/luks-key" + reset_private_file "$state/staged-luks" || return 1 + read -r uuid slot old_slots extra <"$state/staged-luks" + [[ $uuid =~ ^[a-fA-F0-9-]{36}$ && $slot =~ ^[0-9]+$ && $slot -le 31 && $old_slots =~ ^[0-9]+(,[0-9]+)*$ && -z $extra ]] || return 1 + device="/dev/disk/by-uuid/$uuid" + [[ -b $device && $(cryptsetup luksUUID "$device") == "$uuid" ]] || return 1 + slots=$(owner_rekey_slots "$device") || return $? + for previous in ${old_slots//,/ }; do grep -qx "$previous" <<<"$slots" || return 1; done + if grep -qx "$slot" <<<"$slots"; then + reset_private_file "$key" || return 1 + cryptsetup open --test-passphrase --key-slot "$slot" --key-file "$key" "$device" || return $? + cryptsetup luksKillSlot -q --key-file "$key" "$device" "$slot" || return $? + fi + reset_state_write "$state/staged-luks-result" rolled-back +} + +reset_limine_record() { + local state=$1 entry=$2 previous="" + if [[ -e $state/limine-services || -L $state/limine-services ]]; then + reset_private_file "$state/limine-services" || return 1 + previous=$(cat "$state/limine-services") || return $? + fi + reset_state_write "$state/limine-services" "${previous:+$previous$'\n'}$entry" +} +reset_limine_quiesce() { + local state=$1 unit loaded enabled active deadline=$((SECONDS + 30)) service_found=0 + for unit in limine-snapper-sync.service limine-snapper-sync.path; do + loaded=$(systemctl show "$unit" --property=LoadState --value) || return $? + [[ $loaded != not-found ]] || continue + [[ $unit != limine-snapper-sync.service ]] || service_found=1 + enabled=$(systemctl is-enabled "$unit" 2>/dev/null || true) + active=$(systemctl show "$unit" --property=ActiveState --value) || return $? + if [[ $unit == *.path && $active == active ]]; then + [[ $enabled != masked* ]] || { reset_error 'Preserve the administrator masked-but-active Limine path'; return 1; } + reset_limine_record "$state" "path $unit" || return $? + systemctl stop "$unit" || return $? + fi + if [[ $enabled != masked* ]]; then + reset_limine_record "$state" "mask $unit" || return $? + systemctl mask --runtime "$unit" || return $? + fi + done + (( service_found )) || return 0 + while true; do + active=$(systemctl show limine-snapper-sync.service --property=ActiveState --value) || return $? + [[ $active == inactive || $active == failed || -z $active ]] && break + (( SECONDS < deadline )) || { reset_error 'Finish the active Limine writer before reset'; return 1; } + sleep 0.2 + done +} +reset_limine_resume() { + local state=$1 kind unit extra + [[ -e $state/limine-services || -L $state/limine-services ]] || return 0 + reset_private_file "$state/limine-services" || return 1 + local -a masks=() paths=() + local -A seen=() + while read -r kind unit extra; do + [[ -z $extra && ! ${seen["$kind $unit"]+yes} ]] || return 1 + case "$kind $unit" in + 'mask limine-snapper-sync.service'|'mask limine-snapper-sync.path') masks+=("$unit") ;; + 'path limine-snapper-sync.path') paths+=("$unit") ;; + *) return 1 ;; + esac + seen["$kind $unit"]=1 + done <"$state/limine-services" + for unit in "${masks[@]}"; do systemctl unmask --runtime "$unit" || return $?; done + for unit in "${paths[@]}"; do systemctl start "$unit" || return $?; done +} diff --git a/install/helpers/owner-rekey.sh b/install/helpers/owner-rekey.sh new file mode 100644 index 00000000000..c50eaf91c75 --- /dev/null +++ b/install/helpers/owner-rekey.sh @@ -0,0 +1,134 @@ +# LUKS slot retirement is separate from boot generation. Every retry proves +# the same device, owner credential/slot and published boot bytes again. +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/reset-boot.sh" + +owner_rekey_slots() { + local metadata slots + metadata=$(cryptsetup luksDump --dump-json-metadata "$1") || return $? + slots=$(jq -er '.keyslots | keys[]' <<<"$metadata") || return $? + [[ -n $slots ]] || return 1 + local slot + while IFS= read -r slot; do [[ $slot =~ ^[0-9]+$ && $slot -le 31 ]] || return 1; done <<<"$slots" + LC_ALL=C sort -n <<<"$slots" +} +owner_rekey_boot_check() { + local state=$1 digest path extra count=0 + reset_private_file "$state/boot-manifest" || return 1 + local -A seen=() + while read -r digest path extra; do + [[ -z $extra && $digest =~ ^[a-f0-9]{64}$ && ($path == /boot/* || $path == /efi/*) && $path != *'/../'* && ! ${seen[$path]+yes} ]] || return 1 + [[ -f $path && ! -L $path && $(sha256sum "$path") == "$digest "* ]] || return 1 + seen[$path]=1; count=$((count + 1)) + done <"$state/boot-manifest" + (( count > 0 )) +} +owner_rekey_boot_prepare() { + local state=$1 work digest relative extra + reset_boot_probe / || return $? + if [[ $RESET_BOOT_BACKEND == grub ]]; then + work=$(mktemp -d "$state/grub.XXXXXXXX") || return $? + rmdir "$work" || return $? + reset_boot_prepare / "$work" owner || return $? + reset_boot_backup "$work" owner || return $? + if ! reset_boot_publish "$work" owner; then + reset_boot_rollback "$work" owner || { + reset_error "Owner boot rollback incomplete; preserve $work and do not reboot"; return 1; + } + return 1 + fi + : >"$state/boot-manifest" + while read -r digest relative extra; do printf '%s /boot/%s\n' "$digest" "${relative#./}" >>"$state/boot-manifest" || return $?; done <"$work/manifest" + else + # Supplied by the existing Limine provisioning command; retains its + # template rebuild, auto-unlock fallback and actual UKI hash collection. + owner_rekey_limine_boot "$state" || return $? + fi + chmod 600 "$state/boot-manifest" || return $? + owner_rekey_boot_check "$state" || return $? + sync -f "$state/boot-manifest" +} +owner_rekey_remove_auto_unlock() { + rm -f /etc/omarchy/provisioning.key \ + /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf \ + /etc/default/grub.d/99-omarchy-provisioning-unlock.cfg \ + /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf +} +owner_rekey_device_valid() { [[ -b $1 ]]; } +owner_rekey_run() { + local device=$1 staged_key=$2 owner_key=$3 state=$4 uuid slots slot owner_slot="" phase=owner-added digest=- version receipt_uuid extra state_fd owner_password + owner_rekey_device_valid "$device" || return 1 + owner_password=$(cat "$owner_key") || return $? + [[ -n $owner_password ]] || return 1 + uuid=$(cryptsetup luksUUID "$device") || return $? + [[ $uuid =~ ^[a-fA-F0-9-]{36}$ ]] || return 1 + [[ ! -L $state && (! -e $state || -d $state) ]] || return 1 + if [[ ! -e $state ]]; then install -d -m 700 "$state" || return $?; fi + [[ $(stat -c %u "$state") == 0 && $(stat -c %a "$state") == 700 ]] || return 1 + [[ ! -L $state/lock && (! -e $state/lock || -f $state/lock) ]] || return 1 + exec {state_fd}>"$state/lock" || return $? + flock -n "$state_fd" || { exec {state_fd}>&-; return 1; } + # Run the state machine in a subshell so its lock always closes on errors. + ( + if [[ -e $state/receipt || -L $state/receipt ]]; then + reset_private_file "$state/receipt" || return 1 + read -r version receipt_uuid owner_slot phase digest extra <"$state/receipt" + [[ $version == 1 && $receipt_uuid == "$uuid" && $owner_slot =~ ^[0-9]+$ && $owner_slot -le 31 && -z $extra && ($phase == owner-added || $phase == boot-published || $phase == complete) ]] || return 1 + cryptsetup open --test-passphrase --key-slot "$owner_slot" --key-file <(printf '%s' "$owner_password") "$device" || { + reset_error 'Retry needs the previously confirmed owner disk credential'; return 1; + } + if [[ $phase != owner-added ]]; then + [[ $digest =~ ^[a-f0-9]{64}$ && $(sha256sum "$state/boot-manifest") == "$digest "* ]] || return 1 + owner_rekey_boot_check "$state" || return $? + else [[ $digest == - ]] || return 1; fi + else + reset_boot_probe / || return $? + reset_private_file "$staged_key" || return 1 + cryptsetup open --test-passphrase --key-file "$staged_key" "$device" || return $? + slots=$(owner_rekey_slots "$device") || return $? + for slot in $slots; do + if cryptsetup open --test-passphrase --key-slot "$slot" --key-file <(printf '%s' "$owner_password") "$device" 2>/dev/null; then owner_slot=$slot; break; fi + done + if [[ -z $owner_slot ]]; then + cryptsetup luksAddKey --key-file "$staged_key" "$device" <(printf '%s' "$owner_password") || return $? + slots=$(owner_rekey_slots "$device") || return $? + for slot in $slots; do + if cryptsetup open --test-passphrase --key-slot "$slot" --key-file <(printf '%s' "$owner_password") "$device" 2>/dev/null; then owner_slot=$slot; break; fi + done + fi + [[ -n $owner_slot ]] || return 1 + reset_state_write "$state/receipt" "1 $uuid $owner_slot owner-added -" || return $? + fi + if [[ $phase == owner-added ]]; then + reset_boot_probe / || return $? + owner_rekey_remove_auto_unlock || return $? + owner_rekey_boot_prepare "$state" || return $? + digest=$(sha256sum "$state/boot-manifest") || return $? + digest=${digest%% *} + reset_state_write "$state/receipt" "1 $uuid $owner_slot boot-published $digest" || return $? + fi + owner_rekey_boot_check "$state" || return $? + cryptsetup open --test-passphrase --key-slot "$owner_slot" --key-file <(printf '%s' "$owner_password") "$device" || return $? + slots=$(owner_rekey_slots "$device") || return $? + for slot in $slots; do + [[ $slot == "$owner_slot" ]] && continue + cryptsetup luksKillSlot -q --key-file <(printf '%s' "$owner_password") "$device" "$slot" || return $? + done + slots=$(owner_rekey_slots "$device") || return $? + [[ $slots == "$owner_slot" ]] || return 1 + owner_rekey_boot_check "$state" || return $? + reset_state_write "$state/receipt" "1 $uuid $owner_slot complete $digest" || return $? + # Old boot backups contain the provisioning key too. Remove all own + # generation directories after the owner-only header and boot readback. + local work + for work in "$state"/grub.*; do + [[ -e $work || -L $work ]] || continue + [[ -d $work && ! -L $work && $(stat -c %u "$work") == 0 ]] || return 1 + rm -rf -- "$work" || return $? + done + rm -f -- "$staged_key" || return $? + sync -f "$state" + ) + local status=$? + exec {state_fd}>&- + return "$status" +} diff --git a/install/helpers/reset-boot.sh b/install/helpers/reset-boot.sh new file mode 100644 index 00000000000..d1a93c7b2e7 --- /dev/null +++ b/install/helpers/reset-boot.sh @@ -0,0 +1,408 @@ +# Shared reset/owner boot preparation. Generators run only in a private mount +# namespace; publishing is a separate, explicitly verified operation. +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/factory-reset.sh" +RESET_BOOT_HELPER=$(readlink -f "${BASH_SOURCE[0]}") + +reset_boot_probe() { + local root=${1%/} row source filesystem options dump _pass version tool preset lines + [[ -n $root ]] || root=/ + RESET_BOOT_BACKEND="" RESET_BOOT_ASAHI=0 + if [[ -x $root/usr/bin/limine-update && -f $root/etc/default/limine ]]; then + RESET_BOOT_BACKEND=limine + return 0 + fi + [[ -x $root/usr/bin/grub-mkconfig && -f $root/etc/default/grub && ! -L $root/etc/default/grub ]] || { + reset_error 'No supported installed Limine or GRUB backend'; return 1; + } + # Initial GRUB contract is the canonical Asahi /boot topology. A separate + # /boot/efi, custom boot filesystem or multiple kernel layout is preserved + # and refused before staging rather than guessed from the first FAT row. + row=$(awk '$1 !~ /^#/ && ($2=="/boot" || $2=="/boot/efi" || $2=="/efi") {print}' "$root/etc/fstab") || return $? + [[ $(wc -l <<<"$row") == 1 ]] || { reset_error 'GRUB reset requires one distinct VFAT /boot mount'; return 1; } + read -r source RESET_BOOT_MOUNT filesystem options dump _pass <<<"$row" + [[ $source == UUID=* && $RESET_BOOT_MOUNT == /boot && $filesystem == vfat && ,$options, != *,noauto,* && ,$options, != *,ro,* ]] || return 1 + RESET_BOOT_UUID=${source#UUID=} + [[ $RESET_BOOT_UUID =~ ^[a-fA-F0-9]{4}-[a-fA-F0-9]{4}$ ]] || return 1 + RESET_BOOT_DEVICE="/dev/disk/by-uuid/$RESET_BOOT_UUID" + [[ -b $RESET_BOOT_DEVICE && $(findmnt -rn -M /boot -o UUID) == "$RESET_BOOT_UUID" && $(findmnt -rn -M /boot -o FSTYPE) == vfat ]] || { + reset_error 'Factory boot identity does not match the mounted VFAT /boot'; return 1; + } + RESET_BOOT_ROOT_UUID=$(findmnt -rn -T "$root" -o UUID) || return $? + [[ $RESET_BOOT_ROOT_UUID =~ ^[a-fA-F0-9-]{36}$ && $(findmnt -rn -T "$root" -o FSTYPE) == btrfs ]] || return 1 + row=$(awk '$1 !~ /^#/ && $2=="/" {print}' "$root/etc/fstab") || return $? + [[ $(wc -l <<<"$row") == 1 ]] || return 1 + # Read all columns explicitly: source, mountpoint, type, options. + read -r source filesystem dump options _pass <<<"$row" + [[ $source == "UUID=$RESET_BOOT_ROOT_UUID" && $filesystem == / && $dump == btrfs && (,$options, == *,subvol=@,* || ,$options, == *,subvol=/@,*) && ,$options, != *,subvolid=* ]] || { + reset_error 'Factory fstab must select this filesystem and canonical @ root'; return 1; + } + local -a kernels=() + for version in "$root"/usr/lib/modules/*; do + [[ -f $version/modules.builtin ]] && kernels+=("$version") + done + (( ${#kernels[@]} == 1 )) || { reset_error 'Factory kernel selection is ambiguous or lacks its packaged image'; return 1; } + RESET_BOOT_KERNEL=${kernels[0]##*/} + if [[ -f ${kernels[0]}/pkgbase && ! -L ${kernels[0]}/pkgbase ]]; then + RESET_BOOT_PKGBASE=$(cat "${kernels[0]}/pkgbase") || return $? + elif [[ -f $root/etc/mkinitcpio.d/linux-aarch64.preset && -x $root/usr/bin/pacman ]]; then + RESET_BOOT_PKGBASE=$(chroot "$root" /usr/bin/pacman -Qqo "/usr/lib/modules/$RESET_BOOT_KERNEL/modules.builtin") || return $? + [[ $RESET_BOOT_PKGBASE == linux-aarch64 ]] || return 1 + else + reset_error 'Kernel package identity is unavailable' + return 1 + fi + [[ $RESET_BOOT_KERNEL =~ ^[a-zA-Z0-9._+-]+$ && $RESET_BOOT_PKGBASE =~ ^[a-zA-Z0-9._+-]+$ ]] || return 1 + for tool in mkinitcpio lsinitcpio grub-mkconfig grub-script-check grub-probe unshare sha256sum mount umount; do + [[ -x $root/usr/bin/$tool ]] || { reset_error "Factory root lacks boot tool: $tool"; return 1; } + done + preset="$root/etc/mkinitcpio.d/$RESET_BOOT_PKGBASE.preset" + [[ -f $preset && ! -L $preset ]] || return 1 + reset_boot_read_preset "$preset" || return $? + [[ -f $root/etc/mkinitcpio.conf && -d $root/etc/grub.d ]] || return 1 + if [[ -f ${kernels[0]}/vmlinuz && ! -L ${kernels[0]}/vmlinuz ]]; then + RESET_BOOT_KERNEL_SOURCE="${kernels[0]}/vmlinuz" + RESET_BOOT_KERNEL_FILE="vmlinuz-$RESET_BOOT_PKGBASE" + [[ $RESET_BOOT_PRESET_KVER == "/boot/$RESET_BOOT_KERNEL_FILE" || $RESET_BOOT_PRESET_KVER == "$RESET_BOOT_KERNEL" ]] || return 1 + elif [[ $RESET_BOOT_PKGBASE == linux-aarch64 && $RESET_BOOT_PRESET_KVER == "$RESET_BOOT_KERNEL" ]]; then + if [[ $root == / ]]; then RESET_BOOT_KERNEL_SOURCE=/boot/vmlinuz-linux; + else RESET_BOOT_KERNEL_SOURCE="$root/boot/Image"; fi + [[ -f $RESET_BOOT_KERNEL_SOURCE && ! -L $RESET_BOOT_KERNEL_SOURCE ]] || return 1 + RESET_BOOT_KERNEL_FILE=vmlinuz-linux + grep -aF "Linux version $RESET_BOOT_KERNEL " "$RESET_BOOT_KERNEL_SOURCE" >/dev/null || { + reset_error 'Retained generic Image does not match the selected modules'; return 1; + } + else + reset_error 'Selected factory has no retained matching kernel payload' + return 1 + fi + [[ -f /boot/$RESET_BOOT_KERNEL_FILE && -f /boot/$RESET_BOOT_IMAGE && -d /boot/grub ]] || return 1 + for version in /boot/vmlinuz-* /boot/vmlinux-* /boot/kernel-* /boot/Image; do + [[ -f $version ]] || continue + [[ $version == "/boot/$RESET_BOOT_KERNEL_FILE" ]] || { reset_error 'Multiple boot kernels require explicit selection'; return 1; } + done + # 10_linux discovers fallback/initrd files independently of PRESETS. They + # must not enter the next root with old modules or embedded key material. + for version in /boot/initramfs-* /boot/initrd*; do + [[ -f $version ]] || continue + [[ $version == "/boot/$RESET_BOOT_IMAGE" ]] || { reset_error "Additional initramfs needs explicit rebuilding: $version"; return 1; } + done + if [[ $RESET_BOOT_PKGBASE == linux-asahi* || -e /boot/m1n1/boot.bin ]]; then + RESET_BOOT_ASAHI=1 + [[ -x $root/usr/bin/update-m1n1 && -f $root/usr/lib/asahi-boot/m1n1.bin && -f $root/usr/lib/asahi-boot/u-boot-nodtb.bin && -d $root/usr/lib/modules/$RESET_BOOT_KERNEL/dtbs ]] || return 1 + if [[ -f $root/etc/default/update-m1n1 ]]; then + [[ -z $(sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d' "$root/etc/default/update-m1n1") ]] || { + reset_error 'Custom update-m1n1 overrides require explicit input review'; return 1; + } + fi + fi + RESET_BOOT_LUKS_UUID="" + local backing root_device + root_device=$(findmnt -rn -T "$root" -o SOURCE) || return $? + root_device=${root_device%%\[*} + backing=$(lsblk -nspo NAME,FSTYPE "$root_device" | awk '$2=="crypto_LUKS" {print $1}') || return $? + if [[ -n $backing ]]; then + [[ $(wc -l <<<"$backing") == 1 && -b $backing ]] || return 1 + RESET_BOOT_LUKS_UUID=$(cryptsetup luksUUID "$backing") || return $? + [[ $RESET_BOOT_LUKS_UUID =~ ^[a-fA-F0-9-]{36}$ ]] || return 1 + fi + RESET_BOOT_BACKEND=grub +} + +reset_boot_read_preset() { + local preset=$1 line key value presets=0 + RESET_BOOT_PRESET_KVER="" RESET_BOOT_IMAGE="" + local -A seen=() + while IFS= read -r line || [[ -n $line ]]; do + line=${line//[[:space:]]/} + [[ -z $line || $line == \#* ]] && continue + [[ $line == *=* ]] || return 1 + key=${line%%=*} value=${line#*=} + [[ ! ${seen[$key]+yes} ]] || return 1 + seen[$key]=1 + case $key in + PRESETS) [[ $value == "('default')" || $value == '("default")' ]] || return 1; presets=1 ;; + ALL_kver|default_image) + [[ $value == \"*\" || $value == \'*\' ]] || return 1 + value=${value:1:${#value}-2} + [[ $value =~ ^[a-zA-Z0-9/._+-]+$ ]] || return 1 + if [[ $key == ALL_kver ]]; then RESET_BOOT_PRESET_KVER=$value; + else [[ $value == /boot/initramfs-*.img && ${value#/boot/} != */* ]] || return 1; RESET_BOOT_IMAGE=${value#/boot/}; fi ;; + ALL_config|default_config) [[ $value == '"/etc/mkinitcpio.conf"' || $value == "'/etc/mkinitcpio.conf'" ]] || return 1 ;; + default_options) [[ $value == '""' || $value == "''" ]] || return 1 ;; + # These package assignments are inactive with PRESETS=('default'). + fallback_image|fallback_options|fallback_config) [[ $value != *'`'* && $value != *'$'* && $value != *';'* ]] || return 1 ;; + *) reset_error "Unsupported active preset setting: $key"; return 1 ;; + esac + done <"$preset" + [[ $presets == 1 && -n $RESET_BOOT_PRESET_KVER && -n $RESET_BOOT_IMAGE ]] +} + +reset_boot_stage_path_valid() { + local root_uuid=$1 stage=$2 + [[ $stage == /* && $stage != *'/../'* && ! -e $stage && ! -L $stage && -d ${stage%/*} ]] || return 1 + # /run can contain a mounted Btrfs top level. Check its actual backing, + # not its spelling; a real tmpfs staging directory is never acceptable. + [[ $(findmnt -rn -T "${stage%/*}" -o FSTYPE) == btrfs && $(findmnt -rn -T "${stage%/*}" -o UUID) == "$root_uuid" ]] +} + +reset_boot_prepare() { + local root=$1 stage=$2 key_mode=$3 + [[ $key_mode == provision || $key_mode == owner ]] || return 1 + reset_boot_probe "$root" || return $? + [[ $RESET_BOOT_BACKEND == grub ]] || { reset_error 'Use the existing Limine generator for this backend'; return 1; } + reset_boot_stage_path_valid "$RESET_BOOT_ROOT_UUID" "$stage" || return $? + install -d -m 700 "$stage" || return $? + unshare --mount --propagation private /bin/bash -euo pipefail -c \ + 'source "$1"; shift; reset_boot_generate_private "$@"' reset-boot "$RESET_BOOT_HELPER" "$root" "$stage" "$key_mode" +} + +reset_boot_generate_private() { + local root=${1%/} stage=$2 key_mode=$3 directory kernel image original_root + [[ -n $root ]] || root=/ + reset_boot_probe "$root" || return $? + install -d -m 700 "$stage/runtime" "$stage/runtime/tmp" "$stage/tmp" "$stage/files/grub" "$stage/files/m1n1" || return $? + # /run and /tmp are backed by the caller's disk staging directory. These + # mounts are private and disappear on exit, including generator failures. + for directory in proc sys dev; do + mount --rbind "/$directory" "$root/$directory" || return $? + mount --make-rslave "$root/$directory" || return $? + done + mount --bind "$stage/runtime" "$root/run" || return $? + mount --bind "$stage/tmp" "$root/tmp" || return $? + kernel=$RESET_BOOT_KERNEL_FILE image=$RESET_BOOT_IMAGE + cp -- "$RESET_BOOT_KERNEL_SOURCE" "$stage/files/$kernel" || return $? + sha256sum "$RESET_BOOT_KERNEL_SOURCE" >"$stage/kernel-input" || return $? + mount -o ro "$RESET_BOOT_DEVICE" "$root/boot" || return $? + chroot "$root" /usr/bin/env TMPDIR=/run/tmp TMP=/run/tmp TEMP=/run/tmp /usr/bin/mkinitcpio \ + --nopost -k "$RESET_BOOT_KERNEL" -g /run/initramfs.img || return $? + mv "$stage/runtime/initramfs.img" "$stage/files/$image" || return $? + mount --bind "$stage/files/$kernel" "$root/boot/$kernel" || return $? + mount --bind "$stage/files/$image" "$root/boot/$image" || return $? + chroot "$root" /usr/bin/grub-mkconfig -o /run/grub.cfg.raw || return $? + original_root=$(btrfs subvolume show "$root" | awk '$1=="Name:" {print $2; exit}') || return $? + [[ $original_root == @ || $original_root == @omarchy-reset-next ]] || return 1 + sed "s|rootflags=subvol=$original_root\([[:space:]]\)|rootflags=subvol=@\\1|g" "$stage/runtime/grub.cfg.raw" >"$stage/files/grub/grub.cfg" || return $? + if (( RESET_BOOT_ASAHI )); then + local input logical + local -a inputs=(usr/bin/update-m1n1 usr/share/asahi-scripts/functions.sh usr/lib/asahi-boot/m1n1.bin usr/lib/asahi-boot/u-boot-nodtb.bin) + [[ ! -f $root/etc/m1n1.conf ]] || inputs+=(etc/m1n1.conf) + [[ ! -f $root/etc/default/update-m1n1 ]] || inputs+=(etc/default/update-m1n1) + for input in "$root/usr/lib/modules/$RESET_BOOT_KERNEL"/dtbs/*.dtb; do + [[ -f $input && ! -L $input ]] || return 1 + inputs+=("${input#"${root%/}/"}") + done + : >"$stage/asahi-inputs" + for logical in "${inputs[@]}"; do + [[ -f $root/$logical && ! -L $root/$logical ]] || return 1 + input=$(sha256sum "$root/$logical") || return $? + printf '%s /%s\n' "${input%% *}" "$logical" >>"$stage/asahi-inputs" || return $? + done + chroot "$root" /usr/bin/env LC_ALL=C "DTBS=/usr/lib/modules/$RESET_BOOT_KERNEL/dtbs/*.dtb" \ + /usr/bin/update-m1n1 /run/boot.bin || return $? + [[ -s $stage/runtime/boot.bin ]] || return 1 + mv "$stage/runtime/boot.bin" "$stage/files/m1n1/boot.bin" || return $? + fi + printf '%s\n' "$RESET_BOOT_ROOT_UUID $RESET_BOOT_UUID $RESET_BOOT_KERNEL $RESET_BOOT_PKGBASE $key_mode $RESET_BOOT_ASAHI $kernel $image" >"$stage/identity" || return $? + cp "$stage/files/grub/grub.cfg" "$stage/runtime/grub.cfg.final" || return $? + reset_boot_verify "$root" "$stage" "$key_mode" || return $? + (cd "$stage/files" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) >"$stage/manifest" || return $? + sync -f "$stage" +} + +reset_boot_verify() { + local root=$1 stage=$2 key_mode=$3 cfg="$2/files/grub/grub.cfg" listing lines kernel image + kernel=$RESET_BOOT_KERNEL_FILE image=$RESET_BOOT_IMAGE + [[ $(sha256sum "$stage/files/$kernel") == "$(cut -d' ' -f1 "$stage/kernel-input") "* ]] || return 1 + chroot "$root" /usr/bin/grub-script-check /run/grub.cfg.final || return $? + lines=$(awk '$1=="linux" || $1=="linuxefi" {print}' "$cfg") || return $? + [[ -n $lines && $lines != *'@omarchy-reset-next'* ]] || return 1 + local line word roots flags kernels cryptkeys cryptdevices linux_count initrd_count search_uuid="" + linux_count=$(wc -l <<<"$lines") + local -a words + while IFS= read -r line; do + roots=0 flags=0 kernels=0 cryptkeys=0 cryptdevices=0 + read -ra words <<<"$line" + for word in "${words[@]}"; do + case $word in + /vmlinuz-*|/Image) [[ $word == "/$kernel" ]] || return 1; kernels=$((kernels + 1));; + root=*) [[ $word == "root=UUID=$RESET_BOOT_ROOT_UUID" || $word == root=/dev/mapper/root ]] || return 1; roots=$((roots + 1));; + rootflags=*) [[ $word == rootflags=subvol=@ ]] || return 1; flags=$((flags + 1));; + cryptdevice=*) + [[ -n $RESET_BOOT_LUKS_UUID && ($word == "cryptdevice=UUID=$RESET_BOOT_LUKS_UUID:root" || $word == "cryptdevice=UUID=$RESET_BOOT_LUKS_UUID:root:allow-discards") ]] || return 1 + cryptdevices=$((cryptdevices + 1)) ;; + rd.luks.*|cryptopts=*) reset_error 'Unsupported encrypted-root command line'; return 1 ;; + cryptkey=*) [[ $word == cryptkey=rootfs:/etc/omarchy/provisioning.key && $key_mode == provision ]] || return 1; cryptkeys=$((cryptkeys + 1));; + esac + done + (( roots == 1 && flags == 1 && kernels == 1 && cryptkeys <= 1 )) || return 1 + if [[ -n $RESET_BOOT_LUKS_UUID ]]; then (( cryptdevices == 1 )) || return 1; + else (( cryptdevices == 0 && cryptkeys == 0 )) || return 1; fi + if [[ -f $root/etc/omarchy/provisioning.key ]]; then (( cryptkeys == 1 )) || return 1; fi + done <<<"$lines" + # Every initrd line must name exactly the image we rebuilt. No stale + # fallback, external keyfile or unvalidated microcode image is accepted. + lines=$(awk '$1=="initrd" || $1=="initrdefi" {print}' "$cfg") || return $? + [[ -n $lines ]] || return 1 + initrd_count=$(wc -l <<<"$lines") + [[ $initrd_count == "$linux_count" ]] || return 1 + while IFS= read -r line; do + read -ra words <<<"$line" + [[ ${#words[@]} == 2 && ${words[1]} == "/$image" ]] || return 1 + done <<<"$lines" + # Header/font probes may select the root filesystem; each actual boot + # payload must be read with root set to the verified separate boot UUID. + while IFS= read -r line; do + read -ra words <<<"$line" + case ${words[0]:-} in + set) [[ ${words[1]:-} != root=* ]] || search_uuid="" ;; + search) + if [[ $line == *--fs-uuid* && $line == *--set=root* ]]; then + search_uuid=${words[${#words[@]}-1]}; search_uuid=${search_uuid//\'/} + fi ;; + linux|linuxefi|initrd|initrdefi) [[ $search_uuid == "$RESET_BOOT_UUID" ]] || return 1 ;; + esac + done <"$cfg" + listing=$(chroot "$root" /usr/bin/lsinitcpio "/boot/$image") || return $? + grep -q "usr/lib/modules/$RESET_BOOT_KERNEL/" <<<"$listing" || return 1 + if [[ -n $RESET_BOOT_LUKS_UUID ]]; then + grep -qx 'hooks/encrypt' <<<"$listing" && grep -qx 'usr/bin/cryptsetup' <<<"$listing" || return 1 + if ! grep -q 'drivers/md/dm-crypt\.ko' "$root/usr/lib/modules/$RESET_BOOT_KERNEL/modules.builtin"; then + grep -q "usr/lib/modules/$RESET_BOOT_KERNEL/.*dm-crypt\.ko" <<<"$listing" || return 1 + fi + fi + if ! grep -q 'fs/btrfs/btrfs\.ko' "$root/usr/lib/modules/$RESET_BOOT_KERNEL/modules.builtin"; then + grep -q "usr/lib/modules/$RESET_BOOT_KERNEL/.*btrfs\.ko" <<<"$listing" || return 1 + fi + if [[ $key_mode == owner ]]; then + [[ $listing != *etc/omarchy/provisioning.key* && ! -e $root/etc/omarchy/provisioning.key ]] || return 1 + elif [[ -f $root/etc/omarchy/provisioning.key ]]; then + grep -qx 'etc/omarchy/provisioning.key' <<<"$listing" || return 1 + fi +} + +reset_boot_bundle_check() { + local stage=$1 key_mode=$2 root_uuid boot_uuid kernel pkgbase mode asahi kernel_file image_file extra file digest count=0 + [[ -d $stage && ! -L $stage && $(stat -c %u "$stage") == 0 && $(stat -c %a "$stage") == 700 ]] || return 1 + [[ -f $stage/identity && ! -L $stage/identity && -f $stage/manifest && ! -L $stage/manifest ]] || return 1 + read -r root_uuid boot_uuid kernel pkgbase mode asahi kernel_file image_file extra <"$stage/identity" + [[ -z $extra && $root_uuid =~ ^[a-fA-F0-9-]{36}$ && $boot_uuid =~ ^[a-fA-F0-9]{4}-[a-fA-F0-9]{4}$ && $kernel =~ ^[a-zA-Z0-9._+-]+$ && $pkgbase =~ ^[a-zA-Z0-9._+-]+$ && $mode == "$key_mode" && ($asahi == 0 || $asahi == 1) ]] || return 1 + [[ $(findmnt -rn -M /boot -o UUID) == "$boot_uuid" && $(findmnt -rn -M /boot -o FSTYPE) == vfat && $(findmnt -rn -T / -o UUID) == "$root_uuid" ]] || return 1 + [[ $kernel_file == "vmlinuz-$pkgbase" || ($pkgbase == linux-aarch64 && $kernel_file == vmlinuz-linux) ]] || return 1 + [[ $image_file =~ ^initramfs-[a-zA-Z0-9._+-]+\.img$ ]] || return 1 + [[ -z $(find "$stage/files" -type l -print -quit) ]] || return 1 + local -A seen=() + while read -r digest file extra; do + [[ $digest =~ ^[a-f0-9]{64}$ && -z $extra && ! ${seen[$file]+yes} ]] || return 1 + case $file in + "./$kernel_file"|"./$image_file"|./grub/grub.cfg) ;; + ./m1n1/boot.bin) (( asahi == 1 )) || return 1 ;; + *) return 1 ;; + esac + [[ -f $stage/files/$file && $(sha256sum "$stage/files/$file") == "$digest "* ]] || return 1 + seen[$file]=1; count=$((count + 1)) + done <"$stage/manifest" + (( count == 3 + asahi )) || return 1 + [[ $(find "$stage/files" -type f | wc -l) == "$count" ]] || return 1 +} + +reset_boot_copy_atomic() { + local source=$1 destination=$2 temporary + [[ ! -L $destination && (! -e $destination || -f $destination) && -d ${destination%/*} && ! -L ${destination%/*} ]] || return 1 + temporary=$(mktemp "${destination%/*}/.omarchy-reset.XXXXXXXX") || return $? + if ! { cp -- "$source" "$temporary" && sync -f "$temporary" && cmp "$source" "$temporary" && mv -T "$temporary" "$destination" && sync -f "${destination%/*}"; }; then + rm -f -- "$temporary" + return 1 + fi +} + +reset_boot_backup() { + local stage=$1 key_mode=$2 digest relative extra old_digest + reset_boot_bundle_check "$stage" "$key_mode" || return $? + [[ ! -e $stage/backup && ! -L $stage/backup ]] || return 1 + install -d -m 700 "$stage/backup/files" || return $? + while read -r digest relative extra; do + relative=${relative#./} + [[ ! -L /boot/$relative && (! -e /boot/$relative || -f /boot/$relative) && ! -L /boot/${relative%/*} ]] || return 1 + if [[ -f /boot/$relative ]]; then + install -Dm600 "/boot/$relative" "$stage/backup/files/$relative" || return $? + old_digest=$(sha256sum "$stage/backup/files/$relative") || return $? + printf '%s %s\n' "${old_digest%% *}" "$relative" >>"$stage/backup/manifest" || return $? + else + printf 'absent %s\n' "$relative" >>"$stage/backup/manifest" || return $? + fi + done <"$stage/manifest" + reset_state_write "$stage/backup/binding" "$(sha256sum "$stage/manifest" | cut -d' ' -f1) $(sha256sum "$stage/backup/manifest" | cut -d' ' -f1)" || return $? + sync -f "$stage/backup" +} + +reset_boot_backup_check() { + local stage=$1 expected digest relative extra count=0 candidate + reset_private_file "$stage/backup/binding" || return 1 + [[ -f $stage/backup/manifest && ! -L $stage/backup/manifest ]] || return 1 + expected="$(sha256sum "$stage/manifest" | cut -d' ' -f1) $(sha256sum "$stage/backup/manifest" | cut -d' ' -f1)" + [[ $(cat "$stage/backup/binding") == "$expected" ]] || return 1 + local -A destinations=() + while read -r digest relative extra; do + [[ -z $extra && ($digest == absent || $digest =~ ^[a-f0-9]{64}$) && ! ${destinations[$relative]+yes} ]] || return 1 + candidate=$(awk -v path="./$relative" '$2==path {print $1}' "$stage/manifest") || return $? + [[ $candidate =~ ^[a-f0-9]{64}$ ]] || return 1 + if [[ $digest != absent ]]; then + [[ -f $stage/backup/files/$relative && ! -L $stage/backup/files/$relative && $(sha256sum "$stage/backup/files/$relative") == "$digest "* ]] || return 1 + fi + destinations[$relative]=1; count=$((count + 1)) + done <"$stage/backup/manifest" + (( count > 0 )) && [[ $count == "$(wc -l <"$stage/manifest")" ]] +} + +reset_boot_readback() { + local stage=$1 key_mode=$2 digest relative extra + reset_boot_bundle_check "$stage" "$key_mode" || return $? + while read -r digest relative extra; do + relative=${relative#./} + [[ -f /boot/$relative && ! -L /boot/$relative && $(sha256sum "/boot/$relative") == "$digest "* ]] || return 1 + done <"$stage/manifest" +} + +reset_boot_publish() { + local stage=$1 key_mode=$2 digest relative extra + reset_boot_bundle_check "$stage" "$key_mode" || return $? + reset_boot_backup_check "$stage" || return $? + # The caller owns the whole root/baseline/boot transaction. Mark intent + # before its first boot write, so a later root exchange failure restores + # these same scoped bytes, not a newly generated approximation. + reset_state_write "$stage/publication" publishing || return $? + while read -r digest relative extra; do + relative=${relative#./} + reset_boot_copy_atomic "$stage/files/$relative" "/boot/$relative" || return $? + done <"$stage/manifest" + reset_boot_readback "$stage" "$key_mode" || return $? + reset_state_write "$stage/publication" published +} + +reset_boot_rollback() { + local stage=$1 key_mode=$2 old_digest relative extra candidate current + reset_boot_bundle_check "$stage" "$key_mode" || return $? + reset_boot_backup_check "$stage" || return $? + # Inspect every current path before restoring any. Unknown concurrent bytes + # are preserved for inspection; only this transaction's old/new bytes fit. + while read -r old_digest relative extra; do + [[ -z $extra && ! -L /boot/$relative ]] || return 1 + candidate=$(awk -v path="./$relative" '$2==path {print $1}' "$stage/manifest") || return $? + [[ $candidate =~ ^[a-f0-9]{64}$ ]] || return 1 + if [[ -f /boot/$relative ]]; then + current=$(sha256sum "/boot/$relative") || return $? + [[ ${current%% *} == "$candidate" || ${current%% *} == "$old_digest" ]] || { reset_error "Boot path changed outside reset: $relative"; return 1; } + else + [[ $old_digest == absent ]] || return 1 + fi + if [[ $old_digest != absent ]]; then + [[ $old_digest =~ ^[a-f0-9]{64}$ && -f $stage/backup/files/$relative && ! -L $stage/backup/files/$relative && $(sha256sum "$stage/backup/files/$relative") == "$old_digest "* ]] || return 1 + fi + done <"$stage/backup/manifest" + while read -r old_digest relative extra; do + if [[ $old_digest == absent ]]; then rm -f -- "/boot/$relative" || return $?; + else reset_boot_copy_atomic "$stage/backup/files/$relative" "/boot/$relative" || return $?; fi + done <"$stage/backup/manifest" + sync -f /boot || return $? + reset_state_write "$stage/publication" rolled-back +} diff --git a/test/shell.d/factory-reset-closure-test.sh b/test/shell.d/factory-reset-closure-test.sh new file mode 100644 index 00000000000..bb305f1a417 --- /dev/null +++ b/test/shell.d/factory-reset-closure-test.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/factory-reset.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +fixture_root="$test_tmp/old-root" +mkdir -p "$fixture_root/usr/bin" "$fixture_root/usr/share/omarchy/bin" "$fixture_root/usr/share/omarchy/install/helpers" +# Historical package aliases must be replaced as directory entries, without +# following an absolute alias into this process's actual filesystem. +ln -s /unavailable-historical-target "$fixture_root/usr/share/omarchy/bin/omarchy-provision-owner" +ln -s ../share/omarchy/bin/omarchy-provision-owner "$fixture_root/usr/bin/omarchy-provision-owner" +reset_closure_install "$ROOT" "$fixture_root" "$test_tmp/closure.sha256" +while IFS= read -r file; do + cmp "$ROOT/$file" "$fixture_root/usr/share/omarchy/$file" || fail "current closure $file" + if [[ $file == bin/* ]]; then cmp "$ROOT/$file" "$fixture_root/usr/bin/${file#bin/}" || fail 'installed binary closure'; fi +done < <(reset_closure_files) +[[ ! -L $fixture_root/usr/bin/omarchy-provision-owner && ! -L $fixture_root/usr/share/omarchy/bin/omarchy-provision-owner ]] || fail 'historical aliases remain' +grep -q '/usr/bin/omarchy-provision-owner$' "$test_tmp/closure.sha256" || fail 'actual executable is bound' +grep -q '/install/helpers/browser-policy.sh$' "$test_tmp/closure.sha256" || fail 'browser helper closure' +grep -q '/install/helpers/as-root.sh$' "$test_tmp/closure.sha256" || fail 'transitive privilege helper closure' +pass 'exact current worker and transitive closure replace historical aliases safely' +mkdir -p "$test_tmp/other-root/usr/share" +ln -s "$test_tmp/outside" "$test_tmp/other-root/usr/share/omarchy" +if reset_closure_install "$ROOT" "$test_tmp/other-root" "$test_tmp/rejected.sha256"; then fail 'symlinked closure parent'; fi +[[ ! -e $test_tmp/outside ]] || fail 'outside target touched' +pass 'historical development-link parent fails without escaping selected root' diff --git a/test/shell.d/factory-reset-inventory-test.sh b/test/shell.d/factory-reset-inventory-test.sh new file mode 100644 index 00000000000..21c6556cb91 --- /dev/null +++ b/test/shell.d/factory-reset-inventory-test.sh @@ -0,0 +1,108 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/factory-reset.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +stat() { if [[ $* == '-c %u '* ]]; then echo 0; else command stat "$@"; fi; } +sync() { :; } +fixture_volume() { mkdir -p "$1"; printf '%s\n' "$2" >"$1/.uuid"; } +reset_uuid() { [[ ! -L $1 && -f $1/.uuid ]] && cat "$1/.uuid"; } +reset_empty_volume() { [[ -z $(find "$1" -mindepth 1 ! -name .uuid -print -quit) ]]; } +reset_nested_paths() { + local file + while IFS= read -r file; do printf '%s\n' "${file#"$TOP/"}" | sed 's|/\.uuid$||'; done < <(find "$1" -mindepth 2 -name .uuid | sort) +} +findmnt() { printf '%s\n' "${MOUNTS:-/ /test none}"; } +btrfs() { + case "$1 $2" in + 'subvolume delete') echo "$3" >>"$DELETIONS"; [[ ${FAIL_DELETE:-} != "$3" ]] || return 71; rm "$3/.uuid"; rmdir "$3" ;; + 'subvolume create') fixture_volume "$3" aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa ;; + *) return 99 ;; + esac +} +new_fixture() { + TOP="$test_tmp/$1/top"; mkdir -p "$TOP" + MANIFEST="$test_tmp/$1/inventory" STATE="$test_tmp/$1/state" DELETIONS="$test_tmp/$1/deletions" + : >"$DELETIONS"; MOUNTS="" FAIL_DELETE="" + fixture_volume "$TOP/@" 11111111-1111-1111-1111-111111111111 + fixture_volume "$TOP/@factory" 22222222-2222-2222-2222-222222222222 + fixture_volume "$TOP/@old-1" 33333333-3333-3333-3333-333333333333 + fixture_volume "$TOP/@old-1/.snapshots" 44444444-4444-4444-4444-444444444444 + fixture_volume "$TOP/@fresh" 55555555-5555-5555-5555-555555555555 + fixture_volume "$TOP/@home" 66666666-6666-6666-6666-666666666666 + fixture_volume "$TOP/@admin" 77777777-7777-7777-7777-777777777777 + reset_inventory_build "$TOP" 123 "$MANIFEST" +} +move_roots() { mv "$TOP/@" "$TOP/@omarchy-old-123"; mv "$TOP/@factory" "$TOP/@omarchy-old-factory-123"; } +FS_UUID=99999999-9999-9999-9999-999999999999 +new_fixture inventory +[[ $(wc -l <"$MANIFEST") == 6 ]] || fail 'every selected root and nested history recorded' +reset_inventory_verify_sources "$TOP" "$MANIFEST" +reset_inventory_display "$MANIFEST" >"$test_tmp/display" +grep -q 'Legacy names are not ownership proof' "$test_tmp/display" || fail 'legacy ownership caveat' +! grep -q @admin "$MANIFEST" || fail 'admin not inferred' +fixture_volume "$TOP/@old-1/new-child" 88888888-8888-8888-8888-888888888888 +if reset_inventory_verify_sources "$TOP" "$MANIFEST"; then fail 'new descendant must invalidate confirmation'; fi +pass 'explicit full descendant inventory and changed-set refusal' +new_fixture collision +fixture_volume "$TOP/@omarchy-old-123" 88888888-8888-8888-8888-888888888888 +if reset_inventory_verify_sources "$TOP" "$MANIFEST"; then fail 'root destination collision'; fi +pass 'preflight root collision' +new_fixture mounted +move_roots +MOUNTS="/somewhere $FS_UUID /@old-1/.snapshots" +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'mounted nested history'; fi +[[ ! -s $DELETIONS ]] || fail 'whole inventory preflight before any deletion' +pass 'mounted descendant prevents all cleanup mutation' +new_fixture unexpected +move_roots +fixture_volume "$TOP/@old-1/new-child" 88888888-8888-8888-8888-888888888888 +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'unknown child'; fi +[[ ! -s $DELETIONS ]] || fail 'unknown child preflight before deletion' +pass 'unlisted nested subvolume preserved' +new_fixture identity +move_roots +echo 88888888-8888-8888-8888-888888888888 >"$TOP/@fresh/.uuid" +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'changed UUID'; fi +[[ ! -s $DELETIONS ]] || fail 'changed identity detected before deletion' +pass 'changed identity loses authorization' +new_fixture retry +move_roots +FAIL_DELETE="$TOP/@fresh" +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'injected cleanup failure'; fi +FAIL_DELETE= +reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID" +reset_recreate_clean_subvolume "$TOP" @home "$STATE" +# Emulate an ordinary later failure after home recreation, then actual retry. +reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID" +reset_recreate_clean_subvolume "$TOP" @home "$STATE" +[[ $(reset_uuid "$TOP/@home") == aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa && -d $TOP/@admin ]] || fail 'replacement/admin preserved' +[[ ! -e $TOP/@fresh && ! -e $TOP/@old-1 ]] || fail 'all authorized history erased' +pass 'partial deletion and post-home recreation retry preserve exact new identity' +echo bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb >"$TOP/@home/.uuid" +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'arbitrary replacement'; fi +pass 'replacement receipt does not authorize different subvolume' +new_fixture state +move_roots +mkdir -m 755 "$STATE" +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'permissive state'; fi +[[ ! -s $DELETIONS ]] || fail 'state failure preserves roots' +pass 'state permissions fail closed' +new_fixture binding +move_roots +reset_state_bind "$MANIFEST" "$STATE" "$FS_UUID" +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" 88888888-8888-8888-8888-888888888888; then fail 'filesystem changed'; fi +[[ ! -s $DELETIONS ]] || fail 'binding failure preserves roots' +pass 'filesystem and manifest state binding' + +new_fixture receipt_write +move_roots +reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID" +eval "$(declare -f reset_state_write | sed '1s/reset_state_write/real_state_write/')" +reset_state_write() { [[ $1 != "$STATE/replacement-home" ]] || return 72; real_state_write "$@"; } +if reset_recreate_clean_subvolume "$TOP" @home "$STATE"; then fail 'receipt write failure'; fi +[[ ! -e $TOP/@home && ! -e $TOP/@omarchy-reset-home ]] || fail 'own empty unrecorded temporary removed' +reset_state_write() { real_state_write "$@"; } +reset_recreate_clean_subvolume "$TOP" @home "$STATE" +pass 'failed replacement receipt cleans only own empty temporary and permits retry' diff --git a/test/shell.d/factory-reset-services-test.sh b/test/shell.d/factory-reset-services-test.sh new file mode 100644 index 00000000000..fcb702de0c4 --- /dev/null +++ b/test/shell.d/factory-reset-services-test.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/factory-reset.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +stat() { if [[ $* == '-c %u '* ]]; then echo 0; else command stat "$@"; fi; } +sync() { :; } +sleep() { :; } +EVENTS="$test_tmp/events" +: >"$EVENTS" +SERVICE_MASK=masked PATH_MASK=enabled PATH_ACTIVE=active ABSENT=0 +systemctl() { + local command=$1 unit=${2:-} + case "$command $unit ${3:-}" in + 'show '*\ --property=LoadState) if (( ABSENT )); then echo not-found; else echo loaded; fi ;; + 'show limine-snapper-sync.service --property=ActiveState') echo inactive ;; + 'show limine-snapper-sync.path --property=ActiveState') echo "$PATH_ACTIVE" ;; + 'is-enabled limine-snapper-sync.service '*) echo "$SERVICE_MASK" ;; + 'is-enabled limine-snapper-sync.path '*) echo "$PATH_MASK" ;; + *) printf '%s\n' "$*" >>"$EVENTS" ;; + esac +} +mkdir -m 700 "$test_tmp/state" +reset_limine_quiesce "$test_tmp/state" +reset_limine_resume "$test_tmp/state" +grep -qx 'stop limine-snapper-sync.path' "$EVENTS" || fail 'active path stopped' +grep -qx 'unmask --runtime limine-snapper-sync.path' "$EVENTS" || fail 'own path mask restored' +grep -qx 'start limine-snapper-sync.path' "$EVENTS" || fail 'prior active path restored' +if grep -q 'unmask.*limine-snapper-sync.service' "$EVENTS"; then fail 'administrator service mask removed'; fi +if grep -q 'stop.*limine-snapper-sync.service' "$EVENTS"; then fail 'writer service interrupted'; fi +pass 'ordinary rollback restores only owned Limine path state and preserves admin service mask' +mkdir -m 700 "$test_tmp/absent" +ABSENT=1 +cp "$EVENTS" "$test_tmp/expected" +reset_limine_quiesce "$test_tmp/absent" +reset_limine_resume "$test_tmp/absent" +cmp "$EVENTS" "$test_tmp/expected" || fail 'absent backend mutated service state' +pass 'GRUB installation with absent Limine units needs no service mutation' +mkdir -m 700 "$test_tmp/masked-active" +ABSENT=0 PATH_MASK=masked +if reset_limine_quiesce "$test_tmp/masked-active"; then fail 'masked active custom path'; fi +cmp "$EVENTS" "$test_tmp/expected" || fail 'custom active mask was changed' +pass 'masked-but-active administrator path is preserved on refusal' diff --git a/test/shell.d/factory-reset-transaction-test.sh b/test/shell.d/factory-reset-transaction-test.sh new file mode 100644 index 00000000000..0d6d87f4a5e --- /dev/null +++ b/test/shell.d/factory-reset-transaction-test.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/factory-reset.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +stat() { if [[ $* == '-c %u '* ]]; then echo 0; else command stat "$@"; fi; } +sync() { :; } +reset_uuid() { [[ ! -L $1 && -f $1/.uuid ]] && cat "$1/.uuid"; } +findmnt() { echo "$RESET_TXN_FS"; } +fixture_volume() { mkdir -p "$1"; echo "$2" >"$1/.uuid"; } +reset_boot_rollback() { [[ $1 == "$STATE/boot" && $2 == provision ]] || return 1; echo restored >"$STATE/boot-verdict"; } +new_fixture() { + TOP="$test_tmp/$1" STATE="$test_tmp/$1/.journal" + mkdir -p "$TOP"; mkdir -m 700 "$STATE" + RESET_TXN_FS=11111111-1111-1111-1111-111111111111 RESET_TXN_STAMP=123 + RESET_TXN_ROOT=22222222-2222-2222-2222-222222222222 RESET_TXN_FACTORY=33333333-3333-3333-3333-333333333333 + RESET_TXN_NEXT=44444444-4444-4444-4444-444444444444 RESET_TXN_CLEAN=55555555-5555-5555-5555-555555555555 + fixture_volume "$TOP/@" "$RESET_TXN_ROOT" + fixture_volume "$TOP/@factory" "$RESET_TXN_FACTORY" + fixture_volume "$TOP/@omarchy-reset-next" "$RESET_TXN_NEXT" + fixture_volume "$TOP/@omarchy-reset-factory" "$RESET_TXN_CLEAN" + reset_transaction_record "$STATE" + mkdir "$STATE/boot" + reset_state_write "$STATE/boot/publication" published +} +for boundary in 0 1 2 3 4; do + new_fixture "move-$boundary" + if (( boundary >= 1 )); then mv "$TOP/@factory" "$TOP/@omarchy-old-factory-123"; fi + if (( boundary >= 2 )); then mv "$TOP/@omarchy-reset-factory" "$TOP/@factory"; fi + if (( boundary >= 3 )); then mv "$TOP/@" "$TOP/@omarchy-old-123"; fi + if (( boundary >= 4 )); then mv "$TOP/@omarchy-reset-next" "$TOP/@"; fi + reset_transaction_rollback "$TOP" "$STATE" + [[ $(reset_uuid "$TOP/@") == "$RESET_TXN_ROOT" && $(reset_uuid "$TOP/@factory") == "$RESET_TXN_FACTORY" ]] || fail 'root+baseline rollback' + [[ $(reset_uuid "$TOP/@omarchy-reset-next") == "$RESET_TXN_NEXT" && $(reset_uuid "$TOP/@omarchy-reset-factory") == "$RESET_TXN_CLEAN" ]] || fail 'known stage retained' + [[ $(cat "$STATE/boot-verdict") == restored && $(cat "$STATE/phase") == rolled-back ]] || fail 'boot restored in same transaction' + pass "root/baseline/boot reconciliation after $boundary exchange renames" +done +new_fixture collision +mv "$TOP/@" "$TOP/@omarchy-old-123" +fixture_volume "$TOP/@" 66666666-6666-6666-6666-666666666666 +if reset_transaction_rollback "$TOP" "$STATE"; then fail 'unexpected current root'; fi +[[ $(reset_uuid "$TOP/@") == 66666666-6666-6666-6666-666666666666 && -d $TOP/@omarchy-old-123 ]] || fail 'unexpected root preserved' +pass 'unknown root collision fails without guessing' +new_fixture cancel +rm -r "${STATE:?}/boot" +RESET_TXN_NEXT=- RESET_TXN_CLEAN=- +reset_transaction_record "$STATE" +expected="$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY - -" +echo inventory >"$STATE/inventory" +reset_cancel_journal "$STATE" "$expected" +[[ ! -e $STATE ]] || fail 'cancel strands journal' +mkdir -m 700 "$STATE" +reset_transaction_record "$STATE" +reset_cancel_journal "$STATE" "$expected" +pass 'cancel then repeat pre-confirmation journal is retryable' diff --git a/test/shell.d/factory-update-exclusion-test.sh b/test/shell.d/factory-update-exclusion-test.sh new file mode 100644 index 00000000000..55ca75688bd --- /dev/null +++ b/test/shell.d/factory-update-exclusion-test.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir "$test_tmp/bin" "$test_tmp/runtime" +maintenance="$test_tmp/maintenance.lock" +: >"$maintenance" +# Only redirect the absolute system lock into this isolated fixture. flock +# and descriptor inheritance are real; no root/system path is writable here. +sed "s|/var/lib/omarchy/factory-reset.lock|$maintenance|g" "$ROOT/bin/omarchy-update-lock" >"$test_tmp/update-lock" +cat >"$test_tmp/bin/stat" <<'STUB' +#!/bin/bash +if [[ $1 == -c && $2 == %u ]]; then echo 0; else /usr/bin/stat "$@"; fi +STUB +chmod +x "$test_tmp/bin/stat" "$test_tmp/update-lock" +export PATH="$test_tmp/bin:$PATH" XDG_RUNTIME_DIR="$test_tmp/runtime" +exec {exclusive}<>"$maintenance" +flock -xn "$exclusive" +if bash "$test_tmp/update-lock" run true; then fail 'reset exclusive lock must block update'; fi +flock -u "$exclusive" +exec {exclusive}>&- +pass 'factory exclusive maintenance lock blocks actual update wrapper' +printf 'pending fixture-root fixture-next\n' >"$maintenance" +if bash "$test_tmp/update-lock" run true; then fail 'pending reset must block update after process exit'; fi +: >"$maintenance" +pass 'staged reset remains blocked until reboot wipe clears marker' +cat >"$test_tmp/inside-update" <<'SCRIPT' +#!/bin/bash +set -e +# The update's inherited shared maintenance lock excludes a factory reset. +if flock -xn "$1" true; then exit 91; fi +# Its own migration can still take the separate recovery-layout lock. +flock -xn "$2" true +SCRIPT +bash "$test_tmp/update-lock" run bash "$test_tmp/inside-update" "$maintenance" "$test_tmp/recovery.lock" +pass 'running update excludes reset but permits its own recovery migration lock' +rm "$maintenance" +bash "$test_tmp/update-lock" run true +pass 'first update before stable maintenance lock creation retains existing behavior' diff --git a/test/shell.d/owner-rekey-test.sh b/test/shell.d/owner-rekey-test.sh new file mode 100644 index 00000000000..fef5b4c7579 --- /dev/null +++ b/test/shell.d/owner-rekey-test.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/owner-rekey.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +stat() { if [[ $* == '-c %u '* ]]; then echo 0; else command stat "$@"; fi; } +sync() { :; } +owner_rekey_device_valid() { [[ $1 == /fixture-luks ]]; } +reset_boot_probe() { [[ ${FAIL_PROBE:-0} == 0 ]]; } +owner_rekey_slots() { find "$HEADER" -name 'slot-*' -printf '%f\n' | sed 's/slot-//' | sort -n; } +owner_rekey_remove_auto_unlock() { echo remove >>"$EVENTS"; } +owner_rekey_boot_prepare() { + echo boot >>"$EVENTS" + [[ $FAIL_BOOT == 0 ]] || return 73 + printf 'fixture-published-boot\n' >"$1/boot-manifest" + chmod 600 "$1/boot-manifest" +} +owner_rekey_boot_check() { [[ -f $1/boot-manifest && $(cat "$1/boot-manifest") == fixture-published-boot ]]; } +cryptsetup() { + local operation=$1 key_file="" slot="" value argument + shift + case $operation in + luksUUID) echo "$DEVICE_UUID"; return ;; + open) + while (( $# )); do + argument=$1; shift + case $argument in --key-file) key_file=$1; shift;; --key-slot) slot=$1; shift;; esac + done + value=$(cat "$key_file") + if [[ -n $slot ]]; then [[ -f $HEADER/slot-$slot && $(cat "$HEADER/slot-$slot") == "$value" ]]; + else grep -lFx -- "$value" "$HEADER"/slot-* >/dev/null; fi ;; + luksAddKey) + [[ $1 == --key-file && $(cat "$2") == staged ]] || return 74 + printf '%s' "$(cat "$4")" >"$HEADER/slot-2" + echo add >>"$EVENTS" ;; + luksKillSlot) + [[ $1 == -q && $2 == --key-file && $(cat "$3") == owner ]] || return 75 + slot=$5 + echo "kill-$slot" >>"$EVENTS" + [[ $FAIL_KILL != "$slot" ]] || return 76 + rm "$HEADER/slot-$slot" ;; + *) return 99 ;; + esac +} +new_fixture() { + local base="$test_tmp/$1" + mkdir -p "$base/header" + HEADER="$base/header" STATE="$base/state" EVENTS="$base/events" STAGED="$base/staged" OWNER="$base/owner" + echo staged >"$HEADER/slot-0"; echo seller >"$HEADER/slot-1" + echo staged >"$STAGED"; echo owner >"$OWNER"; chmod 600 "$STAGED" "$OWNER" + : >"$EVENTS"; FAIL_BOOT=0 FAIL_KILL="" + DEVICE_UUID=11111111-1111-1111-1111-111111111111 +} +new_fixture partial +FAIL_KILL=1 +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'partial retirement failure'; fi +[[ ! -f $HEADER/slot-0 && -f $HEADER/slot-1 && -f $HEADER/slot-2 && -f $STAGED ]] || fail 'throwaway removed, owner and pending retained' +[[ $(cut -d' ' -f4 "$STATE/receipt") == boot-published ]] || fail 'published receipt preserved' +FAIL_KILL="" +owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE" +[[ $(owner_rekey_slots /fixture-luks) == 2 && ! -e $STAGED ]] || fail 'owner-only final slots' +[[ $(grep -c '^boot$' "$EVENTS") == 1 && $(grep -c '^add$' "$EVENTS") == 1 ]] || fail 'retry does not regenerate or add needless key' +pass 'partial retirement after throwaway removal resumes through owner credential' +owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE" +pass 'completed receipt is idempotent with absent staged key' +new_fixture missing +FAIL_KILL=1 +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'injected failure'; fi +rm "$STAGED"; FAIL_KILL="" +owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE" +[[ $(owner_rekey_slots /fixture-luks) == 2 ]] || fail 'missing staged key must not bypass remaining retirement' +pass 'missing throwaway file does not skip pending retirement' +new_fixture wrong +FAIL_KILL=1 +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'injected failure'; fi +cp "$EVENTS" "$test_tmp/events-before" +echo wrong >"$OWNER" +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'wrong owner retry'; fi +cmp "$EVENTS" "$test_tmp/events-before" || fail 'wrong credential mutates neither boot nor slots' +pass 'wrong owner credential fails before mutation' +new_fixture swapped +FAIL_KILL=1 +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'injected failure'; fi +cp "$EVENTS" "$test_tmp/events-before" +DEVICE_UUID=22222222-2222-2222-2222-222222222222 +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'different LUKS identity'; fi +cmp "$EVENTS" "$test_tmp/events-before" || fail 'device mismatch mutates neither boot nor slots' +pass 'device substitution refuses prior receipt' +new_fixture boot_fail +FAIL_BOOT=1 +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'boot failure'; fi +[[ $(owner_rekey_slots /fixture-luks | wc -l) == 3 && -f $STAGED ]] || fail 'no retirement before verified boot' +FAIL_BOOT=0 +owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE" +[[ $(grep -c '^add$' "$EVENTS") == 1 ]] || fail 'generation retry preserves existing owner slot' +pass 'boot failure retains all recovery slots and retries same owner slot' + +new_fixture unsupported +FAIL_PROBE=1 +if owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE"; then fail 'unsupported boot preflight'; fi +[[ ! -s $EVENTS && $(owner_rekey_slots /fixture-luks | wc -l) == 2 ]] || fail 'unsupported boot mutated header/config' +pass 'owner boot preflight precedes key addition and config mutation' diff --git a/test/shell.d/reset-boot-test.sh b/test/shell.d/reset-boot-test.sh new file mode 100644 index 00000000000..c11862a13db --- /dev/null +++ b/test/shell.d/reset-boot-test.sh @@ -0,0 +1,86 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/reset-boot.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +cat >"$test_tmp/asahi.preset" <<'PRESET' +# stock Asahi form +ALL_kver="/boot/vmlinuz-linux-asahi" +PRESETS=('default') +default_image="/boot/initramfs-linux-asahi.img" +PRESET +reset_boot_read_preset "$test_tmp/asahi.preset" +[[ $RESET_BOOT_PRESET_KVER == /boot/vmlinuz-linux-asahi && $RESET_BOOT_IMAGE == initramfs-linux-asahi.img ]] || fail 'Asahi preset' +cat >"$test_tmp/generic.preset" <<'PRESET' +ALL_kver="7.2.4-1-aarch64-ARCH" +PRESETS=('default') +default_image="/boot/initramfs-linux.img" +fallback_image="/boot/initramfs-linux-fallback.img" +fallback_options="-S autodetect" +PRESET +reset_boot_read_preset "$test_tmp/generic.preset" +[[ $RESET_BOOT_PRESET_KVER == 7.2.4-1-aarch64-ARCH && $RESET_BOOT_IMAGE == initramfs-linux.img ]] || fail 'generic package preset' +pass 'stock Asahi and generic ARM default presets parsed without evaluation' +cp "$test_tmp/asahi.preset" "$test_tmp/custom.preset" +echo 'default_options="-S encrypt"' >>"$test_tmp/custom.preset" +if reset_boot_read_preset "$test_tmp/custom.preset"; then fail 'custom options silently lost'; fi +cp "$test_tmp/asahi.preset" "$test_tmp/custom.preset" +echo 'touch /unexpected' >>"$test_tmp/custom.preset" +if reset_boot_read_preset "$test_tmp/custom.preset"; then fail 'executable preset'; fi +pass 'unsupported options/commands fail without executing preset' +RESET_BOOT_KERNEL=fixture-version RESET_BOOT_PKGBASE=linux-asahi RESET_BOOT_KERNEL_FILE=vmlinuz-linux-asahi RESET_BOOT_IMAGE=initramfs-linux-asahi.img +RESET_BOOT_ROOT_UUID=11111111-1111-1111-1111-111111111111 RESET_BOOT_UUID=ABCD-1234 RESET_BOOT_LUKS_UUID="" +TEST_ROOT="$test_tmp/root" STAGE="$test_tmp/stage" +mkdir -p "$TEST_ROOT/usr/lib/modules/$RESET_BOOT_KERNEL" "$STAGE/files/grub" +printf 'kernel\n' >"$STAGE/files/$RESET_BOOT_KERNEL_FILE" +sha256sum "$STAGE/files/$RESET_BOOT_KERNEL_FILE" >"$STAGE/kernel-input" +printf 'kernel/fs/btrfs/btrfs.ko\n' >"$TEST_ROOT/usr/lib/modules/$RESET_BOOT_KERNEL/modules.builtin" +chroot() { + case $2 in + /usr/bin/grub-script-check) return 0 ;; + /usr/bin/lsinitcpio) printf '%s\n' "usr/lib/modules/$RESET_BOOT_KERNEL/" "${DECRYPT_CONTENTS:-}" "${KEY_CONTENTS:-}" ;; + *) return 99 ;; + esac +} +new_cfg() { + cat >"$STAGE/files/grub/grub.cfg" < Date: Sun, 13 Sep 2026 15:40:23 +0530 Subject: [PATCH 17/27] Keep channel downloads outside private home directories --- install/helpers/arm-channel.sh | 41 ++++++++++--- test/shell.d/arm-channel-staging-test.sh | 77 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 test/shell.d/arm-channel-staging-test.sh diff --git a/install/helpers/arm-channel.sh b/install/helpers/arm-channel.sh index 8dd19e9d1e2..2ccddc6f768 100644 --- a/install/helpers/arm-channel.sh +++ b/install/helpers/arm-channel.sh @@ -42,14 +42,41 @@ omarchy_arm_channel_render() { ' "$config" >"$output" } -# Keep large captured transactions on persistent disk, independent of /tmp. +# DownloadUser must traverse the whole path for downloads and frozen file:// +# repositories. A private HOME/cache cannot provide that contract. Allocate only +# our new child under verified root-controlled persistent parents; never loosen +# an existing directory or disable pacman's download sandbox. omarchy_arm_channel_stage_new() { - local scratch="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/channels" - mkdir -p "$scratch" || return - case $(findmnt -n -o FSTYPE -T "$scratch") in - "" | tmpfs | ramfs) echo "ARM channel staging needs a disk-backed cache directory." >&2; return 1 ;; - esac - mktemp -d "$scratch/transaction.XXXXXXXX" + sudo bash -euo pipefail -c ' + owner="$1"; group="$2"; stage=""; complete=0 + [[ $owner =~ ^[0-9]+$ && $group =~ ^[0-9]+$ ]] || exit 1 + cleanup() { + if [[ -n $stage && $complete == 0 ]]; then rmdir -- "$stage"; fi + } + trap cleanup EXIT + for path in / /var /var/cache /var/cache/omarchy /var/cache/omarchy/channels; do + if [[ ! -e $path && ! -L $path ]]; then + case "$path" in + /var/cache/omarchy | /var/cache/omarchy/channels) mkdir -m 755 -- "$path" ;; + *) echo "Missing channel cache parent: $path" >&2; exit 1 ;; + esac + fi + [[ -d $path && ! -L $path ]] || { echo "Unsafe channel cache parent: $path" >&2; exit 1; } + read -r uid mode < <(stat -c "%u %a" -- "$path") + [[ $uid == 0 && $mode =~ ^[0-7]{3,4}$ ]] && (( (8#$mode & 0022) == 0 && (8#$mode & 0001) != 0 )) || { + echo "Channel cache parent must be root-owned, traversable and not writable by other users: $path" >&2 + exit 1 + } + case $(findmnt -n -o FSTYPE -T "$path") in + "" | tmpfs | ramfs) echo "ARM channel staging needs disk-backed cache parents: $path" >&2; exit 1 ;; + esac + done + stage=$(mktemp -d /var/cache/omarchy/channels/transaction.XXXXXXXX) + chmod 755 "$stage" + chown "$owner:$group" "$stage" + printf "%s\n" "$stage" + complete=1 + ' bash "$(id -u)" "$(id -g)" } omarchy_arm_channel_stage_remove() { diff --git a/test/shell.d/arm-channel-staging-test.sh b/test/shell.d/arm-channel-staging-test.sh new file mode 100644 index 00000000000..7dd75188bf6 --- /dev/null +++ b/test/shell.d/arm-channel-staging-test.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/base-test.sh" +source "$ROOT/install/helpers/arm-channel.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +export TEST_STAGE_ROOT="$test_tmp/system" TEST_STAGE_CALLS="$test_tmp/calls" +mkdir -p "$test_tmp/bin" "$test_tmp/home" "$TEST_STAGE_ROOT/var/cache" +chmod 700 "$test_tmp/home" +chmod 755 "$TEST_STAGE_ROOT" "$TEST_STAGE_ROOT/var" "$TEST_STAGE_ROOT/var/cache" +# Run the exact privileged program against disposable directories. Only its +# fixed /var prefix and root ownership probe are modeled; real modes, symlinks, +# mktemp, mkdir, chmod and rmdir exercise the allocation/failure contract. +sudo() { + local -a args=("$@") + [[ ${args[0]} == bash && ${args[3]} == -c ]] || return 99 + args[4]=${args[4]//\/var/$TEST_STAGE_ROOT/var} + "${args[@]}" +} +cat >"$test_tmp/bin/stat" <<'SCRIPT' +#!/bin/bash +set -euo pipefail +if [[ $* == '-c %u %a -- '* && ( ${@: -1} == "$TEST_STAGE_ROOT"/* || ${@: -1} == / ) ]]; then + printf '%s %s\n' "$([[ ${@: -1} == / ]] && echo 0 || echo "${TEST_STAGE_OWNER:-0}")" "$(/usr/bin/stat -c %a -- "${@: -1}")" +else + /usr/bin/stat "$@" +fi +SCRIPT +cat >"$test_tmp/bin/findmnt" <<'SCRIPT' +#!/bin/bash +if [[ -n ${TEST_STAGE_FSTYPE:-} ]]; then + printf '%s\n' "$TEST_STAGE_FSTYPE" +else + /usr/bin/findmnt "$@" +fi +SCRIPT +cat >"$test_tmp/bin/chown" <<'SCRIPT' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_STAGE_CALLS" +exit "${TEST_STAGE_CHOWN_STATUS:-0}" +SCRIPT +chmod +x "$test_tmp/bin/"* +export PATH="$test_tmp/bin:$PATH" HOME="$test_tmp/home" XDG_CACHE_HOME="$test_tmp/home/private-cache" +first=$(omarchy_arm_channel_stage_new) +second=$(omarchy_arm_channel_stage_new) +[[ $first != "$second" && $first == "$TEST_STAGE_ROOT/var/cache/omarchy/channels/transaction."* ]] || fail 'separate unique system-cache stages' +[[ $(stat -c %a "$first") == 755 && $(stat -c %a "$HOME") == 700 && ! -e $XDG_CACHE_HOME ]] || fail 'download traversal never changes private home/cache' +[[ $(cat "$TEST_STAGE_CALLS") == "$(id -u):$(id -g) $first"$'\n'"$(id -u):$(id -g) $second" ]] || fail 'chown is restricted to the new transaction children' +pass 'fresh and repeated channel staging use unique traversable disk cache children without changing HOME' +rmdir "$first" "$second" + +for condition in symlink writable private wrong-owner ram; do + path="$TEST_STAGE_ROOT/var/cache/omarchy/channels" + case "$condition" in + symlink) rmdir "$path"; mkdir "$test_tmp/administrator"; ln -s "$test_tmp/administrator" "$path" ;; + writable) chmod 777 "$path" ;; + private) chmod 700 "$path" ;; + wrong-owner) export TEST_STAGE_OWNER=1234 ;; + ram) export TEST_STAGE_FSTYPE=tmpfs ;; + esac + : >"$TEST_STAGE_CALLS" + if omarchy_arm_channel_stage_new >"$test_tmp/rejected" 2>&1; then fail "$condition cache parent must refuse"; fi + [[ ! -s $TEST_STAGE_CALLS ]] || fail "$condition must not chown any existing tree" + case "$condition" in + symlink) [[ -L $path && -z $(ls -A "$test_tmp/administrator") ]] || fail 'preserve administrator symlink'; rm "$path"; mkdir -m755 "$path" ;; + writable) [[ $(stat -c %a "$path") == 777 ]] || fail 'preserve writable parent mode'; chmod 755 "$path" ;; + private) [[ $(stat -c %a "$path") == 700 ]] || fail 'preserve private parent mode'; chmod 755 "$path" ;; + wrong-owner) unset TEST_STAGE_OWNER ;; + ram) unset TEST_STAGE_FSTYPE ;; + esac +done +pass 'unsafe, private, symlink and RAM-backed parent paths are preserved and rejected' + +if TEST_STAGE_CHOWN_STATUS=1 omarchy_arm_channel_stage_new >"$test_tmp/rejected" 2>&1; then fail 'failed allocation ownership must fail'; fi +[[ -z $(ls -A "$TEST_STAGE_ROOT/var/cache/omarchy/channels") ]] || fail 'failed empty allocation must be removed' +[[ $(stat -c %a "$HOME") == 700 ]] || fail 'HOME remains private after failures' +pass 'allocation failure removes only its new empty transaction' From 30f530cf881ec5ea346a1ad85cdba1b40b19be3a Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 15:41:28 +0530 Subject: [PATCH 18/27] Prepare version 4.0.3rc2 --- version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version b/version index f9c1d302a1d..283f4d023be 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.0.3rc1 +4.0.3rc2 From 7e091f3df91bbd021f5aed2025739df05617d04c Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 16:18:33 +0530 Subject: [PATCH 19/27] Capture complete factory reset history under maintenance --- bin/omarchy-system-factory-reset | 46 +++++--- install/helpers/factory-reset.sh | 36 +++++-- ...actory-reset-inventory-maintenance-test.sh | 102 ++++++++++++++++++ test/shell.d/factory-reset-inventory-test.sh | 34 +++++- 4 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 test/shell.d/factory-reset-inventory-maintenance-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index e39da6ff4f4..1d785b3c8dd 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -113,13 +113,25 @@ cleanup() { echo "Original root and baseline restored. Failed staging is retained at $RESET_STATE and @omarchy-reset-* for inspection." >&2 fi fi + if [[ ${swap_done:-0} == 0 && ${reset_services_started:-0} == 1 && $rollback_ok == 1 ]]; then + if ! reset_limine_resume "$RESET_STATE"; then + rollback_ok=0 + echo "Limine service restoration needs inspection: $RESET_STATE/limine-services" >&2 + fi + if ! backend_resume_services; then + rollback_ok=0 + echo "Reset service restoration needs inspection: $BACKEND_SERVICE_RECEIPT" >&2 + fi + fi if [[ ${reset_started:-0} == 0 && ${reset_journal_created:-0} == 1 ]]; then - # This invocation has created only its private pre-confirm journal. - # Canceling the existing prompt must leave the command retryable. - reset_cancel_journal "$RESET_STATE" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY - -" || echo "Pre-confirm reset journal needs inspection: $RESET_STATE" >&2 - elif [[ ${swap_done:-0} == 0 && ${reset_services_started:-0} == 1 && $rollback_ok == 1 ]]; then - reset_limine_resume "$RESET_STATE" || echo "Limine service restoration needs inspection: $RESET_STATE/limine-services" >&2 - backend_resume_services || echo "Reset service restoration needs inspection: $BACKEND_SERVICE_RECEIPT" >&2 + # Maintenance starts before inventory and the prompt. Restore our service + # changes first; a failed restoration must retain its identities/receipt. + if (( rollback_ok )); then + reset_cancel_journal "$RESET_STATE" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY - -" || echo "Pre-confirm reset journal needs inspection: $RESET_STATE" >&2 + else + status=1 + echo "Pre-confirm maintenance restoration is incomplete; preserve $RESET_STATE for inspection." >&2 + fi fi umount -R "$TOP_MNT" 2>/dev/null || true fi @@ -335,12 +347,9 @@ rebuild_next_boot() { umount "$next$esp_mount" } -stage_full_reset() { - local top="$TOP_MNT" next="$TOP_MNT/$NEXT_NAME" clean="$TOP_MNT/@omarchy-reset-factory" user - reset_inventory_verify_sources "$top" "$RESET_STATE/inventory" || fail "Confirmed reset inventory changed" - [[ ! -e $next && ! -L $next && ! -e $clean && ! -L $clean ]] || fail "A prior reset staging object needs inspection" - reset_started=1 - reset_state_write "$RESET_STATE/backend" "$RESET_BOOT_BACKEND" || fail "Could not record boot backend" +prepare_reset_inventory() { + # The recovery lock is already held. Pause managed writers before capturing + # identities, and keep them paused across the prompt and root exchange. source "$OMARCHY_PATH/bin/omarchy-mac-snapper-backend" # shellcheck disable=SC2034 # consumed by the sourced recovery maintenance helpers BACKEND_MASKED=() BACKEND_TIMERS=() BACKEND_DAEMON=0 @@ -349,6 +358,16 @@ stage_full_reset() { reset_services_started=1 backend_quiesce || fail "Could not obtain exclusive Snapper maintenance" reset_limine_quiesce "$RESET_STATE" || fail "Could not obtain exclusive Limine maintenance" + reset_inventory_build "$TOP_MNT" "$RESET_TXN_STAMP" "$RESET_STATE/inventory" || fail "Could not construct complete reset inventory" + reset_inventory_verify_sources "$TOP_MNT" "$RESET_STATE/inventory" || fail "Reset inventory is not stable" +} + +stage_full_reset() { + local top="$TOP_MNT" next="$TOP_MNT/$NEXT_NAME" clean="$TOP_MNT/@omarchy-reset-factory" user + reset_inventory_verify_sources "$top" "$RESET_STATE/inventory" || fail "Confirmed reset inventory changed" + [[ ! -e $next && ! -L $next && ! -e $clean && ! -L $clean ]] || fail "A prior reset staging object needs inspection" + reset_started=1 + reset_state_write "$RESET_STATE/backend" "$RESET_BOOT_BACKEND" || fail "Could not record boot backend" reset_state_write "$RESET_STATE/phase" preparing || fail "Could not record reset preparation" log "Cloning the factory snapshot" btrfs subvolume snapshot "$top/@factory" "$next" >>"$LOG_FILE" @@ -506,8 +525,7 @@ main() { RESET_TXN_FACTORY=$(reset_uuid "$TOP_MNT/@factory") || fail "Could not identify factory baseline" RESET_TXN_NEXT=- RESET_TXN_CLEAN=- reset_transaction_record "$RESET_STATE" || fail "Could not record reset identities" - reset_inventory_build "$TOP_MNT" "$RESET_TXN_STAMP" "$RESET_STATE/inventory" || fail "Could not construct complete reset inventory" - reset_inventory_verify_sources "$TOP_MNT" "$RESET_STATE/inventory" || fail "Reset inventory is not stable" + prepare_reset_inventory confirm_reset stage_full_reset diff --git a/install/helpers/factory-reset.sh b/install/helpers/factory-reset.sh index 4506c883541..8f00b2ab9a1 100644 --- a/install/helpers/factory-reset.sh +++ b/install/helpers/factory-reset.sh @@ -26,20 +26,33 @@ reset_nested_paths() { done <<<"$listing" } reset_inventory_add() { - local top=$1 source=$2 destination=$3 role=$4 manifest=$5 path identity relative + local top=$1 source=$2 destination=$3 role=$4 manifest=$5 path identity relative nested child entry_role reset_safe_path "$source" && reset_safe_path "$destination" || return 1 - identity=$(reset_uuid "$top/$source") || return $? - printf '%s\t%s\t%s\t%s\n' "$identity" "$source" "$destination" "$role" >>"$manifest" || return $? - local nested - nested=$(reset_nested_paths "$top/$source") || return $? - while IFS= read -r path; do - [[ -n $path ]] || continue - [[ $path == "$source/"* ]] || { reset_error "Unexpected nested subvolume: $path"; return 1; } - relative=${path#"$source/"} + # list -o reports direct children, not the complete descendant tree. Walk + # each child's own list so nested Snapper snapshots are explicitly authorized. + local -a pending=("$source") + local -A seen=() + while (( ${#pending[@]} )); do + path=${pending[0]} + pending=("${pending[@]:1}") + [[ ! ${seen[$path]+yes} ]] || continue + seen[$path]=1 identity=$(reset_uuid "$top/$path") || return $? - printf '%s\t%s\t%s/%s\t%s\n' "$identity" "$path" "$destination" "$relative" "nested-$role" >>"$manifest" || return $? - done <<<"$nested" + if [[ $path == "$source" ]]; then + relative="" entry_role=$role + else + relative="/${path#"$source/"}" entry_role="nested-$role" + fi + printf '%s\t%s\t%s%s\t%s\n' "$identity" "$path" "$destination" "$relative" "$entry_role" >>"$manifest" || return $? + nested=$(reset_nested_paths "$top/$path") || return $? + while IFS= read -r child; do + [[ -n $child ]] || continue + [[ $child == "$path/"* ]] || { reset_error "Unexpected nested subvolume: $child"; return 1; } + pending+=("$child") + done <<<"$nested" + done } + reset_inventory_build() { local top=$1 stamp=$2 manifest=$3 candidate [[ $stamp =~ ^[0-9]+$ && ! -e $manifest && ! -L $manifest ]] || return 1 @@ -460,4 +473,5 @@ reset_limine_resume() { done <"$state/limine-services" for unit in "${masks[@]}"; do systemctl unmask --runtime "$unit" || return $?; done for unit in "${paths[@]}"; do systemctl start "$unit" || return $?; done + rm -- "$state/limine-services" } diff --git a/test/shell.d/factory-reset-inventory-maintenance-test.sh b/test/shell.d/factory-reset-inventory-maintenance-test.sh new file mode 100644 index 00000000000..3b523d0f62d --- /dev/null +++ b/test/shell.d/factory-reset-inventory-maintenance-test.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/base-test.sh" +source "$ROOT/install/helpers/factory-reset.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +python3 - "$ROOT/bin/omarchy-system-factory-reset" "$test_tmp/functions" <<'PY' +import pathlib,re,sys +text=pathlib.Path(sys.argv[1]).read_text() +functions=[] +for name in ['cleanup','prepare_reset_inventory']: + match=re.search(r'^'+name+r'\(\) \{\n.*?^\}',text,re.M|re.S) + assert match,name + functions.append(match.group()) +main=text[text.index('main() {'):] +assert main.index('prepare_reset_inventory')"$OMARCHY_PATH/bin/omarchy-mac-snapper-backend" <<'SHIM' +backend_quiesce() { + echo snapper-quiesce >>"$EVENTS" + writer_active=0 + [[ ${QUIESCE_FAIL:-0} == 0 ]] +} +backend_resume_services() { + echo snapper-resume >>"$EVENTS" + [[ ${RESUME_FAIL:-0} == 0 ]] || return 1 + writer_active=1 + rm "$BACKEND_SERVICE_RECEIPT" +} +SHIM +stat() { if [[ $* == '-c %u '* ]]; then echo 0; else command stat "$@"; fi; } +mountpoint() { return 0; } +umount() { :; } +fail() { echo "$*" >&2; exit 1; } +reset_limine_quiesce() { + echo limine-quiesce >>"$EVENTS" + printf '%s\n' 'mask limine-snapper-sync.path' 'path limine-snapper-sync.path' >"$RESET_STATE/limine-services" + chmod 600 "$RESET_STATE/limine-services" +} +systemctl() { echo "limine-$*" >>"$EVENTS"; [[ ${LIMINE_RESUME_FAIL:-0} == 0 ]]; } +reset_inventory_build() { + echo inventory-build >>"$EVENTS" + echo captured >"$3" + # A managed writer can add a descendant between capture and verification. + (( !writer_active )) || echo appeared >>"$test_tmp/history" +} +reset_inventory_verify_sources() { + echo inventory-verify >>"$EVENTS" + [[ ! -s $test_tmp/history ]] +} +setup_case() { + TOP_MNT="$test_tmp/$1" RESET_STATE="$test_tmp/$1/state" EVENTS="$test_tmp/$1-events" + mkdir -p "$RESET_STATE" + chmod 700 "$RESET_STATE" + RESET_TXN_FS=f RESET_TXN_STAMP=s RESET_TXN_ROOT=r RESET_TXN_FACTORY=b + printf '%s\n' 'f s r b - -' >"$RESET_STATE/identities" + chmod 600 "$RESET_STATE/identities" + : >"$EVENTS"; : >"$test_tmp/history" + # shellcheck disable=SC2034 # read by the extracted command functions + swap_done=0 reset_started=0 reset_journal_created=1 reset_services_started=0 writer_active=1 +} +setup_case unpaused +reset_inventory_build "$TOP_MNT" s "$RESET_STATE/inventory" || true +if reset_inventory_verify_sources "$TOP_MNT" "$RESET_STATE/inventory"; then fail 'modeled writer must invalidate unpaused inventory'; fi +setup_case cancel +prepare_reset_inventory +[[ $(cat "$EVENTS") == $'snapper-quiesce\nlimine-quiesce\ninventory-build\ninventory-verify' ]] || fail 'maintenance must precede capture and remain active' +cleanup +[[ ! -e $RESET_STATE && $writer_active == 1 ]] || fail 'cancel must resume services before removing its journal' +pass 'managed writer is paused before inventory, and cancellation restores services and removes only the owned journal' + +if ( + setup_case failed-quiesce + trap cleanup EXIT + export QUIESCE_FAIL=1 + prepare_reset_inventory +); then fail 'partial quiesce must fail'; fi +[[ ! -e $test_tmp/failed-quiesce/state ]] || fail 'partial maintenance failure must remove its journal after restoring services' +grep -qx snapper-resume "$test_tmp/failed-quiesce-events" || fail 'partial failure did not restore services' +pass 'partial pre-confirm maintenance failure restores services and leaves reset retryable' + +setup_case failed-resume +prepare_reset_inventory +export RESUME_FAIL=1 +if cleanup; then fail 'failed service restoration must fail cleanup'; fi +unset RESUME_FAIL +[[ -f $RESET_STATE/identities && -f $RESET_STATE/inventory && -f $RESET_STATE/services ]] || fail 'failed restoration must retain identities, inventory and receipt' +pass 'failed restoration preserves the pre-confirm journal and service receipt for inspection' + +setup_case failed-limine-resume +prepare_reset_inventory +LIMINE_RESUME_FAIL=1 +if cleanup; then fail 'failed Limine restoration must fail cleanup'; fi +unset LIMINE_RESUME_FAIL +[[ -f $RESET_STATE/identities && -f $RESET_STATE/limine-services ]] || fail 'failed Limine restoration must preserve its validated receipt and identities' +pass 'real Limine receipt is removed only after successful restoration and retained on failure' diff --git a/test/shell.d/factory-reset-inventory-test.sh b/test/shell.d/factory-reset-inventory-test.sh index 21c6556cb91..bb67b98b047 100644 --- a/test/shell.d/factory-reset-inventory-test.sh +++ b/test/shell.d/factory-reset-inventory-test.sh @@ -10,9 +10,14 @@ fixture_volume() { mkdir -p "$1"; printf '%s\n' "$2" >"$1/.uuid"; } reset_uuid() { [[ ! -L $1 && -f $1/.uuid ]] && cat "$1/.uuid"; } reset_empty_volume() { [[ -z $(find "$1" -mindepth 1 ! -name .uuid -print -quit) ]]; } reset_nested_paths() { - local file - while IFS= read -r file; do printf '%s\n' "${file#"$TOP/"}" | sed 's|/\.uuid$||'; done < <(find "$1" -mindepth 2 -name .uuid | sort) + local path + # Match real list -o: stop at each directly contained subvolume instead of + # flattening all descendants (which hid the missing recursive walk). + while IFS= read -r path; do printf '%s\n' "${path#"$TOP/"}"; done < <( + find "$1" -mindepth 1 -type d -exec test -f '{}/.uuid' \; -print -prune | sort + ) } + findmnt() { printf '%s\n' "${MOUNTS:-/ /test none}"; } btrfs() { case "$1 $2" in @@ -106,3 +111,28 @@ if reset_recreate_clean_subvolume "$TOP" @home "$STATE"; then fail 'receipt writ reset_state_write() { real_state_write "$@"; } reset_recreate_clean_subvolume "$TOP" @home "$STATE" pass 'failed replacement receipt cleans only own empty temporary and permits retry' + +new_fixture deep +fixture_volume "$TOP/@/.snapshots" 88888888-8888-8888-8888-888888888888 +fixture_volume "$TOP/@/.snapshots/1/snapshot" 99999999-9999-9999-9999-999999999999 +fixture_volume "$TOP/@/.snapshots/1/snapshot/deeper" aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +MANIFEST="$test_tmp/deep/complete-inventory" +reset_inventory_build "$TOP" 123 "$MANIFEST" +[[ $(wc -l <"$MANIFEST") == 9 ]] || fail 'every depth must be captured exactly once' +grep -qx $'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\t@/.snapshots/1/snapshot/deeper\t@omarchy-old-123/.snapshots/1/snapshot/deeper\tnested-current-root' "$MANIFEST" || fail 'deep UUID and destination mapping' +reset_inventory_verify_sources "$TOP" "$MANIFEST" +pass 'direct-child enumeration recursively captures three nested levels with exact destination identities' +eval "$(declare -f reset_nested_paths | sed '1s/reset_nested_paths/direct_nested_paths/')" +reset_nested_paths() { + direct_nested_paths "$1" + if [[ $1 == "$TOP/@" ]]; then + printf '%s\n' '@/.snapshots/1/snapshot' '@/.snapshots/1/snapshot/deeper' + fi +} +reset_inventory_build "$TOP" 123 "$test_tmp/deep/recursive-list-inventory" +cmp "$MANIFEST" "$test_tmp/deep/recursive-list-inventory" || fail 'recursive list variants must record each path once' +reset_inventory_verify_sources "$TOP" "$test_tmp/deep/recursive-list-inventory" +pass 'repeated paths from recursive listing variants are inventoried exactly once' +fixture_volume "$TOP/@/.snapshots/duplicate" aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +if reset_inventory_build "$TOP" 123 "$test_tmp/deep/duplicate-inventory"; then fail 'duplicate descendant UUID must refuse'; fi +pass 'recursive inventory refuses duplicate identities without authorizing cleanup' From 3107742e2562d419217158d62452f28e846dd1ad Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 16:35:37 +0530 Subject: [PATCH 20/27] Use a private read-only view of the mounted ESP --- install/helpers/reset-boot.sh | 13 ++++++++++++- test/shell.d/reset-boot-test.sh | 9 +++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/install/helpers/reset-boot.sh b/install/helpers/reset-boot.sh index d1a93c7b2e7..0be4dc94e40 100644 --- a/install/helpers/reset-boot.sh +++ b/install/helpers/reset-boot.sh @@ -145,6 +145,17 @@ reset_boot_stage_path_valid() { [[ $(findmnt -rn -T "${stage%/*}" -o FSTYPE) == btrfs && $(findmnt -rn -T "${stage%/*}" -o UUID) == "$root_uuid" ]] } +reset_boot_bind_boot_readonly() { + local root=${1%/} + [[ -n $root ]] || root=/ + # The supported GRUB topology already has this VFAT mounted at /boot. + # A second device mount with different read-only state is rejected by the + # kernel. Give the private namespace a read-only bind view instead; this + # does not change the live /boot mount or the underlying superblock state. + mount --bind /boot "$root/boot" || return $? + mount -o remount,bind,ro "$root/boot" +} + reset_boot_prepare() { local root=$1 stage=$2 key_mode=$3 [[ $key_mode == provision || $key_mode == owner ]] || return 1 @@ -172,7 +183,7 @@ reset_boot_generate_private() { kernel=$RESET_BOOT_KERNEL_FILE image=$RESET_BOOT_IMAGE cp -- "$RESET_BOOT_KERNEL_SOURCE" "$stage/files/$kernel" || return $? sha256sum "$RESET_BOOT_KERNEL_SOURCE" >"$stage/kernel-input" || return $? - mount -o ro "$RESET_BOOT_DEVICE" "$root/boot" || return $? + reset_boot_bind_boot_readonly "$root" || return $? chroot "$root" /usr/bin/env TMPDIR=/run/tmp TMP=/run/tmp TEMP=/run/tmp /usr/bin/mkinitcpio \ --nopost -k "$RESET_BOOT_KERNEL" -g /run/initramfs.img || return $? mv "$stage/runtime/initramfs.img" "$stage/files/$image" || return $? diff --git a/test/shell.d/reset-boot-test.sh b/test/shell.d/reset-boot-test.sh index c11862a13db..85cd2902fec 100644 --- a/test/shell.d/reset-boot-test.sh +++ b/test/shell.d/reset-boot-test.sh @@ -84,3 +84,12 @@ reset_boot_stage_path_valid "$RESET_BOOT_ROOT_UUID" /run/omarchy-factory-stage-f STAGE_FS=tmpfs if reset_boot_stage_path_valid "$RESET_BOOT_ROOT_UUID" /run/omarchy-factory-stage-fixture; then fail 'RAM staging accepted'; fi pass 'actual filesystem backing governs run-path staging acceptance' + +mount() { printf '%s\n' "$*" >>"$test_tmp/mount.log"; } +mkdir -p "$test_tmp/next/boot" +reset_boot_bind_boot_readonly "$test_tmp/next" +mapfile -t mount_calls <"$test_tmp/mount.log" +[[ ${#mount_calls[@]} == 2 ]] || fail 'unexpected boot bind call count' +[[ ${mount_calls[0]} == "--bind /boot $test_tmp/next/boot" ]] || fail 'live boot was not bind-mounted' +[[ ${mount_calls[1]} == "-o remount,bind,ro $test_tmp/next/boot" ]] || fail 'private boot view was not made read-only' +pass 'private boot view reuses the mounted VFAT without changing superblock state' From 525e43051fea198fabefd7feaf52dd72bf31e954 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 16:40:33 +0530 Subject: [PATCH 21/27] Expose staged reset roots to GRUB probes --- install/helpers/reset-boot.sh | 10 ++++++++++ test/shell.d/reset-boot-test.sh | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/install/helpers/reset-boot.sh b/install/helpers/reset-boot.sh index 0be4dc94e40..d9f310282f4 100644 --- a/install/helpers/reset-boot.sh +++ b/install/helpers/reset-boot.sh @@ -145,6 +145,15 @@ reset_boot_stage_path_valid() { [[ $(findmnt -rn -T "${stage%/*}" -o FSTYPE) == btrfs && $(findmnt -rn -T "${stage%/*}" -o UUID) == "$root_uuid" ]] } +reset_boot_bind_staged_root() { + local root=${1%/} + [[ -n $root ]] || root=/ + # grub-probe resolves devices through mountinfo. A Btrfs subvolume reached + # only as a directory below the top-level mount is invisible there after + # chroot, so expose the staged subvolume as its own private mount. + [[ $root == / ]] || mount --bind "$root" "$root" +} + reset_boot_bind_boot_readonly() { local root=${1%/} [[ -n $root ]] || root=/ @@ -174,6 +183,7 @@ reset_boot_generate_private() { install -d -m 700 "$stage/runtime" "$stage/runtime/tmp" "$stage/tmp" "$stage/files/grub" "$stage/files/m1n1" || return $? # /run and /tmp are backed by the caller's disk staging directory. These # mounts are private and disappear on exit, including generator failures. + reset_boot_bind_staged_root "$root" || return $? for directory in proc sys dev; do mount --rbind "/$directory" "$root/$directory" || return $? mount --make-rslave "$root/$directory" || return $? diff --git a/test/shell.d/reset-boot-test.sh b/test/shell.d/reset-boot-test.sh index 85cd2902fec..1b38c2d92bc 100644 --- a/test/shell.d/reset-boot-test.sh +++ b/test/shell.d/reset-boot-test.sh @@ -87,6 +87,12 @@ pass 'actual filesystem backing governs run-path staging acceptance' mount() { printf '%s\n' "$*" >>"$test_tmp/mount.log"; } mkdir -p "$test_tmp/next/boot" +reset_boot_bind_staged_root "$test_tmp/next" +reset_boot_bind_staged_root / +mapfile -t root_mount_calls <"$test_tmp/mount.log" +[[ ${#root_mount_calls[@]} == 1 && ${root_mount_calls[0]} == "--bind $test_tmp/next $test_tmp/next" ]] || fail 'staged root mount visibility' +pass 'staged subvolume is exposed as a mount while the real root needs no bind' +: >"$test_tmp/mount.log" reset_boot_bind_boot_readonly "$test_tmp/next" mapfile -t mount_calls <"$test_tmp/mount.log" [[ ${#mount_calls[@]} == 2 ]] || fail 'unexpected boot bind call count' From 784bcb73a16e5909fd15e4b9670c1995bf4ab5a0 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 17:04:23 +0530 Subject: [PATCH 22/27] Hand factory resets the Btrfs default root --- bin/omarchy-system-factory-reset | 5 +- install/helpers/factory-reset.sh | 69 +++++++++++++++++-- test/shell.d/factory-reset-default-test.sh | 39 +++++++++++ ...actory-reset-inventory-maintenance-test.sh | 4 +- test/shell.d/factory-reset-inventory-test.sh | 9 ++- .../shell.d/factory-reset-transaction-test.sh | 25 +++++++ 6 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 test/shell.d/factory-reset-default-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 1d785b3c8dd..6186f4a47eb 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -127,7 +127,7 @@ cleanup() { # Maintenance starts before inventory and the prompt. Restore our service # changes first; a failed restoration must retain its identities/receipt. if (( rollback_ok )); then - reset_cancel_journal "$RESET_STATE" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY - -" || echo "Pre-confirm reset journal needs inspection: $RESET_STATE" >&2 + reset_cancel_journal "$RESET_STATE" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY - - $RESET_TXN_DEFAULT $RESET_TXN_DEFAULT_PATH" || echo "Pre-confirm reset journal needs inspection: $RESET_STATE" >&2 else status=1 echo "Pre-confirm maintenance restoration is incomplete; preserve $RESET_STATE for inspection." >&2 @@ -455,6 +455,7 @@ stage_full_reset() { mv -T "$top/@" "$top/@omarchy-old-$RESET_TXN_STAMP" mv -T "$next" "$top/@" [[ $(reset_uuid "$top/@") == "$RESET_TXN_NEXT" && $(reset_uuid "$top/@factory") == "$RESET_TXN_CLEAN" ]] || fail "Root exchange identity check failed" + reset_default_set "$top" "$RESET_TXN_NEXT" "$top/@" || fail "Could not hand the Btrfs default to the factory root" printf 'pending %s %s\n' "$RESET_TXN_FS" "$RESET_TXN_NEXT" >/var/lib/omarchy/factory-reset.lock reset_state_write "$RESET_STATE/phase" committed || fail "Could not record completed exchange" sync -f "$top" @@ -523,9 +524,11 @@ main() { RESET_TXN_FS=$(findmnt -rn -T "$TOP_MNT" -o UUID) RESET_TXN_ROOT=$(reset_uuid "$TOP_MNT/@") || fail "Could not identify current root" RESET_TXN_FACTORY=$(reset_uuid "$TOP_MNT/@factory") || fail "Could not identify factory baseline" + IFS=$'\t' read -r RESET_TXN_DEFAULT RESET_TXN_DEFAULT_PATH < <(reset_default_identity "$TOP_MNT") || fail "Could not identify the Btrfs default subvolume" RESET_TXN_NEXT=- RESET_TXN_CLEAN=- reset_transaction_record "$RESET_STATE" || fail "Could not record reset identities" prepare_reset_inventory + reset_default_in_inventory "$RESET_STATE/inventory" "$RESET_TXN_DEFAULT" "$RESET_TXN_DEFAULT_PATH" || fail "The Btrfs default is outside the confirmed reset inventory" confirm_reset stage_full_reset diff --git a/install/helpers/factory-reset.sh b/install/helpers/factory-reset.sh index 8f00b2ab9a1..6d2742a3696 100644 --- a/install/helpers/factory-reset.sh +++ b/install/helpers/factory-reset.sh @@ -10,6 +10,53 @@ reset_uuid() { [[ $identity =~ ^[a-fA-F0-9-]{36}$ ]] || return 1 printf '%s\n' "$identity" } +reset_default_identity() { + local top=$1 row path identity + row=$(LC_ALL=C btrfs subvolume get-default "$top") || return $? + if [[ $row == 'ID 5 (FS_TREE)' ]]; then + printf '%s\t%s\n' - - + return 0 + fi + [[ $row == ID\ *\ gen\ *\ top\ level\ *\ path\ * ]] || return 1 + path=${row#* path } + reset_safe_path "$path" || return 1 + identity=$(reset_uuid "$top/$path") || return $? + printf '%s\t%s\n' "$identity" "$path" +} +reset_default_uuid() { + local identity path extra + IFS=$'\t' read -r identity path extra < <(reset_default_identity "$1") || return $? + [[ -z $extra ]] || return 1 + printf '%s\n' "$identity" +} +reset_default_set() { + local top=$1 expected=$2 path=$3 + [[ $expected =~ ^[a-fA-F0-9-]{36}$ && $path == "$top/"* && $(reset_uuid "$path") == "$expected" ]] || return 1 + btrfs subvolume set-default "$path" || return $? + [[ $(reset_default_uuid "$top") == "$expected" ]] +} +reset_default_set_top() { + local top=$1 + btrfs subvolume set-default 5 "$top" || return $? + [[ $(reset_default_uuid "$top") == - ]] +} +reset_default_restore() { + local top=$1 expected=$2 path=$3 + if [[ $expected == - && $path == - ]]; then + reset_default_set_top "$top" + else + reset_safe_path "$path" && reset_default_set "$top" "$expected" "$top/$path" + fi +} +reset_default_in_inventory() { + local manifest=$1 identity=$2 path=$3 + [[ $identity == - && $path == - ]] && return 0 + [[ $identity =~ ^[a-fA-F0-9-]{36}$ ]] && reset_safe_path "$path" || return 1 + awk -F '\t' -v identity="$identity" -v path="$path" ' + $1 == identity && $2 == path { matches++ } + END { exit(matches == 1 ? 0 : 1) } + ' "$manifest" +} reset_safe_path() { [[ $1 =~ ^[a-zA-Z0-9@._/-]+$ && $1 != /* && $1 != */ && $1 != *//* && /$1/ != */../* && /$1/ != */./* ]] } @@ -183,10 +230,16 @@ reset_row_state() { } } reset_cleanup_preflight() { - local top=$1 manifest=$2 state=$3 filesystem_uuid=$4 identity source destination role nested path + local top=$1 manifest=$2 state=$3 filesystem_uuid=$4 identity source destination role nested path default_uuid local -A planned=() reset_inventory_validate "$manifest" && reset_state_bind "$manifest" "$state" "$filesystem_uuid" || return $? - while IFS=$'\t' read -r identity source destination role; do planned[$destination]=$identity; done <"$manifest" + default_uuid=$(reset_default_uuid "$top") || return $? + while IFS=$'\t' read -r identity source destination role; do + [[ $default_uuid == - || $identity != "$default_uuid" ]] || { + reset_error "Refusing to delete the default subvolume: $destination"; return 1; + } + planned[$destination]=$identity + done <"$manifest" while IFS=$'\t' read -r identity source destination role; do reset_row_state "$top" "$state" "$identity" "$destination" || return $? (( RESET_ROW_PRESENT )) || continue @@ -340,14 +393,19 @@ reset_transaction_read() { local state=$1 extra [[ -d $state && ! -L $state && $(stat -c %u "$state") == 0 && $(stat -c %a "$state") == 700 ]] || return 1 reset_private_file "$state/identities" || return 1 - read -r RESET_TXN_FS RESET_TXN_STAMP RESET_TXN_ROOT RESET_TXN_FACTORY RESET_TXN_NEXT RESET_TXN_CLEAN extra <"$state/identities" + read -r RESET_TXN_FS RESET_TXN_STAMP RESET_TXN_ROOT RESET_TXN_FACTORY RESET_TXN_NEXT RESET_TXN_CLEAN RESET_TXN_DEFAULT RESET_TXN_DEFAULT_PATH extra <"$state/identities" [[ -z $extra && $RESET_TXN_STAMP =~ ^[0-9]+$ ]] || return 1 local identity for identity in "$RESET_TXN_FS" "$RESET_TXN_ROOT" "$RESET_TXN_FACTORY"; do [[ $identity =~ ^[a-fA-F0-9-]{36}$ ]] || return 1; done - for identity in "$RESET_TXN_NEXT" "$RESET_TXN_CLEAN"; do [[ $identity == - || $identity =~ ^[a-fA-F0-9-]{36}$ ]] || return 1; done + for identity in "$RESET_TXN_NEXT" "$RESET_TXN_CLEAN" "$RESET_TXN_DEFAULT"; do [[ $identity == - || $identity =~ ^[a-fA-F0-9-]{36}$ ]] || return 1; done + if [[ $RESET_TXN_DEFAULT == - ]]; then + [[ $RESET_TXN_DEFAULT_PATH == - ]] || return 1 + else + reset_safe_path "$RESET_TXN_DEFAULT_PATH" || return 1 + fi } reset_transaction_record() { - reset_state_write "$1/identities" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY $RESET_TXN_NEXT $RESET_TXN_CLEAN" + reset_state_write "$1/identities" "$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY $RESET_TXN_NEXT $RESET_TXN_CLEAN $RESET_TXN_DEFAULT $RESET_TXN_DEFAULT_PATH" } reset_transaction_rollback() { local top=$1 state=$2 current @@ -377,6 +435,7 @@ reset_transaction_rollback() { [[ $(reset_uuid "$top/@omarchy-old-factory-$RESET_TXN_STAMP") == "$RESET_TXN_FACTORY" ]] || return 1 mv -T "$top/@omarchy-old-factory-$RESET_TXN_STAMP" "$top/@factory" || return $? fi + reset_default_restore "$top" "$RESET_TXN_DEFAULT" "$RESET_TXN_DEFAULT_PATH" || return $? if [[ -e $state/boot/publication || -L $state/boot/publication ]]; then reset_private_file "$state/boot/publication" || return 1 if [[ $(cat "$state/boot/publication") != rolled-back ]]; then diff --git a/test/shell.d/factory-reset-default-test.sh b/test/shell.d/factory-reset-default-test.sh new file mode 100644 index 00000000000..708908ea413 --- /dev/null +++ b/test/shell.d/factory-reset-default-test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/factory-reset.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +top="$test_tmp/top" +mkdir -p "$top/@old" "$top/@next" +printf '%s\n' 11111111-1111-1111-1111-111111111111 >"$top/@old/.uuid" +printf '%s\n' 22222222-2222-2222-2222-222222222222 >"$top/@next/.uuid" +reset_uuid() { [[ -f $1/.uuid && ! -L $1 ]] && cat "$1/.uuid"; } +DEFAULT_PATH=@old +btrfs() { + case "$1 $2" in + 'subvolume get-default') + if [[ $DEFAULT_PATH == FS_TREE ]]; then printf '%s\n' 'ID 5 (FS_TREE)'; + else printf 'ID 257 gen 42 top level 5 path %s\n' "$DEFAULT_PATH"; fi ;; + 'subvolume set-default') + if [[ $3 == 5 && $4 == "$top" ]]; then DEFAULT_PATH=FS_TREE; + elif [[ $3 == "$top/"* ]]; then DEFAULT_PATH=${3#"$top/"}; + else return 1; fi ;; + *) return 1 ;; + esac +} +[[ $(reset_default_uuid "$top") == 11111111-1111-1111-1111-111111111111 ]] || fail 'default UUID parsing' +[[ $(reset_default_identity "$top") == $'11111111-1111-1111-1111-111111111111\t@old' ]] || fail 'default identity and path binding' +manifest="$test_tmp/inventory" +printf '%s\t%s\t%s\t%s\n' 11111111-1111-1111-1111-111111111111 @old @old retained >"$manifest" +reset_default_in_inventory "$manifest" 11111111-1111-1111-1111-111111111111 @old +if reset_default_in_inventory "$manifest" 22222222-2222-2222-2222-222222222222 @next; then fail 'uninventoried default accepted'; fi +reset_default_set "$top" 22222222-2222-2222-2222-222222222222 "$top/@next" +[[ $DEFAULT_PATH == @next && $(reset_default_uuid "$top") == 22222222-2222-2222-2222-222222222222 ]] || fail 'default handoff verification' +reset_default_set_top "$top" +[[ $DEFAULT_PATH == FS_TREE && $(reset_default_uuid "$top") == - ]] || fail 'top-level default restoration' +reset_default_restore "$top" 11111111-1111-1111-1111-111111111111 @old +[[ $DEFAULT_PATH == @old ]] || fail 'recorded retained default restoration' +DEFAULT_PATH=../unsafe +if reset_default_uuid "$top"; then fail 'unsafe default path accepted'; fi +pass 'default identity, handoff, restoration and unsafe-path refusal' diff --git a/test/shell.d/factory-reset-inventory-maintenance-test.sh b/test/shell.d/factory-reset-inventory-maintenance-test.sh index 3b523d0f62d..872c2c77bab 100644 --- a/test/shell.d/factory-reset-inventory-maintenance-test.sh +++ b/test/shell.d/factory-reset-inventory-maintenance-test.sh @@ -58,8 +58,8 @@ setup_case() { TOP_MNT="$test_tmp/$1" RESET_STATE="$test_tmp/$1/state" EVENTS="$test_tmp/$1-events" mkdir -p "$RESET_STATE" chmod 700 "$RESET_STATE" - RESET_TXN_FS=f RESET_TXN_STAMP=s RESET_TXN_ROOT=r RESET_TXN_FACTORY=b - printf '%s\n' 'f s r b - -' >"$RESET_STATE/identities" + RESET_TXN_FS=f RESET_TXN_STAMP=s RESET_TXN_ROOT=r RESET_TXN_FACTORY=b RESET_TXN_DEFAULT=r RESET_TXN_DEFAULT_PATH=@ + printf '%s\n' 'f s r b - - r @' >"$RESET_STATE/identities" chmod 600 "$RESET_STATE/identities" : >"$EVENTS"; : >"$test_tmp/history" # shellcheck disable=SC2034 # read by the extracted command functions diff --git a/test/shell.d/factory-reset-inventory-test.sh b/test/shell.d/factory-reset-inventory-test.sh index bb67b98b047..b165661aa7b 100644 --- a/test/shell.d/factory-reset-inventory-test.sh +++ b/test/shell.d/factory-reset-inventory-test.sh @@ -8,6 +8,7 @@ stat() { if [[ $* == '-c %u '* ]]; then echo 0; else command stat "$@"; fi; } sync() { :; } fixture_volume() { mkdir -p "$1"; printf '%s\n' "$2" >"$1/.uuid"; } reset_uuid() { [[ ! -L $1 && -f $1/.uuid ]] && cat "$1/.uuid"; } +reset_default_uuid() { printf '%s\n' "${DEFAULT_UUID:--}"; } reset_empty_volume() { [[ -z $(find "$1" -mindepth 1 ! -name .uuid -print -quit) ]]; } reset_nested_paths() { local path @@ -29,7 +30,7 @@ btrfs() { new_fixture() { TOP="$test_tmp/$1/top"; mkdir -p "$TOP" MANIFEST="$test_tmp/$1/inventory" STATE="$test_tmp/$1/state" DELETIONS="$test_tmp/$1/deletions" - : >"$DELETIONS"; MOUNTS="" FAIL_DELETE="" + : >"$DELETIONS"; MOUNTS="" FAIL_DELETE="" DEFAULT_UUID=- fixture_volume "$TOP/@" 11111111-1111-1111-1111-111111111111 fixture_volume "$TOP/@factory" 22222222-2222-2222-2222-222222222222 fixture_volume "$TOP/@old-1" 33333333-3333-3333-3333-333333333333 @@ -60,6 +61,12 @@ MOUNTS="/somewhere $FS_UUID /@old-1/.snapshots" if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'mounted nested history'; fi [[ ! -s $DELETIONS ]] || fail 'whole inventory preflight before any deletion' pass 'mounted descendant prevents all cleanup mutation' +new_fixture default +move_roots +DEFAULT_UUID=33333333-3333-3333-3333-333333333333 +if reset_cleanup_inventory "$TOP" "$MANIFEST" "$STATE" "$FS_UUID"; then fail 'default subvolume deletion'; fi +[[ ! -s $DELETIONS ]] || fail 'default subvolume refusal must precede deletion' +pass 'cleanup refuses a selected default subvolume before deleting anything' new_fixture unexpected move_roots fixture_volume "$TOP/@old-1/new-child" 88888888-8888-8888-8888-888888888888 diff --git a/test/shell.d/factory-reset-transaction-test.sh b/test/shell.d/factory-reset-transaction-test.sh index 0d6d87f4a5e..ededb6c1ec0 100644 --- a/test/shell.d/factory-reset-transaction-test.sh +++ b/test/shell.d/factory-reset-transaction-test.sh @@ -10,12 +10,20 @@ reset_uuid() { [[ ! -L $1 && -f $1/.uuid ]] && cat "$1/.uuid"; } findmnt() { echo "$RESET_TXN_FS"; } fixture_volume() { mkdir -p "$1"; echo "$2" >"$1/.uuid"; } reset_boot_rollback() { [[ $1 == "$STATE/boot" && $2 == provision ]] || return 1; echo restored >"$STATE/boot-verdict"; } +reset_default_uuid() { printf '%s\n' "$DEFAULT_UUID"; } +reset_default_set() { [[ $1 == "$TOP" && $3 == "$TOP/@" && $(reset_uuid "$3") == "$2" ]] || return 1; DEFAULT_UUID=$2; } +reset_default_set_top() { [[ $1 == "$TOP" ]] || return 1; DEFAULT_UUID=-; } +reset_default_restore() { + [[ $1 == "$TOP" && $2 == "$RESET_TXN_DEFAULT" && $3 == "$RESET_TXN_DEFAULT_PATH" ]] || return 1 + if [[ $2 == - ]]; then DEFAULT_UUID=-; else [[ $(reset_uuid "$TOP/$3") == "$2" ]] || return 1; DEFAULT_UUID=$2; fi +} new_fixture() { TOP="$test_tmp/$1" STATE="$test_tmp/$1/.journal" mkdir -p "$TOP"; mkdir -m 700 "$STATE" RESET_TXN_FS=11111111-1111-1111-1111-111111111111 RESET_TXN_STAMP=123 RESET_TXN_ROOT=22222222-2222-2222-2222-222222222222 RESET_TXN_FACTORY=33333333-3333-3333-3333-333333333333 RESET_TXN_NEXT=44444444-4444-4444-4444-444444444444 RESET_TXN_CLEAN=55555555-5555-5555-5555-555555555555 + RESET_TXN_DEFAULT=$RESET_TXN_ROOT RESET_TXN_DEFAULT_PATH=@ DEFAULT_UUID=$RESET_TXN_ROOT fixture_volume "$TOP/@" "$RESET_TXN_ROOT" fixture_volume "$TOP/@factory" "$RESET_TXN_FACTORY" fixture_volume "$TOP/@omarchy-reset-next" "$RESET_TXN_NEXT" @@ -30,12 +38,27 @@ for boundary in 0 1 2 3 4; do if (( boundary >= 2 )); then mv "$TOP/@omarchy-reset-factory" "$TOP/@factory"; fi if (( boundary >= 3 )); then mv "$TOP/@" "$TOP/@omarchy-old-123"; fi if (( boundary >= 4 )); then mv "$TOP/@omarchy-reset-next" "$TOP/@"; fi + if (( boundary >= 4 )); then DEFAULT_UUID=$RESET_TXN_NEXT; fi reset_transaction_rollback "$TOP" "$STATE" [[ $(reset_uuid "$TOP/@") == "$RESET_TXN_ROOT" && $(reset_uuid "$TOP/@factory") == "$RESET_TXN_FACTORY" ]] || fail 'root+baseline rollback' [[ $(reset_uuid "$TOP/@omarchy-reset-next") == "$RESET_TXN_NEXT" && $(reset_uuid "$TOP/@omarchy-reset-factory") == "$RESET_TXN_CLEAN" ]] || fail 'known stage retained' [[ $(cat "$STATE/boot-verdict") == restored && $(cat "$STATE/phase") == rolled-back ]] || fail 'boot restored in same transaction' + [[ $DEFAULT_UUID == "$RESET_TXN_ROOT" ]] || fail 'default root restored in same transaction' pass "root/baseline/boot reconciliation after $boundary exchange renames" done +new_fixture retained-default +retained_uuid=77777777-7777-7777-7777-777777777777 +fixture_volume "$TOP/@old-retained" "$retained_uuid" +RESET_TXN_DEFAULT=$retained_uuid RESET_TXN_DEFAULT_PATH=@old-retained +reset_transaction_record "$STATE" +mv "$TOP/@factory" "$TOP/@omarchy-old-factory-123" +mv "$TOP/@omarchy-reset-factory" "$TOP/@factory" +mv "$TOP/@" "$TOP/@omarchy-old-123" +mv "$TOP/@omarchy-reset-next" "$TOP/@" +DEFAULT_UUID=$RESET_TXN_NEXT +reset_transaction_rollback "$TOP" "$STATE" +[[ $DEFAULT_UUID == "$retained_uuid" && $(reset_uuid "$TOP/@old-retained") == "$retained_uuid" ]] || fail 'retained default rollback binding' +pass 'rollback restores an explicitly recorded retained default subvolume' new_fixture collision mv "$TOP/@" "$TOP/@omarchy-old-123" fixture_volume "$TOP/@" 66666666-6666-6666-6666-666666666666 @@ -47,6 +70,8 @@ rm -r "${STATE:?}/boot" RESET_TXN_NEXT=- RESET_TXN_CLEAN=- reset_transaction_record "$STATE" expected="$RESET_TXN_FS $RESET_TXN_STAMP $RESET_TXN_ROOT $RESET_TXN_FACTORY - -" +expected="$expected $RESET_TXN_DEFAULT" +expected="$expected $RESET_TXN_DEFAULT_PATH" echo inventory >"$STATE/inventory" reset_cancel_journal "$STATE" "$expected" [[ ! -e $STATE ]] || fail 'cancel strands journal' From f6845e0caa5e7b6472183f3a8e07f402ac1aa81c Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 18:14:51 +0530 Subject: [PATCH 23/27] Install pacman-conf for ARM channel tests --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 308b695b708..d576b6d194c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -97,7 +97,7 @@ jobs: - name: Install test dependencies run: | sudo apt-get update - sudo apt-get install -y python3-yaml jq lua5.4 imagemagick libxkbcommon-tools ripgrep desktop-file-utils + sudo apt-get install -y python3-yaml jq lua5.4 imagemagick libxkbcommon-tools ripgrep desktop-file-utils pacman-package-manager sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua if ! command -v magick >/dev/null; then sudo ln -sf "$(command -v convert)" /usr/local/bin/magick From 1052c83d14e2a7b2188bba36025058ae26c02649 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 20:43:16 +0530 Subject: [PATCH 24/27] Fail closed on residual provisioning unlock keys --- bin/omarchy-provision-owner | 48 ++++++++++++++--- install/helpers/owner-rekey.sh | 3 ++ test/shell.d/owner-rekey-test.sh | 2 + .../provision-owner-rekey-guard-test.sh | 54 +++++++++++++++++++ version | 2 +- 5 files changed, 100 insertions(+), 9 deletions(-) create mode 100755 test/shell.d/provision-owner-rekey-guard-test.sh diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 163ed84354a..2c51f66a2f9 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -321,11 +321,34 @@ NOW=0 FINALIZE_BASE=-1 FINALIZE_TOTAL=$(grep -c '^run_logged' "$OMARCHY_PATH/install/user/all.sh" 2>/dev/null || echo 0) +# Any surviving auto-unlock artifact means retirement is still pending. Test +# symlinks explicitly: a dangling link must fail closed instead of making owner +# provisioning look complete. +owner_auto_unlock_pending() { + local provisioning_dir="${1:-$PROVISIONING_DIR}" system_root="${2:-/}" + local path + for path in \ + "$provisioning_dir/luks-key" \ + "$system_root/etc/omarchy/provisioning.key" \ + "$system_root/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf" \ + "$system_root/etc/default/grub.d/99-omarchy-provisioning-unlock.cfg" \ + "$system_root/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf"; do + [[ -e $path || -L $path ]] && return 0 + done + return 1 +} + +owner_rekey_pending() { + local provisioning_dir="${1:-$PROVISIONING_DIR}" system_root="${2:-/}" + owner_auto_unlock_pending "$provisioning_dir" "$system_root" || + [[ -e $provisioning_dir/owner-rekey || -L $provisioning_dir/owner-rekey ]] +} + # Whether this boot will re-key LUKS (encrypted installs stage a throwaway key). # The re-key rebuilds the UKI and is the slowest single step, so it needs a wide # band of its own; unencrypted installs skip it and let finalize take the room. REKEY_PENDING=false -[[ -e $PROVISIONING_DIR/luks-key || -L $PROVISIONING_DIR/luks-key || -e $PROVISIONING_DIR/owner-rekey || -L $PROVISIONING_DIR/owner-rekey ]] && REKEY_PENDING=true +owner_rekey_pending && REKEY_PENDING=true # Per-mille bands per phase: "lo hi tau". tau shapes the asymptotic time floor; # it is not a duration prediction. A wide band moves visibly; a narrow one looks @@ -888,19 +911,28 @@ finalize_user() { # the staged auto-unlock keyfile would leave the disk effectively unencrypted # forever. rekey_luks() { - local state="$PROVISIONING_DIR/owner-rekey" device + local state="$PROVISIONING_DIR/owner-rekey" staged_key="$PROVISIONING_DIR/luks-key" device # A valid in-progress receipt survives removal of the throwaway slot/key. - # Missing staged material must not bypass unfinished retirement. - if [[ ! -e $PROVISIONING_DIR/luks-key && ! -L $PROVISIONING_DIR/luks-key && ! -e $state && ! -L $state ]]; then - [[ ! -e /etc/omarchy/provisioning.key ]] || return 1 - return 0 + # If only the installed auto-unlock copy survived, it is still the staged + # credential and can complete retirement. Unsafe links fail reset_private_file + # inside owner_rekey_run. Config remnants without a credential or receipt are + # not enough to prove a safe boot, so leave provisioning pending. + if [[ ! -e $staged_key && ! -L $staged_key ]]; then + if [[ -e /etc/omarchy/provisioning.key || -L /etc/omarchy/provisioning.key ]]; then + staged_key=/etc/omarchy/provisioning.key + elif [[ ! -e $state && ! -L $state ]]; then + return 1 + fi fi source "$OMARCHY_PATH/install/helpers/owner-rekey.sh" device=$(luks_device) || return $? - if ! owner_rekey_run "$device" "$PROVISIONING_DIR/luks-key" <(printf '%s' "$password") "$state" >>"$LOG_FILE" 2>&1; then + if ! owner_rekey_run "$device" "$staged_key" <(printf '%s' "$password") "$state" >>"$LOG_FILE" 2>&1; then say --foreground 1 "Disk re-key remains pending. Retry with the same confirmed owner disk password; do not discard its receipt." return 1 fi + # A completed receipt proves the disk and published boot, but must not make a + # reintroduced key or drop-in invisible to the outer provisioning flow. + ! owner_auto_unlock_pending || return 1 } owner_rekey_limine_boot() { @@ -1025,7 +1057,7 @@ run_provisioning() { touch "$FINALIZE_WARNING_FLAG" fi - if [[ -e $PROVISIONING_DIR/luks-key || -L $PROVISIONING_DIR/luks-key || -e $PROVISIONING_DIR/owner-rekey || -L $PROVISIONING_DIR/owner-rekey ]]; then + if owner_rekey_pending; then log_step "re-keying LUKS to the user's password" echo rekey >"$STATE_FILE" rekey_luks || return $? diff --git a/install/helpers/owner-rekey.sh b/install/helpers/owner-rekey.sh index c50eaf91c75..6e2f85da2c7 100644 --- a/install/helpers/owner-rekey.sh +++ b/install/helpers/owner-rekey.sh @@ -106,6 +106,9 @@ owner_rekey_run() { digest=${digest%% *} reset_state_write "$state/receipt" "1 $uuid $owner_slot boot-published $digest" || return $? fi + # Cleanup is deliberately repeated for receipt retries. A stale or + # reintroduced key/drop-in must never survive a successful completion. + [[ $phase == owner-added ]] || owner_rekey_remove_auto_unlock || return $? owner_rekey_boot_check "$state" || return $? cryptsetup open --test-passphrase --key-slot "$owner_slot" --key-file <(printf '%s' "$owner_password") "$device" || return $? slots=$(owner_rekey_slots "$device") || return $? diff --git a/test/shell.d/owner-rekey-test.sh b/test/shell.d/owner-rekey-test.sh index fef5b4c7579..e10b1e17227 100644 --- a/test/shell.d/owner-rekey-test.sh +++ b/test/shell.d/owner-rekey-test.sh @@ -62,7 +62,9 @@ owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE" [[ $(owner_rekey_slots /fixture-luks) == 2 && ! -e $STAGED ]] || fail 'owner-only final slots' [[ $(grep -c '^boot$' "$EVENTS") == 1 && $(grep -c '^add$' "$EVENTS") == 1 ]] || fail 'retry does not regenerate or add needless key' pass 'partial retirement after throwaway removal resumes through owner credential' +removals_before=$(grep -c '^remove$' "$EVENTS") owner_rekey_run /fixture-luks "$STAGED" "$OWNER" "$STATE" +[[ $(grep -c '^remove$' "$EVENTS") == $((removals_before + 1)) ]] || fail 'completed retry repeats auto-unlock cleanup' pass 'completed receipt is idempotent with absent staged key' new_fixture missing FAIL_KILL=1 diff --git a/test/shell.d/provision-owner-rekey-guard-test.sh b/test/shell.d/provision-owner-rekey-guard-test.sh new file mode 100755 index 00000000000..ebab6021cb7 --- /dev/null +++ b/test/shell.d/provision-owner-rekey-guard-test.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +provision_owner="$ROOT/bin/omarchy-provision-owner" +eval "$(sed -n '/^owner_auto_unlock_pending() {/,/^}/p; /^owner_rekey_pending() {/,/^}/p' "$provision_owner")" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +provisioning_dir="$test_tmp/var/lib/omarchy/provisioning" +system_root="$test_tmp/root" +mkdir -p "$provisioning_dir" "$system_root/etc" + +if owner_rekey_pending "$provisioning_dir" "$system_root"; then + fail 'clean provisioning state is not re-key pending' +fi + +artifacts=( + "$provisioning_dir/luks-key" + "$provisioning_dir/owner-rekey" + "$system_root/etc/omarchy/provisioning.key" + "$system_root/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf" + "$system_root/etc/default/grub.d/99-omarchy-provisioning-unlock.cfg" + "$system_root/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf" +) + +for artifact in "${artifacts[@]}"; do + mkdir -p "$(dirname "$artifact")" + : >"$artifact" + owner_rekey_pending "$provisioning_dir" "$system_root" || + fail "auto-unlock artifact remains pending: $artifact" + rm -f "$artifact" + + ln -s /missing-auto-unlock-target "$artifact" + owner_rekey_pending "$provisioning_dir" "$system_root" || + fail "dangling auto-unlock link remains pending: $artifact" + rm -f "$artifact" +done + +mkdir -p "$provisioning_dir/owner-rekey" +owner_rekey_pending "$provisioning_dir" "$system_root" || + fail 'an owner re-key receipt remains pending' +owner_auto_unlock_pending "$provisioning_dir" "$system_root" && + fail 'a receipt alone is not an auto-unlock artifact' +rmdir "$provisioning_dir/owner-rekey" + +grep -q 'staged_key=/etc/omarchy/provisioning.key' "$provision_owner" || + fail 'installed provisioning key can resume owner re-keying' +grep -q '^ if owner_rekey_pending; then$' "$provision_owner" || + fail 'owner provisioning gates completion on every auto-unlock artifact' +grep -q '^ ! owner_auto_unlock_pending || return 1$' "$provision_owner" || + fail 'owner provisioning verifies cleanup after a completed receipt' + +pass 'owner provisioning detects files and dangling links for every auto-unlock artifact' diff --git a/version b/version index 283f4d023be..6472dd8554b 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.0.3rc2 +4.0.3rc3 From fea4693e8d34b78e7d735e390f7e751c41af94b2 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 22:09:35 +0530 Subject: [PATCH 25/27] Bootstrap fork-owned package signing trust --- build-inputs/README.md | 2 +- build-inputs/omarchy-mac-keyring/PKGBUILD | 19 ++++ .../omarchy-mac-keyring.install | 11 ++ build-inputs/prepare-recipes.sh | 5 + build-packages.sh | 15 ++- default/pacman/keyrings/omarchy-mac-revoked | 0 default/pacman/keyrings/omarchy-mac-trusted | 1 + default/pacman/keyrings/omarchy-mac.gpg | 16 +++ docs/arm-package-sources.md | 6 +- install.sh | 16 +++ install/helpers/arm-channel.sh | 106 +++++++++++++++++- migrations/1789316115.sh | 16 +++ test/shell.d/arm-channel-test.sh | 10 ++ test/shell.d/helpers/install-orchestration.sh | 3 +- .../install-mac-snapper-dependency-test.sh | 2 +- .../omarchy-mac-keyring-migration-test.sh | 69 ++++++++++++ test/shell.d/package-build-contract-test.sh | 26 +++++ version | 2 +- 18 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 build-inputs/omarchy-mac-keyring/PKGBUILD create mode 100644 build-inputs/omarchy-mac-keyring/omarchy-mac-keyring.install create mode 100644 default/pacman/keyrings/omarchy-mac-revoked create mode 100644 default/pacman/keyrings/omarchy-mac-trusted create mode 100644 default/pacman/keyrings/omarchy-mac.gpg create mode 100644 migrations/1789316115.sh create mode 100644 test/shell.d/omarchy-mac-keyring-migration-test.sh diff --git a/build-inputs/README.md b/build-inputs/README.md index 398dd5b543b..37302eb05f9 100644 --- a/build-inputs/README.md +++ b/build-inputs/README.md @@ -2,7 +2,7 @@ `omarchy-pkgs-revision` pins the merged upstream recipe commit for this desktop source. `prepare-recipes.sh` copies that checkout and applies `omarchy-first-run-packages.patch` before any build; the native installer, desktop CI, and ARM publisher all use this contract. The patch carries the publisher's existing Snapper and keyboard-unit fixes plus the same conditional plocate-unit support for development recipes that stable recipes already have. Updating the pin requires reviewing and testing the overlay against the new commit. -`build-packages.sh` uses this checkout's `version` for both desktop packages, including the exact `omarchy-settings` dependency. Use attached prerelease versions (`4.0.3rc1`), which pacman sorts below the final `4.0.3`. A source version change resets the recipe release to 1; `OMARCHY_PKGREL` can explicitly select a positive release number. Downloaded font and keyring sources retain makepkg checksum verification. The output directory includes `build-inputs.txt` with the recipe revision, recipe file hashes, source revision, dirty-file count, and effective PKGBUILD hashes. +`build-packages.sh` uses this checkout's `version` for both desktop packages, including the exact `omarchy-settings` dependency. Use attached prerelease versions (`4.0.3rc1`), which pacman sorts below the final `4.0.3`. A source version change resets the recipe release to 1; `OMARCHY_PKGREL` can explicitly select a positive release number. Downloaded font and keyring sources retain makepkg checksum verification. The fork-owned `omarchy-mac-keyring` recipe and its exact public bytes are added after exporting the pinned upstream recipe tree; `omarchy` depends on that keyring without replacing upstream `omarchy-keyring`. The output directory includes `build-inputs.txt` with the recipe revision, recipe file hashes, source revision, dirty-file count, and effective PKGBUILD hashes. `OMARCHY_PKGS_PATH` accepts a repository checkout or its `pkgbuilds` directory. Release builds require the pinned revision and a clean recipe tree. For intentional development with a different or modified recipe tree, also set `OMARCHY_ALLOW_CUSTOM_RECIPES=1`; this emits a warning and records the custom input hashes. Custom recipes still must match the reviewed ARM overlay. Their output is not evidence for the pinned release build. diff --git a/build-inputs/omarchy-mac-keyring/PKGBUILD b/build-inputs/omarchy-mac-keyring/PKGBUILD new file mode 100644 index 00000000000..31e730e6f5c --- /dev/null +++ b/build-inputs/omarchy-mac-keyring/PKGBUILD @@ -0,0 +1,19 @@ +# Maintainer: Omarchy Mac + +pkgname=omarchy-mac-keyring +pkgver=20260913 +pkgrel=1 +pkgdesc='Omarchy Mac package signing keyring' +arch=(any) +url='https://github.com/omarchy-mac/omarchy-mac' +license=('GPL-3.0-or-later') +install=$pkgname.install +source=('omarchy-mac.gpg' 'omarchy-mac-trusted' 'omarchy-mac-revoked') +sha512sums=('0e411148bf58cb1cf3c1ef544ea0936d82b0d7b3996c3ef3d20ac909959129c1d7cb58dc84ac466cd3e2d75db228aff896640899b4bbbef19e7636f3033a7ea8' + '2fb4497f96fd9f446e0177f9dbf8eb34f0ad3dce5fc7fb687312c5e20a1127ceb68f0d6022424f35b37e2a6a91fb097b2212595375fa4d37d7547697b17de86e' + 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e') + +package() { + install -D -m0644 -t "$pkgdir/usr/share/pacman/keyrings/" \ + omarchy-mac.gpg omarchy-mac-trusted omarchy-mac-revoked +} diff --git a/build-inputs/omarchy-mac-keyring/omarchy-mac-keyring.install b/build-inputs/omarchy-mac-keyring/omarchy-mac-keyring.install new file mode 100644 index 00000000000..40a474972ff --- /dev/null +++ b/build-inputs/omarchy-mac-keyring/omarchy-mac-keyring.install @@ -0,0 +1,11 @@ +post_upgrade() { + if [ -x usr/bin/pacman-key ] && usr/bin/pacman-key -l >/dev/null 2>&1; then + usr/bin/pacman-key --populate omarchy-mac + else + echo ' >>> Initialize pacman-key, then run pacman-key --populate omarchy-mac.' + fi +} + +post_install() { + post_upgrade +} diff --git a/build-inputs/prepare-recipes.sh b/build-inputs/prepare-recipes.sh index ce739bc9f24..15aec910b36 100755 --- a/build-inputs/prepare-recipes.sh +++ b/build-inputs/prepare-recipes.sh @@ -38,6 +38,11 @@ prepare_omarchy_recipes() { echo 'Package recipes no longer match the ARM first-run overlay; review them before building.' >&2 return 1 fi + cp -a "$inputs_dir/omarchy-mac-keyring" "$destination/pkgbuilds/" || return 1 + cp -a "$inputs_dir/../default/pacman/keyrings/omarchy-mac.gpg" \ + "$inputs_dir/../default/pacman/keyrings/omarchy-mac-trusted" \ + "$inputs_dir/../default/pacman/keyrings/omarchy-mac-revoked" \ + "$destination/pkgbuilds/omarchy-mac-keyring/" || return 1 printf '%s\n' "recipe_commit=$actual" "recipe_pin=$revision" "custom_recipes=${OMARCHY_ALLOW_CUSTOM_RECIPES:-0}" >"$destination/provenance" (cd "$destination" && find pkgbuilds -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) >>"$destination/provenance" } diff --git a/build-packages.sh b/build-packages.sh index 279963be4e5..c3116053529 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -2,7 +2,7 @@ # Build the Omarchy packages for Apple Silicon from this checkout. # -# omarchy, omarchy-settings, omarchy-keyring, and ttf-jetbrains-mono-nerd-basic +# omarchy, omarchy-settings, both keyrings, and ttf-jetbrains-mono-nerd-basic # include architecture-specific settings and dependencies. Build on aarch64 # using the pinned recipes and shared ARM overlay below. # @@ -29,6 +29,7 @@ readonly limine_dependencies=( readonly packages=( omarchy-keyring + omarchy-mac-keyring ttf-jetbrains-mono-nerd-basic omarchy-settings omarchy @@ -120,6 +121,17 @@ ensure_snapper_dependency() { fi } +ensure_omarchy_mac_keyring_dependency() { + local pkgbuild="$1" + + grep -qx 'depends=(' "$pkgbuild" || + fail "omarchy PKGBUILD no longer has the expected depends array: $pkgbuild" + if ! sed -n '/^depends=(/,/^)/p' "$pkgbuild" | + grep -qE "^[[:space:]]*['\"]omarchy-mac-keyring([<>=][^'\"]*)?['\"]([[:space:]]|$)"; then + sed -i "/^depends=(/a\\ 'omarchy-mac-keyring'" "$pkgbuild" + fi +} + # Upstream's package() deletes /etc/mkinitcpio.conf.d wholesale on aarch64, # reasoning that omarchy_hooks.conf is the x86 file that would inject the # Limine hooks into an Asahi initramfs. That is true of upstream's copy and @@ -221,6 +233,7 @@ build_package() { if [[ $package == "omarchy" ]]; then strip_limine_dependencies "$build_dir/$package/PKGBUILD" ensure_snapper_dependency "$build_dir/$package/PKGBUILD" + ensure_omarchy_mac_keyring_dependency "$build_dir/$package/PKGBUILD" fi if [[ $package == "omarchy-settings" ]]; then keep_apple_silicon_mkinitcpio_drop_ins "$build_dir/$package/PKGBUILD" diff --git a/default/pacman/keyrings/omarchy-mac-revoked b/default/pacman/keyrings/omarchy-mac-revoked new file mode 100644 index 00000000000..e69de29bb2d diff --git a/default/pacman/keyrings/omarchy-mac-trusted b/default/pacman/keyrings/omarchy-mac-trusted new file mode 100644 index 00000000000..557aa18eee6 --- /dev/null +++ b/default/pacman/keyrings/omarchy-mac-trusted @@ -0,0 +1 @@ +F3C5AE3FCFFC738C301E30A8F0C548C0D27279F7:4: diff --git a/default/pacman/keyrings/omarchy-mac.gpg b/default/pacman/keyrings/omarchy-mac.gpg new file mode 100644 index 00000000000..24fbc026b91 --- /dev/null +++ b/default/pacman/keyrings/omarchy-mac.gpg @@ -0,0 +1,16 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEaqbKtRYJKwYBBAHaRw8BAQdAI7gUCEOd14QplbZ12KYIV7XkM+/wyPPG9QBz +mPvV5bW0G09tYXJjaHkgTWFjIFBhY2thZ2UgU2lnbmluZ4iWBBMWCgA+FiEE88Wu +P8/8c4wwHjCo8MVIwNJyefcFAmqmyrUCGwEFCQlmAYAFCwkIBwIGFQoJCAsCBBYC +AwECHgECF4AACgkQ8MVIwNJyefc4HgEArUgsUks8G6WRTBV5Ef52/djRyIGu7Uai +e5Ih8U3XB9gA/RjtlKVqC3E00ZhN5m4Oc2IbfPNZr8Oh7ZlYdvFq148BuDMEaqbK +3xYJKwYBBAHaRw8BAQdAWA4H8/FHdnZP1Z9NeFkFNmMNIJyDT66oSHcGCxBjLIOI +9AQYFgoAJhYhBPPFrj/P/HOMMB4wqPDFSMDScnn3BQJqpsrfAhsCBQkB4TOAAIAJ +EPDFSMDScnn3dSAEGRYKAB0WIQRsJZfG5p/EiY09Vg4zEwdpYDAoXgUCaqbK3wAK +CRAzEwdpYDAoXvBSAPMGvUcm9mi64yMqX3nkMsi1H7HuvdXmBaRgyllBrqYRAQDA +xFiDGYOtJqPZwpcIupeEn0Z62ScAwjBS5ONqXNfdAjvBAP41jIblDA42gFKa/G8b +xL9fy7HGsg/zpEFBnGsA/EB03wEA02k2hYO0FuV7qo3pmqVqR0MIVygZLEA2sGWB +Jyvh8wU= +=iEoD +-----END PGP PUBLIC KEY BLOCK----- diff --git a/docs/arm-package-sources.md b/docs/arm-package-sources.md index a51bc65bbda..7a5fc381e7b 100644 --- a/docs/arm-package-sources.md +++ b/docs/arm-package-sources.md @@ -12,6 +12,8 @@ Use `omarchy update` for system upgrades. A bare `pacman -Syu` does not update t The fork-owned repository uses distinct `stable`, `rc`, and `edge` release coordinates under `https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/`. All three lanes provide `omarchy` and `omarchy-settings`; ARM does not request the x86 `omarchy-dev` pair. Channel reporting reads the managed ARM server, so an older installation pointing at `/edge` reports edge even when its installed package names are `omarchy` and `omarchy-settings`. +Omarchy Mac has a separate package-signing primary key, `F3C5AE3FCFFC738C301E30A8F0C548C0D27279F7`. Its exact public bytes ship in `omarchy-mac-keyring`; upstream `omarchy-keyring` remains installed for upstream packages. The 4.0.3rc4 bootstrap is the final unsigned fork transaction and retains the old repository policy only long enough to deliver and populate this trust. The following RC is signed by subkey `6C2597C6E69FC4898D3D560E331307696030285E` and changes the fork policy to `PackageRequired DatabaseRequired TrustedOnly`. Against that signed lane, a client that skipped the bootstrap cannot verify the candidate and stops before changing packages. + An explicit channel switch goes through the normal update lock, snapshot and migration pipeline. It stages the current pacman configuration, changing only the managed ARM lane and reapplying the existing explicit upstream graphics policy. Other repository ordering, options and mirror Includes are preserved. Custom or ambiguous ARM server/Include layouts are rejected rather than guessed. ARM refresh uses this same path and no longer runs the reset-only `pre-refresh-pacman` hook, because it does not discard and recreate the user's configuration. The x86 reset path retains that hook; normal update hooks still run after successful migrations. Before changing installed packages, the switch syncs isolated databases, verifies a matching desktop package pair, resolves the full transaction and downloads its archives under the configured signature policy. Verification uses a private copy of public keyring trust. Required trust that is absent fails preparation; any undeclared key imported during verification rejects the transaction without changing the live keyring. It checks the resolved archive hashes and captures the repository databases and archives as local repositories. A second resolution against the real installed database must match the preflight manifest. One ordinary libalpm system-upgrade transaction then uses those captured repositories, preserving dependency reasons, replacement handling and conflict checks. Explicit version-constrained desktop targets allow RC-to-stable downgrades without enabling distribution-wide downgrades. Equal/older lane database timestamps are handled with a forced sync of the captured database. @@ -22,9 +24,9 @@ This freezes one switch transaction, not future distribution upgrades. Arch Linu ## Fresh Apple Silicon installation -`./install.sh --channel rc` (or `OMARCHY_MIRROR=rc ./install.sh`) installs the published lane's captured `omarchy`/`omarchy-settings` pair. It verifies availability, resolves dependencies and downloads under the configured signature policy before changing locale, packages or active repository configuration. If the base has no managed ARM section, preflight adds one only to its candidate; custom or hidden managed sections must be configured explicitly. The new managed lane uses the existing Mac repository's `Optional TrustAll` policy; unsigned release metadata is not authenticated by this check. Required upstream graphics signatures remain required. +`./install.sh --channel rc` (or `OMARCHY_MIRROR=rc ./install.sh`) installs the published lane's captured `omarchy`/`omarchy-settings` pair. It verifies availability, resolves dependencies and downloads under the configured signature policy before changing locale, packages or active repository configuration. If the base has no managed ARM section, preflight adds one only to its candidate; custom or hidden managed sections must be configured explicitly. The rc4 bootstrap retains the existing `Optional TrustAll` fork policy because its archive and database are intentionally unsigned. This is the final use of that trust model. The following signed RC requires trusted package and database signatures. Required upstream graphics signatures remain required throughout. -Fresh preflight can initialize an ephemeral local signing key in its private keyring and fetch and trust only the declared upstream stack fingerprint `40DFB630FF42BCFFB047046CF0134EE680CAC571`. Host secret keys are never copied. The private keyring and its agent are removed on exit. Existing distribution/Asahi trust must already be provisioned by the base system. Accepted installation can then establish the declared stack signer in the live keyring for subsequent package setup. +Fresh preflight can initialize an ephemeral local signing key in its private keyring and fetch and trust only the declared upstream stack fingerprint `40DFB630FF42BCFFB047046CF0134EE680CAC571`. It imports the fork key only from the exact public bytes pinned in this source checkout and verifies the full primary fingerprint; it never retrieves that key from a keyserver. Host secret keys are never copied. The private keyring and its agent are removed on exit. Existing distribution/Asahi trust must already be provisioned by the base system. Accepted installation installs and populates the durable fork keyring for subsequent package setup. The captured core/system transaction is followed by the ordinary rolling default-package and optional AUR setup. A temporary `IgnorePkg` entry protects the published desktop pair during those later operations, and both package versions are checked after defaults and after system/user setup. Setup preserves the preflighted repository configuration; cleanup removes only the installer's temporary pin, before recording a factory snapshot on success. This does not freeze optional/default dependencies, so their resolved versions, default exclusions and installation failures remain part of RC qualification. diff --git a/install.sh b/install.sh index 7414cccafb1..d8de1f8aaeb 100755 --- a/install.sh +++ b/install.sh @@ -11,6 +11,7 @@ set -euo pipefail readonly checkout="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly package_output="$checkout/build-output" readonly asahi_alarm_key="12CE6799A94A3F1B5DDFFE88F576553597FB8FEB" +readonly omarchy_mac_key="F3C5AE3FCFFC738C301E30A8F0C548C0D27279F7" source "$checkout/install/helpers/arm-package-sources.sh" source "$checkout/install/helpers/arm-channel.sh" install_channel="${OMARCHY_MIRROR:-}" @@ -152,6 +153,19 @@ ensure_asahi_alarm_keyring() { sudo pacman -Sy --needed --noconfirm asahi-alarm-keyring } +ensure_omarchy_mac_keyring() { + local keyfile="$checkout/default/pacman/keyrings/omarchy-mac.gpg" + [[ -f $keyfile && ! -L $keyfile ]] || fail "Pinned Omarchy Mac signing key is missing or unsafe." + + if ! sudo pacman-key --list-keys "$omarchy_mac_key" >/dev/null 2>&1; then + log "Importing the pinned Omarchy Mac package signing key" + sudo pacman-key --add "$keyfile" + fi + sudo pacman-key --finger "$omarchy_mac_key" | tr -d '[:space:]' | grep -qF "$omarchy_mac_key" || + fail "The imported Omarchy Mac signing key has the wrong fingerprint." + sudo pacman-key --lsign-key "$omarchy_mac_key" >/dev/null +} + # Compared in bash rather than with grep against a process substitution, which # ugrep answers differently from GNU grep. # The shipped pacman.conf only lands during post-install, after the package set @@ -169,6 +183,7 @@ ensure_arm_package_repo() { fi ensure_asahi_alarm_keyring + ensure_omarchy_mac_keyring omarchy_arm_prepare_package_sources local -a targets mapfile -t targets < <(omarchy_arm_package_upgrade_args) @@ -367,6 +382,7 @@ main() { if [[ -n $install_channel ]]; then channel_stage=$(omarchy_arm_channel_stage_new) trap cleanup_channel_install EXIT + export OMARCHY_SIGNING_SOURCE="$checkout" # Availability, resolution and signature checks precede locale or system # changes. Apply exactly the captured published pair and dependencies. omarchy_arm_channel_prepare "$channel_stage" "$install_channel" fresh diff --git a/install/helpers/arm-channel.sh b/install/helpers/arm-channel.sh index 2ccddc6f768..93e48e787da 100644 --- a/install/helpers/arm-channel.sh +++ b/install/helpers/arm-channel.sh @@ -42,6 +42,93 @@ omarchy_arm_channel_render() { ' "$config" >"$output" } +omarchy_arm_signature_policy_render() { + local config="$1" policy="$2" output="$3" + [[ $policy == 'PackageRequired DatabaseOptional TrustedOnly' || + $policy == 'PackageRequired DatabaseRequired TrustedOnly' ]] || { + echo "Invalid ARM signature policy: $policy" >&2 + return 1 + } + omarchy_arm_channel_current "$config" >/dev/null || { + echo "Cannot change signature policy for a custom or ambiguous ARM repository." >&2 + return 1 + } + awk -v policy="$policy" ' + /^[[:space:]]*\[/ { + if (selected && !wrote) print "SigLevel = " policy + selected = ($0 ~ /^[[:space:]]*\[omarchy-aarch64\][[:space:]]*(#.*)?$/) + wrote = 0 + print + next + } + selected && /^[[:space:]]*SigLevel[[:space:]]*=/ { + if (!wrote) print "SigLevel = " policy + wrote = 1 + next + } + { print } + END { if (selected && !wrote) print "SigLevel = " policy } + ' "$config" >"$output" +} + +omarchy_arm_signature_policy_assert() { + local config="$1" expected="$2" actual + actual=$(pacman-conf --config "$config" --repo omarchy-aarch64 SigLevel | LC_ALL=C sort) || return 1 + case "$expected" in + 'PackageRequired DatabaseOptional TrustedOnly') + [[ $actual == $'DatabaseOptional\nDatabaseTrustedOnly\nPackageRequired\nPackageTrustedOnly' ]] + ;; + 'PackageRequired DatabaseRequired TrustedOnly') + [[ $actual == $'DatabaseRequired\nDatabaseTrustedOnly\nPackageRequired\nPackageTrustedOnly' ]] + ;; + *) return 1 ;; + esac +} + +omarchy_arm_signature_policy_apply() { + local config="$1" policy="$2" rendered + [[ -f $config && ! -L $config ]] || { + echo "Pacman configuration must be a regular non-symlink: $config" >&2 + return 1 + } + rendered=$(mktemp) + if ! omarchy_arm_signature_policy_render "$config" "$policy" "$rendered" || + ! omarchy_arm_signature_policy_assert "$rendered" "$policy"; then + rm -f -- "$rendered" + return 1 + fi + if ! sudo bash -euo pipefail -c ' + config="$1" rendered="$2" stage="" + cleanup() { [[ -z $stage ]] || rm -f -- "$stage"; } + trap cleanup EXIT + [[ -f $config && ! -L $config ]] || exit 1 + stage=$(mktemp "${config}.omarchy-signature.XXXXXXXX") + cat "$rendered" >"$stage" + mapfile -t actual < <(pacman-conf --config "$stage" --repo omarchy-aarch64 SigLevel | LC_ALL=C sort) + [[ ${#actual[@]} == 4 ]] + case "$3" in + "PackageRequired DatabaseOptional TrustedOnly") + [[ ${actual[0]} == DatabaseOptional && ${actual[1]} == DatabaseTrustedOnly && + ${actual[2]} == PackageRequired && ${actual[3]} == PackageTrustedOnly ]] + ;; + "PackageRequired DatabaseRequired TrustedOnly") + [[ ${actual[0]} == DatabaseRequired && ${actual[1]} == DatabaseTrustedOnly && + ${actual[2]} == PackageRequired && ${actual[3]} == PackageTrustedOnly ]] + ;; + *) exit 1 ;; + esac + chmod --reference="$config" "$stage" + chown --reference="$config" "$stage" + mv -fT -- "$stage" "$config" + stage="" + ' bash "$config" "$rendered" "$policy"; then + rm -f -- "$rendered" + return 1 + fi + rm -f -- "$rendered" + omarchy_arm_signature_policy_assert "$config" "$policy" +} + # DownloadUser must traverse the whole path for downloads and frozen file:// # repositories. A private HOME/cache cannot provide that contract. Allocate only # our new child under verified root-controlled persistent parents; never loosen @@ -150,15 +237,32 @@ omarchy_arm_channel_prepare() { sudo cp -p "$gpgdir/$keyfile" "$stage/keyring/$keyfile" fi done + # The copied ring deliberately excludes host secret keys. Give this private + # ring its own disposable local-signing key before trusting pinned signers. + sudo pacman-key --gpgdir "$stage/keyring" --init # Fresh bases may lack the declared upstream stack signer. Bootstrap only # that exact fingerprint into private trust, using an ephemeral local signer. local key="40DFB630FF42BCFFB047046CF0134EE680CAC571" if [[ $allow_new == "fresh" ]] && ! sudo gpg --homedir "$stage/keyring" --batch --list-keys "$key" >/dev/null 2>&1; then - sudo pacman-key --gpgdir "$stage/keyring" --init sudo pacman-key --gpgdir "$stage/keyring" --recv-keys "$key" --keyserver hkps://keys.openpgp.org omarchy_arm_channel_key_fingerprints "$stage/keyring" | grep -qxF "$key" || return 1 + fi + if [[ $allow_new == "fresh" ]]; then sudo pacman-key --gpgdir "$stage/keyring" --lsign-key "$key" fi + # The fork key is source-pinned. Import exactly those committed bytes rather + # than consulting a keyserver, then verify the full primary fingerprint. + local fork_key="F3C5AE3FCFFC738C301E30A8F0C548C0D27279F7" + local fork_keyfile="${OMARCHY_SIGNING_SOURCE:-$OMARCHY_PATH}/default/pacman/keyrings/omarchy-mac.gpg" + if ! sudo gpg --homedir "$stage/keyring" --batch --list-keys "$fork_key" >/dev/null 2>&1; then + [[ -f $fork_keyfile && ! -L $fork_keyfile ]] || { + echo "Pinned Omarchy Mac signing key is missing or unsafe: $fork_keyfile" >&2 + return 1 + } + sudo pacman-key --gpgdir "$stage/keyring" --add "$fork_keyfile" + omarchy_arm_channel_key_fingerprints "$stage/keyring" | grep -qxF "$fork_key" || return 1 + fi + sudo pacman-key --gpgdir "$stage/keyring" --lsign-key "$fork_key" omarchy_arm_channel_key_fingerprints "$stage/keyring" >"$stage/keys-before" local -a probe=(--config "$stage/resolved.conf" --dbpath "$stage/db" --cachedir "$stage/cache" --gpgdir "$stage/keyring" --logfile "$stage/preflight.log") sudo env OMARCHY_UPDATE_PACMAN=1 pacman "${probe[@]}" -Sy --noconfirm diff --git a/migrations/1789316115.sh b/migrations/1789316115.sh new file mode 100644 index 00000000000..b81f5978f2f --- /dev/null +++ b/migrations/1789316115.sh @@ -0,0 +1,16 @@ +echo "Trust packages signed by Omarchy Mac" + +readonly omarchy_mac_signing_key='F3C5AE3FCFFC738C301E30A8F0C548C0D27279F7' + +# The package is a dependency of omarchy, but keep this self-repairing for a +# partial/manual upgrade. Do not weaken the repository policy to fetch it. +if omarchy-pkg-missing omarchy-mac-keyring; then + omarchy-pkg-add omarchy-mac-keyring +fi + +sudo pacman-key --populate omarchy-mac +sudo pacman-key --finger "$omarchy_mac_signing_key" | tr -d '[:space:]' | grep -qF "$omarchy_mac_signing_key" + +# Policy remains unchanged for this one disclosed bootstrap transaction. The +# next, signed RC carries a successor migration that requires both package and +# database signatures after this key is already durable. diff --git a/test/shell.d/arm-channel-test.sh b/test/shell.d/arm-channel-test.sh index 3e641dfbad4..c7917d4b7e2 100644 --- a/test/shell.d/arm-channel-test.sh +++ b/test/shell.d/arm-channel-test.sh @@ -52,6 +52,8 @@ printf '%s\n' '[options]' 'Architecture = aarch64' '[extra]' 'Server = https://r cp "$test_tmp/fresh" "$test_tmp/fresh-before" omarchy_arm_channel_render "$test_tmp/fresh" rc "$test_tmp/fresh-rendered" fresh [[ $(omarchy_arm_channel_current "$test_tmp/fresh-rendered") == rc ]] || fail 'fresh candidate adds explicit RC lane' +grep -qxF 'SigLevel = Optional TrustAll' "$test_tmp/fresh-rendered" || + fail 'fresh bootstrap retains the disclosed legacy policy until trust is delivered' cmp "$test_tmp/fresh" "$test_tmp/fresh-before" || fail 'fresh render preserves active configuration' printf '%s\n' '[omarchy-aarch64]' 'Server = https://custom.example/repo' >"$test_tmp/hidden" printf 'Include = %s\n' "$test_tmp/hidden" >>"$test_tmp/fresh" @@ -60,6 +62,14 @@ if omarchy_arm_channel_render "$test_tmp/fresh" rc "$test_tmp/rejected" fresh >/ fi pass 'fresh candidates add a missing lane but reject hidden custom repositories' +for template in "$ROOT/default/pacman/pacman.conf" "$ROOT/default/pacman/pacman-stable.conf" \ + "$ROOT/default/pacman/pacman-rc.conf" "$ROOT/default/pacman/pacman-edge.conf"; do + sed -n '/^\[omarchy-aarch64\]/,/^\[/p' "$template" | + grep -qxF 'SigLevel = Optional TrustAll' || + fail "bootstrap transition policy missing from $template" +done +pass 'all shipped ARM repository templates remain compatible with the one-time bootstrap' + printf '#!/bin/bash\necho aarch64\n' >"$test_tmp/bin/uname" cat >"$test_tmp/bin/omarchy-update" <<'SH' #!/bin/bash diff --git a/test/shell.d/helpers/install-orchestration.sh b/test/shell.d/helpers/install-orchestration.sh index e2c994076b6..923f8a00c7d 100644 --- a/test/shell.d/helpers/install-orchestration.sh +++ b/test/shell.d/helpers/install-orchestration.sh @@ -12,6 +12,7 @@ PY cat >"$work/driver" <<'DRIVER' set -euo pipefail source "$FUNCTIONS" +checkout="$TEST_ROOT" install_channel="${CHANNEL:-}" channel_stage="" log() { :; } @@ -40,7 +41,7 @@ snapshot_factory_baseline() { step snapshot; } pacman() { echo "$2 ${PAIR_VERSION:-4.0.3rc1-1}"; } main "$@" DRIVER -export FUNCTIONS="$work/functions" STAGE="$work/stage" CALLS="$work/calls" +export FUNCTIONS="$work/functions" STAGE="$work/stage" CALLS="$work/calls" TEST_ROOT="$ROOT" mkdir "$STAGE" run_case() { : >"$CALLS" diff --git a/test/shell.d/install-mac-snapper-dependency-test.sh b/test/shell.d/install-mac-snapper-dependency-test.sh index 3cf701675cb..ecd2a3ea757 100644 --- a/test/shell.d/install-mac-snapper-dependency-test.sh +++ b/test/shell.d/install-mac-snapper-dependency-test.sh @@ -43,7 +43,7 @@ PKGBUILD expected_dependency=$dependency [[ $dependency != "absent" ]] || expected_dependency=snapper - expected=$(printf '%s\n' gum "$expected_dependency" | sort) + expected=$(printf '%s\n' gum omarchy-mac-keyring "$expected_dependency" | sort) actual=$(sort "$case_dir/output/omarchy.pkg.tar.zst") [[ $actual == "$expected" ]] || fail "Mac build requires Snapper exactly once and preserves other dependencies ($dependency)" "$actual" diff --git a/test/shell.d/omarchy-mac-keyring-migration-test.sh b/test/shell.d/omarchy-mac-keyring-migration-test.sh new file mode 100644 index 00000000000..f5b50472149 --- /dev/null +++ b/test/shell.d/omarchy-mac-keyring-migration-test.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname -- "${BASH_SOURCE[0]}")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" +config="$test_tmp/pacman.conf" +calls="$test_tmp/calls" +cat >"$config" <<'CONF' +[options] +SigLevel = Required DatabaseOptional +[custom] +SigLevel = Optional TrustAll +Server = https://custom.example + [omarchy-aarch64] + SigLevel = Optional TrustAll +Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/rc +[later] +SigLevel = Never +Server = file:///later +CONF + +cat >"$test_tmp/bin/sudo" <<'SH' +#!/bin/bash +if [[ $1 == pacman-key && $2 == --populate ]]; then + echo "populate $3" >>"$TEST_CALLS" + exit 0 +fi +if [[ $1 == pacman-key && $2 == --finger ]]; then + printf 'Key fingerprint = F3C5 AE3F CFFC 738C 301E 30A8 F0C5 48C0 D272 79F7\n' + exit 0 +fi +exec "$@" +SH +chmod +x "$test_tmp/bin/sudo" + +omarchy-pkg-missing() { return 1; } +omarchy-pkg-add() { echo "add $*" >>"$calls"; } +export TEST_CALLS="$calls" +export PATH="$test_tmp/bin:$PATH" +export OMARCHY_PATH="$ROOT" + +# Redirect only the fixture's machine config reference. +sed "s|/etc/pacman.conf|$config|g" "$ROOT/migrations/1789316115.sh" >"$test_tmp/migration.sh" +(source "$test_tmp/migration.sh" >/dev/null) +(source "$test_tmp/migration.sh" >/dev/null) + +[[ $(grep -c '^populate omarchy-mac$' "$calls") == 2 ]] || fail 'migration repopulates trust idempotently' +grep -qxF ' SigLevel = Optional TrustAll' "$config" || + fail 'bootstrap migration changed policy before the signed repository exists' +sed -n '/^\[custom\]/,/^\[/p' "$config" | grep -qxF 'SigLevel = Optional TrustAll' || + fail 'migration changed another repository policy' +sed -n '/^\[later\]/,$p' "$config" | grep -qxF 'SigLevel = Never' || + fail 'migration changed a later repository policy' +pass 'existing installs populate fork trust while retaining the one-time bootstrap policy' + +source "$ROOT/install/helpers/arm-channel.sh" +missing="$test_tmp/missing-policy.conf" +sed '/SigLevel = Optional TrustAll/d' "$config" >"$missing" +omarchy_arm_signature_policy_render "$missing" 'PackageRequired DatabaseOptional TrustedOnly' "$test_tmp/rendered" +omarchy_arm_signature_policy_assert "$test_tmp/rendered" 'PackageRequired DatabaseOptional TrustedOnly' || + fail 'renderer does not establish effective policy when the stanza inherited one' + +ln -s "$config" "$test_tmp/pacman-link.conf" +if omarchy_arm_signature_policy_apply "$test_tmp/pacman-link.conf" 'PackageRequired DatabaseOptional TrustedOnly' >/dev/null 2>&1; then + fail 'policy application follows a pacman.conf symlink' +fi +pass 'policy rendering handles formatting and missing overrides while application rejects symlinks' diff --git a/test/shell.d/package-build-contract-test.sh b/test/shell.d/package-build-contract-test.sh index 40c32f8bb68..fbac68ff7d7 100755 --- a/test/shell.d/package-build-contract-test.sh +++ b/test/shell.d/package-build-contract-test.sh @@ -23,6 +23,20 @@ RECIPE ) || fail 'local source version controls package metadata and exact pair dependency' pass 'source version replaces stale recipe version and preserves explicit pkgrel' +( + source "$ROOT/build-packages.sh" + cat >"$work_dir/omarchy-PKGBUILD" <<'RECIPE' +depends=( + 'omarchy-settings' +) +RECIPE + ensure_omarchy_mac_keyring_dependency "$work_dir/omarchy-PKGBUILD" + ensure_omarchy_mac_keyring_dependency "$work_dir/omarchy-PKGBUILD" + [[ $(grep -c "^[[:space:]]*'omarchy-mac-keyring'$" "$work_dir/omarchy-PKGBUILD") == 1 ]] + [[ " ${packages[*]} " == *' omarchy-mac-keyring '* ]] +) || fail 'omarchy package depends exactly once on the fork keyring' +pass 'build includes the fork keyring and makes it an Omarchy dependency' + # Reject dirty/wrong default sources before touching an existing output. ( source "$ROOT/build-inputs/prepare-recipes.sh" @@ -32,6 +46,18 @@ pass 'source version replaces stale recipe version and preserves explicit pkgrel ) || fail 'unversioned recipes require explicit custom-build opt-in' pass 'release builds reject unversioned recipe inputs before staging' +( + source "$ROOT/build-inputs/prepare-recipes.sh" + OMARCHY_ALLOW_CUSTOM_RECIPES=1 prepare_omarchy_recipes \ + "$ROOT/../omarchy-pkgs/pkgbuilds" "$work_dir/with-keyring" >/dev/null + keyring="$work_dir/with-keyring/pkgbuilds/omarchy-mac-keyring" + [[ -f $keyring/PKGBUILD && -f $keyring/omarchy-mac-keyring.install ]] + cmp "$ROOT/default/pacman/keyrings/omarchy-mac.gpg" "$keyring/omarchy-mac.gpg" + cmp "$ROOT/default/pacman/keyrings/omarchy-mac-trusted" "$keyring/omarchy-mac-trusted" + [[ ! -s $keyring/omarchy-mac-revoked ]] +) || fail 'prepared recipes contain the exact pinned fork keyring payload' +pass 'prepared recipes carry exact fork-owned trust bytes' + # Check Arch's interpreted metadata, not grep of PKGBUILD shell syntax. # The fixtures cover both common and architecture-specific build dependencies. ( diff --git a/version b/version index 6472dd8554b..a95e48f37dd 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.0.3rc3 +4.0.3rc4 From e639d8e382e2de9238a4635b3c2ee25ed4509f07 Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 22:18:59 +0530 Subject: [PATCH 26/27] Validate prepared package recipes --- build-packages.sh | 13 +++++++++---- test/shell.d/package-build-contract-test.sh | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/build-packages.sh b/build-packages.sh index c3116053529..a622b946a6a 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -210,6 +210,14 @@ install_build_dependencies() { sudo pacman -S --needed --noconfirm "${missing[@]}" } +require_package_recipes() { + local pkgbuild_source="$1" package + + for package in "${packages[@]}"; do + [[ -d "$pkgbuild_source/$package" ]] || fail "$pkgbuild_source/$package is missing." + done +} + remove_old_packages() { local artifact @@ -273,16 +281,13 @@ main() { fail "No omarchy-pkgs checkout found. Set OMARCHY_PKGS_PATH or clone it beside this repo." log "Using PKGBUILDs from $pkgbuild_source" - for package in "${packages[@]}"; do - [[ -d "$pkgbuild_source/$package" ]] || fail "$pkgbuild_source/$package is missing." - done - # build_dir stays global: an EXIT trap runs after main's locals are gone, and # under set -u a local would abort the trap instead of cleaning up. build_dir="$(mktemp -d)" trap remove_build_dir EXIT prepare_omarchy_recipes "$pkgbuild_source" "$build_dir/recipes" pkgbuild_source="$build_dir/recipes/pkgbuilds" + require_package_recipes "$pkgbuild_source" install_build_dependencies "$pkgbuild_source" mkdir -p "$output_dir" "$source_cache" diff --git a/test/shell.d/package-build-contract-test.sh b/test/shell.d/package-build-contract-test.sh index fbac68ff7d7..f108b333acf 100755 --- a/test/shell.d/package-build-contract-test.sh +++ b/test/shell.d/package-build-contract-test.sh @@ -58,6 +58,20 @@ pass 'release builds reject unversioned recipe inputs before staging' ) || fail 'prepared recipes contain the exact pinned fork keyring payload' pass 'prepared recipes carry exact fork-owned trust bytes' +# The fork keyring is injected locally and deliberately absent from the pinned +# upstream recipe checkout. Validate only after preparing the combined tree. +( + source "$ROOT/build-packages.sh" + upstream="$ROOT/../omarchy-pkgs/pkgbuilds" + prepared="$work_dir/prepared-with-fork-keyring" + [[ ! -e $upstream/omarchy-mac-keyring ]] + if ( require_package_recipes "$upstream" >/dev/null 2>&1 ); then exit 1; fi + + OMARCHY_ALLOW_CUSTOM_RECIPES=1 prepare_omarchy_recipes "$upstream" "$prepared" >/dev/null + require_package_recipes "$prepared/pkgbuilds" +) || fail 'package validation must run against locally augmented recipes' +pass 'local keyring recipe is accepted after recipe preparation' + # Check Arch's interpreted metadata, not grep of PKGBUILD shell syntax. # The fixtures cover both common and architecture-specific build dependencies. ( From 46f3294d5646ee215e149c1fe79a68f2af6b961b Mon Sep 17 00:00:00 2001 From: Naeem Malik Date: Sun, 13 Sep 2026 22:25:01 +0530 Subject: [PATCH 27/27] Honor configured package recipe checkout in tests --- test/shell.d/package-build-contract-test.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/shell.d/package-build-contract-test.sh b/test/shell.d/package-build-contract-test.sh index f108b333acf..a44d72c9173 100755 --- a/test/shell.d/package-build-contract-test.sh +++ b/test/shell.d/package-build-contract-test.sh @@ -3,6 +3,8 @@ set -euo pipefail source "$(dirname -- "${BASH_SOURCE[0]}")/base-test.sh" work_dir=$(mktemp -d) trap 'rm -rf "$work_dir"' EXIT +recipe_source="${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs}" +[[ ! -d $recipe_source/pkgbuilds ]] || recipe_source="$recipe_source/pkgbuilds" # Exercise local-source package metadata, including the pair's dynamic pin. cat >"$work_dir/PKGBUILD" <<'RECIPE' @@ -49,7 +51,7 @@ pass 'release builds reject unversioned recipe inputs before staging' ( source "$ROOT/build-inputs/prepare-recipes.sh" OMARCHY_ALLOW_CUSTOM_RECIPES=1 prepare_omarchy_recipes \ - "$ROOT/../omarchy-pkgs/pkgbuilds" "$work_dir/with-keyring" >/dev/null + "$recipe_source" "$work_dir/with-keyring" >/dev/null keyring="$work_dir/with-keyring/pkgbuilds/omarchy-mac-keyring" [[ -f $keyring/PKGBUILD && -f $keyring/omarchy-mac-keyring.install ]] cmp "$ROOT/default/pacman/keyrings/omarchy-mac.gpg" "$keyring/omarchy-mac.gpg" @@ -62,7 +64,7 @@ pass 'prepared recipes carry exact fork-owned trust bytes' # upstream recipe checkout. Validate only after preparing the combined tree. ( source "$ROOT/build-packages.sh" - upstream="$ROOT/../omarchy-pkgs/pkgbuilds" + upstream="$recipe_source" prepared="$work_dir/prepared-with-fork-keyring" [[ ! -e $upstream/omarchy-mac-keyring ]] if ( require_package_recipes "$upstream" >/dev/null 2>&1 ); then exit 1; fi