diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 7f266648..22c789fd 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -29,17 +29,25 @@ set -euo pipefail PROVISIONING_DIR=/var/lib/omarchy/provisioning +PACKAGED_PATH=/usr/bin/omarchy-system-factory-reset OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" TOP_MNT=/run/omarchy-system-factory-reset/top NEXT_NAME=@omarchy-reset-next LOG_FILE=/var/log/omarchy-system-factory-reset.log +# Never replace a newer checkout reset with a different installed implementation. +if ! /usr/bin/cmp -s -- "${BASH_SOURCE[0]}" "$PACKAGED_PATH"; then + echo "Factory reset differs from the installed command. Install the matching Omarchy package before resetting." >&2 + exit 1 +fi + # Self-elevate so the menu entry (and a bare shell invocation) need no sudo # prefix. The caller's gum theme vars are forwarded as `env` arguments rather # than via `sudo -E`, so styling survives even under an env_reset sudoers. +# Always elevate the packaged copy, including when invoked from a dev checkout. if (( EUID != 0 )); then mapfile -t gum_env < <(env | grep '^GUM_' || true) - exec sudo env "${gum_env[@]}" "$0" "$@" + exec sudo env "${gum_env[@]}" "$PACKAGED_PATH" "$@" fi export PATH="$OMARCHY_PATH/bin:$PATH" diff --git a/docs/file-layout.md b/docs/file-layout.md index 1bf34747..4abb6745 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -198,6 +198,8 @@ yet and silently runs the packaged copy of one it has. The drop-in is validated with `visudo -c` before install and removed by `omarchy-dev-unlink`; unlike `/etc/omarchy.conf`, it takes effect without a reboot. +Factory reset is an exception: it always self-elevates through `/usr/bin/omarchy-system-factory-reset` and refuses a checkout copy that differs from the installed command, including when invoked with `sudo`. Install the matching package before resetting so a newer checkout cannot silently hand off to older account-scrubbing code. + ## Runtime finalization (`omarchy-provision-user`) Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, diff --git a/test/shell.d/factory-reset-elevation-test.sh b/test/shell.d/factory-reset-elevation-test.sh new file mode 100755 index 00000000..da47d88f --- /dev/null +++ b/test/shell.d/factory-reset-elevation-test.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +if (( EUID == 0 )); then + skip "factory-reset self-elevation requires an unprivileged caller" + exit 0 +fi + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/checkout with spaces" + +# Exercise the real startup, but never include the destructive reset body. +awk ' + /^export PATH=/ { found = 1; print "exit 99"; exit } + { print } + END { if (!found) exit 1 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$tmp/checkout with spaces/reset" +grep -Fxq 'PACKAGED_PATH=/usr/bin/omarchy-system-factory-reset' "$ROOT/bin/omarchy-system-factory-reset" || fail "factory reset pins the packaged path" +sed -i "s|^PACKAGED_PATH=.*|PACKAGED_PATH=\"$tmp/packaged-reset\"|" "$tmp/checkout with spaces/reset" +cp "$tmp/checkout with spaces/reset" "$tmp/packaged-reset" +chmod +x "$tmp/checkout with spaces/reset" "$tmp/packaged-reset" +ln -s "$tmp/checkout with spaces/reset" "$tmp/reset-link" + +cat >"$tmp/bin/sudo" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >"$CALL_LOG" +exit "${SUDO_STATUS:-0}" +SH +chmod +x "$tmp/bin/sudo" + +args=('argument with spaces' '' '--flag' '$(touch should-not-run)' $'two\nlines') +expected=(env 'GUM_INPUT_PROMPT=Reset this computer? ' "$tmp/packaged-reset" "${args[@]}") +for invocation in "$tmp/checkout with spaces/reset" './checkout with spaces/reset' "$tmp/reset-link" "$tmp/packaged-reset" reset; do + for status in 0 1 127; do + actual_status=0 + (cd "$tmp" && env -i HOME="$tmp" PATH="$tmp/bin:$tmp/checkout with spaces:/usr/bin:/bin" \ + OMARCHY_PATH="$tmp/checkout with spaces" PACKAGED_PATH="$tmp/wrong-target" \ + GUM_INPUT_PROMPT='Reset this computer? ' CALL_LOG="$tmp/call" SUDO_STATUS="$status" \ + "$invocation" "${args[@]}") || actual_status=$? + (( actual_status == status )) || fail "elevation status is preserved for $invocation" + mapfile -d '' -t actual <"$tmp/call" + (( ${#actual[@]} == ${#expected[@]} )) || fail "elevation argument count is preserved" + for i in "${!expected[@]}"; do + [[ ${actual[i]} == "${expected[i]}" ]] || fail "elevation argument $i is preserved for $invocation" + done + [[ ! -e $tmp/should-not-run ]] || fail "caller arguments were executed" + done + pass "factory reset pins $invocation to the packaged command and preserves arguments, styling and status" +done + +if unshare -Ur true >/dev/null 2>&1; then + status=0 + unshare -Ur "$tmp/packaged-reset" || status=$? + (( status == 99 )) || fail "matching root invocation did not reach the guarded body" + pass "matching packaged root invocation reaches the reset body" +else + skip "user namespaces unavailable; root invocation coverage" +fi + +# A checkout must not hand off to older installed code, even with explicit sudo. +printf '\n# older packaged copy\n' >>"$tmp/packaged-reset" +rm -f "$tmp/call" +if CALL_LOG="$tmp/call" PATH="$tmp/bin:/usr/bin:/bin" "$tmp/reset-link" >"$tmp/mismatch" 2>&1; then + fail "mismatched reset implementation was accepted" +fi +[[ ! -e $tmp/call ]] || fail "mismatched reset requested elevation" +grep -Fq 'Install the matching Omarchy package' "$tmp/mismatch" || fail "mismatch explains recovery" +if unshare -Ur true >/dev/null 2>&1; then + status=0 + unshare -Ur "$tmp/reset-link" >"$tmp/root-mismatch" 2>&1 || status=$? + (( status == 1 )) || fail "root invocation did not refuse mismatch before the reset body" + pass "root checkout invocation refuses a mismatched packaged reset" +fi +pass "mismatched checkout refuses before elevation"