Files
omarchy/test/shell.d/update-stay-awake-security-test.sh
T
David Heinemeier HanssonandClaude Opus 5.5 e1614f2bdb Ask for the sudo password once per omarchy update (#13323)
* Ask for the sudo password once per omarchy update

Every sudo call in omarchy update prompted, because the no-update wrapper
covered the whole run on top of per-phase revokes, and stay-awake revoked
the timestamp on its own entry and exit. A single update could ask four
times before the snapshot finished (#13319).

Authorize once, right after confirmation, starting from a revoked
timestamp so the prompt always belongs to this update. A background
keepalive refreshes it until the update is done. Prune, snapshot,
stay-awake, keyring, system packages, migrations, orphan removal, service
restarts, the post-update hook, and mise all share that authorization.

AUR builds run third-party PKGBUILD code, so they move to the end and run
cold: the keepalive stops, the timestamp is revoked, and yay and any bare
sudo use the no-update wrapper. The timestamp is revoked again after AUR
and on every exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the single authorization for passwordless sudo and ttyless inhibition

Authorize by running a command instead of sudo -v. Under the default
verifypw=all, -v prompts even when passwordless sudo is enabled, which
would have added a prompt those users never had.

Inside an update without a terminal, stay-awake now reuses the update's
authorization with a non-interactive sudo instead of asking again through
polkit. It falls back to polkit only if that authorization is gone.

The test sudo refuses a cold non-interactive call, as the real one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:31:40 +02:00

654 lines
24 KiB
Bash

#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
test_tmp=$(mktemp -d)
test_processes=()
test_runtime_created=""
cleanup_test() {
for pid in "${test_processes[@]}"; do kill "$pid" 2>/dev/null || true; done
[[ -z ${state_dir:-} ]] || rm -rf -- "$state_dir"
[[ -z ${state_hardlink:-} ]] || rm -f -- "$state_hardlink"
rm -rf -- "$test_tmp"
[[ -z $test_runtime_created ]] || rmdir -- "$test_runtime_created" 2>/dev/null || true
}
trap cleanup_test EXIT
stub_bin="$test_tmp/bin"
mapped_root="$test_tmp/omarchy"
test_home="$test_tmp/home"
runtime_dir=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
if [[ ! -d $runtime_dir || -L $runtime_dir || $(stat -Lc '%u %a' "$runtime_dir" 2>/dev/null || true) != "$(id -u) 700" ]]; then
if (( EUID != 0 )); then
fail "test needs a private XDG runtime directory or root namespace"
fi
runtime_dir=$(mktemp -d -p /run omarchy-stay-awake-runtime.XXXXXXXX)
chmod 0700 "$runtime_dir"
test_runtime_created="$runtime_dir"
fi
test_run_id="test-$BASHPID-$RANDOM"
state_dir="$runtime_dir/omarchy-update-stay-awake-$test_run_id"
state_hardlink="$runtime_dir/.omarchy-update-stay-awake-hardlink-$test_run_id"
inhibitor_log="$test_tmp/inhibitors"
mkdir -p "$stub_bin" "$test_home" "$mapped_root/bin" "$mapped_root/default/omarchy/sudo-no-update"
: >"$inhibitor_log"
cat >"$stub_bin/pkexec" <<'SH'
#!/bin/bash
[[ -z ${SUDO_EVENT_LOG:-} ]] || printf 'pkexec\n' >>"$SUDO_EVENT_LOG"
exec "$@"
SH
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
case ${1:-} in
-h) echo 'usage: sudo [-bHkNnPS] command'; exit 0 ;;
-k|-K|-v)
[[ -z ${SUDO_EVENT_LOG:-} ]] || printf 'sudo %s\n' "$1" >>"$SUDO_EVENT_LOG"
exit 0
;;
esac
[[ -z ${SUDO_EVENT_LOG:-} ]] || printf 'sudo %s\n' "$*" >>"$SUDO_EVENT_LOG"
background=0
while (( $# )); do
case "$1" in
-N|-n) shift ;;
-b) background=1; shift ;;
--) shift; break ;;
*) break ;;
esac
done
if (( background )); then
"$@" &
else
exec "$@"
fi
SH
cat >"$stub_bin/setpriv" <<'SH'
#!/bin/bash
while [[ ${1:-} == --* ]]; do
case "$1" in
--reuid|--regid) shift 2 ;;
--clear-groups) shift ;;
*) exit 90 ;;
esac
done
exec "$@"
SH
cat >"$stub_bin/systemd-inhibit" <<'SH'
#!/bin/bash
[[ ${SYSTEMD_FAIL:-0} == "0" ]] || exit 42
printf '%s\n' "$$" >>"$INHIBITOR_LOG"
if [[ -n ${CREATE_BAD_IDLE:-} ]]; then
ln -s "$CREATE_BAD_IDLE" "$TEST_STATE_DIR/idle-owner"
fi
trap 'exit 0' TERM
while [[ ${1:-} == --* ]]; do shift; done
exec "$@"
SH
cat >"$stub_bin/omarchy-toggle-idle" <<'SH'
#!/bin/bash
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
case "$1" in
stay-awake)
mkdir -p "$(dirname "$state_file")"
touch "$state_file"
;;
allow-idle)
rm -f "$state_file"
;;
esac
SH
chmod +x "$stub_bin"/*
ln -s "$stub_bin/omarchy-toggle-idle" "$mapped_root/bin/omarchy-toggle-idle"
mapped_helper="$mapped_root/bin/omarchy-update-stay-awake"
cp "$ROOT/bin/omarchy-update-stay-awake" "$mapped_helper"
cp "$ROOT/bin/omarchy-security-functions" "$mapped_root/bin/omarchy-security-functions"
cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$mapped_root/default/omarchy/sudo-no-update/sudo"
for mapped_file in \
"$mapped_helper" \
"$mapped_root/bin/omarchy-security-functions" \
"$mapped_root/default/omarchy/sudo-no-update/sudo"; do
sed -i \
-e "s#/usr/bin/sudo#$stub_bin/sudo#g" \
-e "s#/usr/bin/pkexec#$stub_bin/pkexec#g" \
-e "s#/usr/bin/systemd-inhibit#$stub_bin/systemd-inhibit#g" \
-e "s#/usr/bin/setpriv#$stub_bin/setpriv#g" \
-e 's#state_dir="$state_base/omarchy-update-stay-awake"#state_dir="$state_base/omarchy-update-stay-awake-${OMARCHY_TEST_RUN_ID:?}"#' \
"$mapped_file"
done
chmod +x "$mapped_helper" "$mapped_root/default/omarchy/sudo-no-update/sudo"
run_helper() {
HOME="$test_home" \
XDG_RUNTIME_DIR="$runtime_dir" \
INHIBITOR_LOG="$inhibitor_log" \
TEST_STATE_DIR="$state_dir" \
OMARCHY_TEST_RUN_ID="$test_run_id" \
OMARCHY_PATH="$mapped_root" \
PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
"$mapped_helper" "$@"
}
wait_dead() {
local pid="$1"
for _ in {1..100}; do
kill -0 "$pid" 2>/dev/null || return 0
[[ $(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null || true) == "Z" ]] && return 0
sleep 0.02
done
return 1
}
prepare_state_dir() {
rm -rf "$state_dir"
mkdir -m 700 "$state_dir"
}
write_inhibit_state() {
local record="$1"
printf '%s\n' "$record" >"$state_dir/inhibit-pid"
chmod 600 "$state_dir/inhibit-pid"
}
start_identity_process() {
local token="$1"
/usr/bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.05; done' \
omarchy-test "--why=Omarchy update in progress [$token]" &
identity_pid=$!
test_processes+=("$identity_pid")
identity_start=$(awk '{ print $22 }' "/proc/$identity_pid/stat")
identity_owner=$(stat -Lc '%u' "/proc/$identity_pid")
}
run_helper start
[[ -s $state_dir/inhibit-pid ]] || fail "valid XDG runtime publishes inhibitor state"
read -r version valid_pid valid_start valid_owner valid_token <"$state_dir/inhibit-pid"
[[ $version == "1" && $valid_token =~ ^[0-9a-f]{32}$ ]] || fail "inhibitor state is an exact versioned identity"
[[ $(stat -Lc '%u %a %h' "$state_dir/inhibit-pid") == "$(id -u) 600 1" ]] ||
fail "inhibitor state is private, caller-owned, and singly linked"
run_helper stop
wait_dead "$valid_pid" || fail "valid inhibitor identity is stopped"
[[ ! -e $state_dir ]] || fail "valid state is cleaned after stop"
pass "valid XDG runtime uses private atomic inhibitor state"
# omarchy update owns its one authorization; the helper must not revoke it.
# Run on its own, the helper still starts and ends cold.
sudo_events="$test_tmp/sudo-events"
: >"$sudo_events"
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper start
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper stop
! grep -qx 'sudo -k' "$sudo_events" || fail "helper revoked the update's authorization" "$(<"$sudo_events")"
SUDO_EVENT_LOG="$sudo_events" run_helper start
SUDO_EVENT_LOG="$sudo_events" run_helper stop
grep -qx 'sudo -k' "$sudo_events" || fail "standalone helper no longer revokes sudo"
pass "helper leaves the update's authorization alone and revokes when standalone"
# Without a terminal, an update's inhibitor reuses the update's authorization
# non-interactively instead of asking again through polkit.
: >"$sudo_events"
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper start </dev/null
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper stop </dev/null
grep -q -- '^sudo -n -N -b -- ' "$sudo_events" || fail "update inhibitor without a terminal did not reuse sudo" "$(<"$sudo_events")"
! grep -qx pkexec "$sudo_events" || fail "update inhibitor without a terminal asked polkit" "$(<"$sudo_events")"
pass "update inhibitor without a terminal reuses the update's authorization instead of polkit"
permissive_runtime="$test_tmp/permissive-runtime"
mkdir -m 755 "$permissive_runtime"
if HOME="$test_home" XDG_RUNTIME_DIR="$permissive_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then
fail "permissive XDG runtime is rejected"
fi
symlink_runtime="$test_tmp/runtime-link"
ln -s "$runtime_dir" "$symlink_runtime"
if HOME="$test_home" XDG_RUNTIME_DIR="$symlink_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then
fail "symlink XDG runtime is rejected"
fi
if HOME="$test_home" XDG_RUNTIME_DIR="$test_tmp/../${test_tmp##*/}/runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then
fail "non-canonical XDG runtime is rejected"
fi
pass "unsafe XDG runtime directories are rejected"
mkdir -m 700 "$test_tmp/state-target"
ln -s "$test_tmp/state-target" "$state_dir"
if run_helper stop 2>/dev/null; then
fail "symlink inhibitor state directory is rejected"
fi
rm -f "$state_dir"
mkdir -m 777 "$state_dir"
chmod 777 "$state_dir"
if run_helper stop 2>/dev/null; then
fail "writable-by-others inhibitor state directory is rejected"
fi
rm -rf "$state_dir"
pass "unsafe inhibitor state directories are rejected"
# Package replacement leaves the preceding helper's PID/start pair and its
# umask-derived modes for the newly installed stop command to consume.
idle_marker="$test_home/.local/state/omarchy/indicators/stay-awake"
for modes in '755 644' '750 640' '700 600'; do
read -r directory_mode file_mode <<<"$modes"
for idle_choice in update user; do
prepare_state_dir
chmod "$directory_mode" "$state_dir"
sleep infinity &
legacy_pid=$!
test_processes+=("$legacy_pid")
legacy_start=$(awk '{ print $22 }' "/proc/$legacy_pid/stat")
printf '%s %s\n' "$legacy_pid" "$legacy_start" >"$state_dir/inhibit-pid"
printf '123:456:789\n' >"$state_dir/idle-owner"
chmod "$file_mode" "$state_dir/"{inhibit-pid,idle-owner}
mkdir -p "${idle_marker%/*}"
if [[ $idle_choice == "update" ]]; then
cp "$state_dir/idle-owner" "$idle_marker"
else
printf 'user-choice\n' >"$idle_marker"
fi
run_helper stop || fail "legacy $modes cleanup fails after helper replacement"
wait_dead "$legacy_pid" || fail "legacy $modes inhibitor survives cleanup"
[[ ! -e $state_dir ]] || fail "legacy $modes state survives cleanup"
if [[ $idle_choice == "update" ]]; then
[[ ! -e $idle_marker ]] || fail "legacy cleanup leaves update-owned Stay Awake enabled"
else
[[ $(<"$idle_marker") == "user-choice" ]] || fail "legacy cleanup changes the user's Stay Awake choice"
rm -f "$idle_marker"
fi
done
done
for legacy_record in stale multiline nul; do
prepare_state_dir
chmod 755 "$state_dir"
sleep infinity &
legacy_pid=$!
test_processes+=("$legacy_pid")
legacy_start=$(awk '{ print $22 }' "/proc/$legacy_pid/stat")
case "$legacy_record" in
stale) printf '%s %s\n' "$legacy_pid" "$((legacy_start + 1))" ;;
multiline) printf '%s %s\nextra\n' "$legacy_pid" "$legacy_start" ;;
nul) printf '%s\0 %s\n' "$legacy_pid" "$legacy_start" ;;
esac >"$state_dir/inhibit-pid"
chmod 644 "$state_dir/inhibit-pid"
if [[ $legacy_record == "stale" ]]; then
run_helper stop
elif run_helper stop 2>/dev/null; then
fail "malformed legacy $legacy_record record was accepted"
fi
kill -0 "$legacy_pid" || fail "legacy $legacy_record record signaled an unrelated process"
kill "$legacy_pid"
wait "$legacy_pid" 2>/dev/null || true
done
pass "legacy upgrade cleanup preserves process identity and the user's idle choice"
prepare_state_dir
printf 'not a record\n' >"$state_dir/inhibit-pid"
chmod 600 "$state_dir/inhibit-pid"
if run_helper stop 2>/dev/null; then
fail "malformed inhibitor state is rejected"
fi
token=11111111111111111111111111111111
start_identity_process "$token"
prepare_state_dir
printf '1 %s %s %s %s\nextra\n' "$identity_pid" "$identity_start" "$identity_owner" "$token" >"$state_dir/inhibit-pid"
chmod 600 "$state_dir/inhibit-pid"
if run_helper stop 2>/dev/null; then
fail "multiline inhibitor state is rejected"
fi
kill -0 "$identity_pid" 2>/dev/null || fail "multiline state cannot signal its target"
prepare_state_dir
write_inhibit_state "1 $identity_pid $((identity_start + 1)) $identity_owner $token"
run_helper stop
kill -0 "$identity_pid" 2>/dev/null || fail "reused PID state cannot signal its target"
prepare_state_dir
write_inhibit_state "1 $identity_pid $identity_start $identity_owner 22222222222222222222222222222222"
run_helper stop
kill -0 "$identity_pid" 2>/dev/null || fail "wrong process identity cannot signal its target"
kill "$identity_pid"
wait_dead "$identity_pid" || true
pass "malformed, multiline, reused-PID, and wrong-identity records are harmless"
retry_flag="$test_tmp/allow-termination"
token=44444444444444444444444444444444
/usr/bin/bash -c '
trap "" TERM
while [[ ! -e $1 ]]; do sleep 0.05; done
trap "exit 0" TERM
while :; do sleep 0.05; done
' omarchy-retry "$retry_flag" "--why=Omarchy update in progress [$token]" &
retry_pid=$!
test_processes+=("$retry_pid")
retry_start=$(awk '{ print $22 }' "/proc/$retry_pid/stat")
retry_owner=$(stat -Lc '%u' "/proc/$retry_pid")
prepare_state_dir
write_inhibit_state "1 $retry_pid $retry_start $retry_owner $token"
if run_helper stop 2>/dev/null; then
fail "failed termination reports success"
fi
[[ -s $state_dir/inhibit-pid ]] || fail "failed termination retains authenticated retry state"
touch "$retry_flag"
sleep 0.1
run_helper stop
wait_dead "$retry_pid" || fail "retained inhibitor state permits a successful retry"
pass "failed termination retains its authenticated retry handle"
for unsafe_kind in symlink permissive hardlink legacy-symlink legacy-permissive legacy-hardlink; do
token=33333333333333333333333333333333
start_identity_process "$token"
prepare_state_dir
record="1 $identity_pid $identity_start $identity_owner $token"
[[ $unsafe_kind != legacy-* ]] || record="$identity_pid $identity_start"
case "${unsafe_kind#legacy-}" in
symlink)
printf '%s\n' "$record" >"$test_tmp/state-victim"
chmod 600 "$test_tmp/state-victim"
ln -s "$test_tmp/state-victim" "$state_dir/inhibit-pid"
;;
permissive)
write_inhibit_state "$record"
if [[ $unsafe_kind == "legacy-permissive" ]]; then
chmod 666 "$state_dir/inhibit-pid"
else
chmod 644 "$state_dir/inhibit-pid"
fi
;;
hardlink)
write_inhibit_state "$record"
ln "$state_dir/inhibit-pid" "$state_hardlink"
;;
esac
if run_helper stop 2>/dev/null; then
fail "$unsafe_kind inhibitor state is rejected"
fi
kill -0 "$identity_pid" 2>/dev/null || fail "$unsafe_kind state cannot signal its target"
kill "$identity_pid"
wait_dead "$identity_pid" || true
rm -f "$test_tmp/state-victim" "$state_hardlink"
done
pass "symlink, permissive, and multiply-linked records are harmless"
: >"$inhibitor_log"
run_helper start
first_pid=$(tail -n 1 "$inhibitor_log")
run_helper start
second_pid=$(tail -n 1 "$inhibitor_log")
[[ $first_pid != "$second_pid" ]] || fail "repeated start replaces the inhibitor"
wait_dead "$first_pid" || fail "repeated start stops the prior inhibitor"
run_helper stop
run_helper stop
wait_dead "$second_pid" || fail "repeated stop remains idempotent"
pass "repeated start and stop preserve one inhibitor"
: >"$inhibitor_log"
concurrent_jobs=()
for _ in {1..4}; do
(run_helper start; run_helper stop) &
concurrent_jobs+=("$!")
done
for job in "${concurrent_jobs[@]}"; do
wait "$job" || fail "concurrent start and stop are serialized"
done
run_helper stop
while read -r pid; do
[[ -n $pid ]] || continue
wait_dead "$pid" || fail "concurrent operation leaves no inhibitor behind"
done <"$inhibitor_log"
pass "concurrent state operations are serialized"
if SYSTEMD_FAIL=1 run_helper start; then
fail "failed systemd-inhibit launch reports success"
fi
[[ ! -e $state_dir/inhibit-pid ]] || fail "failed inhibitor launch publishes no PID state"
run_helper stop
pass "failed inhibitor launch leaves no stale process state"
: >"$inhibitor_log"
rollback_victim="$test_tmp/rollback-victim"
: >"$rollback_victim"
if CREATE_BAD_IDLE="$rollback_victim" run_helper start 2>/dev/null; then
fail "unsafe idle publication reports success"
fi
rollback_pid=$(tail -n 1 "$inhibitor_log")
wait_dead "$rollback_pid" || fail "post-publication failure rolls the inhibitor back"
[[ ! -e $state_dir ]] || fail "rollback removes published inhibitor and idle ownership state"
pass "state publication failures roll back a launched inhibitor"
# Hold each cancellation window open, including publication before child exec,
# readiness before idle setup, and publication of the update-owned idle marker.
cp "$mapped_helper" "$test_tmp/helper-before-pause"
idle_marker="$test_home/.local/state/omarchy/indicators/stay-awake"
for cancel_phase in published ready idle-temporary idle user-idle; do
rm -f "$test_tmp/cancel-ready" "$test_tmp/release-child"
if [[ $cancel_phase == "user-idle" ]]; then
mkdir -p "${idle_marker%/*}"
printf 'user-choice\n' >"$idle_marker"
fi
python3 - "$mapped_helper" "$cancel_phase" <<'PY'
from pathlib import Path
import sys
p = Path(sys.argv[1])
s = p.read_text()
pause = ': >"$TEST_CANCEL_READY"; while :; do /usr/bin/sleep 0.02; done'
if sys.argv[2] == 'published':
anchor = ' while :; do\n inhibit_record='
edits = [(anchor, ' ' + pause + '\n' + anchor),
(' exec -a "$expected"',
' while [[ ! -e $TEST_RELEASE_CHILD ]]; do /usr/bin/sleep 0.02; done\n exec -a "$expected"')]
elif sys.argv[2] == 'idle-temporary':
anchor = ' temporary=$(mktemp "$state_dir/.${state_file##*/}.XXXXXXXX") || return 1'
edits = [(anchor, anchor + '\n if [[ $state_file == "$idle_owner_file" ]]; then ' + pause + '; fi')]
elif sys.argv[2] == 'idle':
anchor = '''printf '%s\\n' "$idle_owner" >"$stay_awake_state"'''
edits = [(anchor, '{ ' + anchor + ' && { ' + pause + '; }; }')]
else:
anchor = ' if [[ ! -f $stay_awake_state ]]; then'
edits = [(anchor, ' ' + pause + '\n' + anchor)]
for old, new in edits:
assert s.count(old) == 1, old
s = s.replace(old, new)
p.write_text(s)
PY
(
export HOME="$test_home" XDG_RUNTIME_DIR="$runtime_dir" OMARCHY_PATH="$mapped_root"
export INHIBITOR_LOG="$inhibitor_log" OMARCHY_TEST_RUN_ID="$test_run_id"
export TEST_CANCEL_READY="$test_tmp/cancel-ready" TEST_RELEASE_CHILD="$test_tmp/release-child"
export PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin"
exec "$mapped_helper" start
) >"$test_tmp/$cancel_phase-cancel.log" 2>&1 &
cancelled_launcher=$!
test_processes+=("$cancelled_launcher")
for _ in {1..100}; do
[[ -e $test_tmp/cancel-ready && -s $state_dir/inhibit-pid ]] && break
sleep 0.02
done
[[ -e $test_tmp/cancel-ready && -s $state_dir/inhibit-pid ]] || fail "$cancel_phase cancellation reached its barrier"
read -r _ published_pid _ <"$state_dir/inhibit-pid"
test_processes+=("$published_pid")
kill -TERM "$cancelled_launcher"
cancelled_status=0
wait "$cancelled_launcher" || cancelled_status=$?
(( cancelled_status == 143 )) || fail "$cancel_phase launch preserves cancellation status"
touch "$test_tmp/release-child"
wait_dead "$published_pid" || fail "$cancel_phase cancellation leaked the held child"
[[ ! -e $state_dir ]] || fail "$cancel_phase cancellation left launch state"
if [[ $cancel_phase == "user-idle" ]]; then
[[ $(<"$idle_marker") == "user-choice" ]] || fail "cancellation changed the user's Stay Awake choice"
rm -f "$idle_marker"
else
[[ ! -e $idle_marker ]] || fail "$cancel_phase cancellation left Stay Awake enabled"
fi
cp "$test_tmp/helper-before-pause" "$mapped_helper"
done
pass "cancellation through idle setup stops the child and restores only update-owned idle state"
namespace_args=()
namespace_probe_error="$test_tmp/namespace-probe.err"
if (( EUID == 0 )); then
namespace_args=(
unshare --user --mount --fork
--map-users=0:0:1 --map-users=1000:1000:2
--map-groups=0:0:1 --map-groups=1000:1000:2
--setuid=0 --setgid=0
)
else
subordinate_uid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid 2>/dev/null || true)
subordinate_gid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid 2>/dev/null || true)
if [[ $subordinate_uid =~ ^[0-9]+$ && $subordinate_gid =~ ^[0-9]+$ ]]; then
namespace_args=(
unshare --user --mount --fork
"--map-users=0:$(id -u):1" "--map-users=1000:$subordinate_uid:2"
"--map-groups=0:$(id -g):1" "--map-groups=1000:$subordinate_gid:2"
--setuid=0 --setgid=0
)
fi
fi
namespace_capable=0
if (( ${#namespace_args[@]} > 0 )) &&
"${namespace_args[@]}" /usr/bin/bash -c '
set -e
mount -t tmpfs -o mode=1777 tmpfs /tmp
setpriv --reuid=1000 --regid=1000 --clear-groups true
setpriv --reuid=1001 --regid=1001 --clear-groups true
' 2>"$namespace_probe_error"; then
namespace_capable=1
fi
if (( namespace_capable == 0 )); then
skip "cross-UID fallback probe: two-UID mount namespace unavailable"
else
# Keep the protected entrypoint, shared helper and harmless command stubs
# together, even when tmpfs hides a checkout or fixture under /tmp.
tar -C "$test_tmp" -cf "$test_tmp/namespace-fixture.tar" bin omarchy
if ! "${namespace_args[@]}" /usr/bin/bash -s -- "$test_tmp" "$test_run_id" \
9<"$test_tmp/namespace-fixture.tar" <<'SH'
set -euo pipefail
fixture=$1
run_id=$2
mount -t tmpfs -o mode=1777 tmpfs /tmp
mkdir -m 755 "$fixture"
tar -C "$fixture" -xf /proc/self/fd/9
exec 9<&-
mkdir -m 700 /tmp/victim-home
chown 1000:1000 /tmp/victim-home
setpriv --reuid=1000 --regid=1000 --clear-groups sleep 30 &
victim_pid=$!
trap 'kill "$victim_pid" "${legacy_sudo_pid:-$victim_pid}" 2>/dev/null || true; wait "$victim_pid" 2>/dev/null || true' EXIT
victim_start=$(awk '{ print $22 }' "/proc/$victim_pid/stat")
state=/tmp/omarchy-1000/omarchy-update-stay-awake-$run_id
run_fallback() {
local uid=$1 home=$2
shift 2
setpriv --reuid="$uid" --regid="$uid" --clear-groups /usr/bin/env -i \
HOME="$home" OMARCHY_PATH="$fixture/omarchy" OMARCHY_TEST_RUN_ID="$run_id" \
INHIBITOR_LOG="$home/inhibitors" PATH="$fixture/bin:/usr/bin:/bin" \
"$fixture/omarchy/bin/omarchy-update-stay-awake" "$@"
}
setpriv --reuid=1001 --regid=1001 --clear-groups /usr/bin/bash -c '
mkdir -m 755 /tmp/omarchy-1000 "$3"
printf "%s %s\n" "$1" "$2" >"$3/inhibit-pid"
chmod 644 "$3/inhibit-pid"
' attacker "$victim_pid" "$victim_start" "$state"
if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then
echo "foreign fallback state was accepted" >&2
exit 1
fi
grep -q 'unsafe Omarchy update inhibitor state path' /tmp/refusal
kill -0 "$victim_pid"
rm -rf /tmp/omarchy-1000
# The old helper also left a readable fallback parent after a successful stop.
setpriv --reuid=1000 --regid=1000 --clear-groups mkdir -m 755 /tmp/omarchy-1000
run_fallback 1000 /tmp/victim-home start
[[ $(stat -Lc '%u %a' /tmp/omarchy-1000) == "1000 700" ]]
run_fallback 1000 /tmp/victim-home stop
[[ ! -e $state ]]
mkdir -m 700 "$state"
chown 1000:1000 "$state"
printf '1 %s %s 1000 %032d\n' "$victim_pid" "$victim_start" 0 \
>"$state/inhibit-pid"
chown 1001:1001 "$state/inhibit-pid"
chmod 600 "$state/inhibit-pid"
if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then
echo "foreign state file was accepted" >&2
exit 1
fi
grep -q 'unsafe Omarchy update sleep inhibitor state' /tmp/refusal
kill -0 "$victim_pid"
# A legacy-looking record owned by another account is still untrusted.
mkdir -m 700 "$state"
chown 1000:1000 "$state"
printf '%s %s\n' "$victim_pid" "$victim_start" >"$state/inhibit-pid"
chown 1001:1001 "$state/inhibit-pid"
chmod 644 "$state/inhibit-pid"
if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then
echo "foreign legacy state file was accepted" >&2
exit 1
fi
kill -0 "$victim_pid"
# The old terminal helper recorded sudo, with the caller's real UID but an
# effective root UID. Its /proc owner is root; the caller can still signal it.
setpriv --ruid=1000 --euid=0 --regid=1000 --clear-groups sleep 30 &
legacy_sudo_pid=$!
for _ in {1..50}; do
[[ $(awk '/^Uid:/ { print $2, $3 }' "/proc/$legacy_sudo_pid/status") == "1000 0" ]] && break
sleep .02
done
[[ $(awk '/^Uid:/ { print $2, $3 }' "/proc/$legacy_sudo_pid/status") == "1000 0" ]]
mkdir -m 755 "$state"
chown 1000:1000 "$state"
printf '%s %s\n' "$legacy_sudo_pid" "$(awk '{ print $22 }' "/proc/$legacy_sudo_pid/stat")" >"$state/inhibit-pid"
chown 1000:1000 "$state/inhibit-pid"
chmod 644 "$state/inhibit-pid"
run_fallback 1000 /tmp/victim-home stop
[[ ! -e /proc/$legacy_sudo_pid || $(awk '{ print $3 }' "/proc/$legacy_sudo_pid/stat") == "Z" ]]
wait "$legacy_sudo_pid" 2>/dev/null || true
# Root may read the record, but must not signal a live legacy target whose
# real UID differs. Preserve the retry handle instead of treating it as dead.
mkdir -m 700 /tmp/root-home
root_state=/tmp/omarchy-0/omarchy-update-stay-awake-$run_id
mkdir -p "$root_state"
printf '%s %s\n' "$victim_pid" "$victim_start" >"$root_state/inhibit-pid"
chmod 644 "$root_state/inhibit-pid"
if run_fallback 0 /tmp/root-home stop 2>/tmp/refusal; then
echo "legacy cleanup accepted another account's live process" >&2
exit 1
fi
grep -q 'retained its state for recovery' /tmp/refusal
[[ -s $root_state/inhibit-pid ]]
kill -0 "$victim_pid"
rm "$root_state/inhibit-pid"
run_fallback 0 /tmp/root-home start
[[ $(stat -Lc '%u %a' /tmp/omarchy-0) == "0 700" ]]
run_fallback 0 /tmp/root-home stop
[[ ! -e /tmp/omarchy-0/omarchy-update-stay-awake-$run_id ]]
SH
then
fail "two-UID fallback probe failed after its capability check" "$(<"$namespace_probe_error")"
fi
pass "foreign UID fallback state cannot kill a victim and safe fallback works"
fi