* 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>
654 lines
24 KiB
Bash
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
|