diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index be27a2cc..f5dbd3ae 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -21,10 +21,211 @@ omarchy_security_install_sudo_cleanup_traps omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo -state_dir="${XDG_RUNTIME_DIR:-/tmp/omarchy-$UID}/omarchy-update-stay-awake" -idle_owner_file="$state_dir/idle-owner" -inhibit_pid_file="$state_dir/inhibit-pid" stay_awake_state="$HOME/.local/state/omarchy/indicators/stay-awake" +caller_uid="" +state_base="" +state_dir="" +idle_owner_file="" +inhibit_pid_file="" +launch_control_file="" +launch_pending=0 +launch_token="" + +fail_state_boundary() { + echo "Refusing to use an unsafe Omarchy update inhibitor state path." >&2 + return 1 +} + +directory_is_private() { + local directory="$1" + local expected_owner="$2" + local legacy="${3:-0}" + local canonical="" + local owner="" + local mode="" + + [[ -d $directory && ! -L $directory ]] || return 1 + canonical=$(readlink -e -- "$directory") || return 1 + [[ $canonical == "$directory" ]] || return 1 + read -r owner mode < <(stat -Lc '%u %a' -- "$directory") || return 1 + [[ $owner == "$expected_owner" ]] || return 1 + [[ $mode == "700" ]] || (( legacy == 1 && (8#$mode & 022) == 0 )) +} + +root_owned_parent_chain() { + local directory="$1" + local parent owner mode type canonical + + parent=$(/usr/bin/dirname -- "$directory") || return 1 + while :; do + [[ -d $parent && ! -L $parent ]] || return 1 + canonical=$(/usr/bin/readlink -e -- "$parent") || return 1 + [[ $canonical == "$parent" ]] || return 1 + read -r owner mode type < <(/usr/bin/stat -Lc '%u %a %F' -- "$parent") || return 1 + [[ $owner == 0 && $type == "directory" ]] || return 1 + ! ((8#$mode & 022)) || return 1 + [[ $parent == / ]] && break + parent=$(/usr/bin/dirname -- "$parent") || return 1 + done +} + +runtime_directory_is_private() { + local directory="$1" expected_owner="$2" + directory_is_private "$directory" "$expected_owner" && + root_owned_parent_chain "$directory" +} + +ensure_private_directory() { + local directory="$1" + + if [[ ! -e $directory && ! -L $directory ]]; then + mkdir -m 700 -- "$directory" 2>/dev/null || true + fi + # Older helpers used mkdir -p with the caller's umask. Tighten only a + # canonical, caller-owned directory that other accounts cannot write. + directory_is_private "$directory" "$caller_uid" 1 || return 1 + chmod 700 -- "$directory" || return 1 + directory_is_private "$directory" "$caller_uid" +} + +initialize_state_boundary() { + local canonical_tmp="" + local tmp_owner="" + local tmp_mode="" + + caller_uid="$EUID" + [[ $caller_uid =~ ^[0-9]+$ ]] || return 1 + + if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + runtime_directory_is_private "$XDG_RUNTIME_DIR" "$caller_uid" || fail_state_boundary + state_base="$XDG_RUNTIME_DIR" + else + [[ -d /tmp && ! -L /tmp ]] || fail_state_boundary + canonical_tmp=$(readlink -e -- /tmp) || fail_state_boundary + read -r tmp_owner tmp_mode < <(stat -Lc '%u %a' -- /tmp) || fail_state_boundary + [[ $canonical_tmp == "/tmp" && $tmp_owner == "0" && $tmp_mode == "1777" ]] || fail_state_boundary + + state_base="/tmp/omarchy-$caller_uid" + ensure_private_directory "$state_base" || fail_state_boundary + fi + + state_dir="$state_base/omarchy-update-stay-awake" + idle_owner_file="$state_dir/idle-owner" + inhibit_pid_file="$state_dir/inhibit-pid" + launch_control_file="$state_dir/launch-control" +} + +state_file_is_private() { + local state_file="$1" + local legacy="${2:-0}" + local owner="" + local mode="" + local links="" + + [[ -f $state_file && ! -L $state_file ]] || return 1 + read -r owner mode links < <(stat -Lc '%u %a %h' -- "$state_file") || return 1 + [[ $owner == "$caller_uid" && $links == "1" ]] || return 1 + [[ $mode == "600" ]] || (( legacy == 1 && (8#$mode & 022) == 0 )) +} + +read_state_record() { + local state_file="$1" + local legacy="${2:-0}" + local records=() + local file_size="" + local LC_ALL=C + + state_file_is_private "$state_file" "$legacy" || return 1 + mapfile -t records <"$state_file" || return 1 + (( ${#records[@]} == 1 )) || return 1 + file_size=$(stat -Lc '%s' -- "$state_file") || return 1 + (( file_size == ${#records[0]} + 1 )) || return 1 + state_file_is_private "$state_file" "$legacy" || return 1 + printf '%s\n' "${records[0]}" +} + +atomic_write_state() { + local state_file="$1" + local record="$2" + local temporary="" + + [[ $record != *$'\n'* ]] || return 1 + temporary=$(mktemp "$state_dir/.${state_file##*/}.XXXXXXXX") || return 1 + chmod 600 "$temporary" || { + rm -f -- "$temporary" + return 1 + } + if ! printf '%s\n' "$record" >"$temporary" || ! state_file_is_private "$temporary"; then + rm -f -- "$temporary" + return 1 + fi + if [[ -e $state_file || -L $state_file ]]; then + state_file_is_private "$state_file" || { + rm -f -- "$temporary" + return 1 + } + fi + mv -fT -- "$temporary" "$state_file" || { + rm -f -- "$temporary" + return 1 + } + state_file_is_private "$state_file" +} + +rollback_pending_launch() { + local control_fd="" + local inhibit_record="" + local inhibit_pid="" + local recorded_start_time="" + local recorded_owner="" + local token="" + + (( launch_pending == 1 )) || return 0 + + if [[ -e $launch_control_file || -L $launch_control_file ]]; then + state_file_is_private "$launch_control_file" || return 1 + exec {control_fd}<>"$launch_control_file" || return 1 + /usr/bin/flock -x "$control_fd" || { + exec {control_fd}>&- + return 1 + } + : >"/proc/self/fd/$control_fd" + printf 'cancelled %s\n' "$launch_token" >&"$control_fd" + rm -f -- "$launch_control_file" + /usr/bin/flock -u "$control_fd" + exec {control_fd}>&- + fi + + inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true) + if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($launch_token)$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + recorded_start_time="${BASH_REMATCH[2]}" + recorded_owner="${BASH_REMATCH[3]}" + token="${BASH_REMATCH[4]}" + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + discard_launched_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" + fi + [[ $(read_state_record "$inhibit_pid_file" 2>/dev/null || true) != "$inhibit_record" ]] || + rm -f -- "$inhibit_pid_file" + fi + + # A signal may interrupt either parent-side atomic write before its rename. + rm -f -- "$state_dir"/.launch-control.* "$state_dir"/.idle-owner.* + stop_locked || return 1 + launch_pending=0 +} + +cleanup_pending_launch() { + local status=$? + + trap - EXIT HUP INT TERM + if ! rollback_pending_launch; then + echo "Failed to roll back the pending Omarchy update sleep inhibitor." >&2 + (( status != 0 )) || status=1 + fi + omarchy_security_exit_with_revoked_sudo "$status" \ + "Failed to invalidate sudo credentials after the update sleep inhibitor." +} process_start_time() { local process_pid="$1" @@ -36,9 +237,67 @@ process_start_time() { process_stat="${process_stat##*) }" read -r -a stat_fields <<<"$process_stat" (( ${#stat_fields[@]} > 19 )) || return 1 + [[ ${stat_fields[19]} =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "${stat_fields[19]}" } +process_owner() { + local process_pid="$1" + local token="$2" + local owner="" + + if [[ -n $token ]]; then + owner=$(stat -Lc '%u' -- "/proc/$process_pid") || return 1 + else + # Legacy state can name sudo: its real UID remains the invoking account, + # even when /proc ownership reflects its effective root credentials. + owner=$(awk '/^Uid:/ { print $2; exit }' "/proc/$process_pid/status") || return 1 + fi + [[ $owner =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$owner" +} + +process_has_token() { + local process_pid="$1" + local token="$2" + local argument="" + local expected="--why=Omarchy update in progress [$token]" + + [[ -r /proc/$process_pid/cmdline ]] || return 1 + while IFS= read -r -d '' argument; do + [[ $argument == "$expected" ]] && return 0 + done <"/proc/$process_pid/cmdline" + return 1 +} + +process_identity() { + local process_pid="$1" + local token="$2" + local start_before="" + local start_after="" + local owner_before="" + local owner_after="" + + start_before=$(process_start_time "$process_pid") || return 1 + owner_before=$(process_owner "$process_pid" "$token") || return 1 + [[ -z $token ]] || process_has_token "$process_pid" "$token" || return 1 + start_after=$(process_start_time "$process_pid") || return 1 + owner_after=$(process_owner "$process_pid" "$token") || return 1 + [[ $start_before == "$start_after" && $owner_before == "$owner_after" ]] || return 1 + printf '%s %s\n' "$start_before" "$owner_before" +} + +process_matches() { + local process_pid="$1" + local expected_start="$2" + local expected_owner="$3" + local token="$4" + local identity="" + + identity=$(process_identity "$process_pid" "$token" 2>/dev/null) || return 1 + [[ $identity == "$expected_start $expected_owner" ]] +} + process_state() { local process_pid="$1" local process_stat="" @@ -49,126 +308,276 @@ process_state() { printf '%s\n' "${process_stat%% *}" } -stop() { - local inhibit_pid="" - local recorded_start_time="" - local current_start_time="" - local idle_owner="" - local current_idle_owner="" +discard_launched_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" - if [[ -s $idle_owner_file ]]; then - idle_owner=$(<"$idle_owner_file") - if [[ -f $stay_awake_state ]]; then - current_idle_owner=$(<"$stay_awake_state") - fi - if [[ -n $idle_owner && $current_idle_owner == "$idle_owner" ]]; then - omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true - fi - rm -f "$idle_owner_file" + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true + for (( attempt = 0; attempt < 25; attempt++ )); do + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || break + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && break + sleep 0.02 + done + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" && + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" KILL || true fi - if [[ -s $inhibit_pid_file ]]; then - read -r inhibit_pid recorded_start_time <"$inhibit_pid_file" || true - if [[ $inhibit_pid =~ ^[0-9]+$ ]]; then - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) + if [[ ! -e /proc/$inhibit_pid ]] || [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]]; then + wait "$inhibit_pid" 2>/dev/null || true + fi +} + +signal_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" + local signal="$5" + + [[ $recorded_owner == "$caller_uid" ]] || return 1 + [[ $signal == "TERM" || $signal == "KILL" ]] || return 1 + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 1 + builtin kill -s "$signal" -- "$inhibit_pid" 2>/dev/null +} + +terminate_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" + + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true + + for (( attempt = 0; attempt < 50; attempt++ )); do + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0 + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || return 0 + sleep 0.02 + done + + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0 + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && return 0 + return 1 +} + +stop_locked() { + local inhibit_record="" + local inhibit_pid="" + local recorded_start_time="" + local recorded_owner="" + local token="" + local idle_owner="" + local current_idle_owner="" + local current_start="" + local failed=0 + local remove_inhibit_state=1 + + if [[ ! -e $state_dir && ! -L $state_dir ]]; then + return 0 + fi + if ! ensure_private_directory "$state_dir"; then + fail_state_boundary + return 1 + fi + + if [[ -e $idle_owner_file || -L $idle_owner_file ]]; then + idle_owner=$(read_state_record "$idle_owner_file" 1 2>/dev/null || true) + if [[ $idle_owner =~ ^[0-9]+:[0-9]+:[0-9]+$ ]]; then + if [[ -f $stay_awake_state ]]; then + current_idle_owner=$(<"$stay_awake_state") + fi + if [[ $current_idle_owner == "$idle_owner" ]]; then + omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true + fi + else + echo "Ignoring unsafe Omarchy update idle ownership state." >&2 + failed=1 fi - if [[ -n $recorded_start_time && $current_start_time == "$recorded_start_time" ]]; then - kill "$inhibit_pid" >/dev/null 2>&1 || true + rm -f -- "$idle_owner_file" + fi - for (( attempt = 0; attempt < 50; attempt++ )); do - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) - [[ $current_start_time == "$recorded_start_time" ]] || break - [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || break - sleep 0.02 - done - - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) - if [[ $current_start_time == "$recorded_start_time" ]] && - [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then - echo "Failed to stop the Omarchy update sleep inhibitor." >&2 - return 1 + if [[ -e $inhibit_pid_file || -L $inhibit_pid_file ]]; then + inhibit_record=$(read_state_record "$inhibit_pid_file" 1 2>/dev/null || true) + if state_file_is_private "$inhibit_pid_file" && + [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ([0-9a-f]{32})$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + recorded_start_time="${BASH_REMATCH[2]}" + recorded_owner="${BASH_REMATCH[3]}" + token="${BASH_REMATCH[4]}" + elif [[ $inhibit_record =~ ^([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})$ ]]; then + # The first update starts the old helper and installs this one before + # cleanup. Its protected same-account record predates launch tokens. + inhibit_pid="${BASH_REMATCH[1]}" + recorded_start_time="${BASH_REMATCH[2]}" + recorded_owner="$caller_uid" + if [[ -e /proc/$inhibit_pid && $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] && + ! process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" ""; then + current_start=$(process_start_time "$inhibit_pid" 2>/dev/null || true) + if [[ -z $current_start || $current_start == "$recorded_start_time" ]]; then + echo "Cannot verify permission to stop the legacy update inhibitor; retained its state for recovery." >&2 + failed=1 + remove_inhibit_state=0 + fi + fi + else + echo "Ignoring unsafe Omarchy update sleep inhibitor state." >&2 + failed=1 + fi + if [[ -n $inhibit_pid ]] && (( remove_inhibit_state == 1 )); then + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + if ! terminate_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + echo "Failed to stop the Omarchy update sleep inhibitor." >&2 + failed=1 + remove_inhibit_state=0 + fi fi fi - rm -f "$inhibit_pid_file" + (( remove_inhibit_state == 0 )) || rm -f -- "$inhibit_pid_file" fi rmdir "$state_dir" 2>/dev/null || true + (( failed == 0 )) } -start() { +start_locked() { local idle_owner="$$:$RANDOM:$RANDOM" + local token="" local launcher_pid="" + local inhibit_record="" + local inhibit_pid="" + local inhibit_start_time="" + local inhibit_owner="" + local readiness_attempts=0 - stop - mkdir -p "$state_dir" + stop_locked || return 1 + ensure_private_directory "$state_dir" || fail_state_boundary - # sudo authenticates in the foreground, then backgrounds the inhibitor. - # The held command drops back to this user before publishing its PID, so stop - # can release the inhibitor without another privileged operation or ticket. + token=$(LC_ALL=C /usr/bin/od -An -N16 -tx1 /dev/urandom | /usr/bin/tr -d ' \n') + [[ $token =~ ^[0-9a-f]{32}$ ]] || return 1 + launch_token="$token" + launch_pending=1 + trap cleanup_pending_launch EXIT + atomic_write_state "$launch_control_file" "active $token" || return 1 + + # The child is identifiable before it publishes state, including before exec. + # exec -a retains that identity without keeping an extra shell alive. local hold_command=( /usr/bin/systemd-inhibit --what=sleep:idle --who=omarchy-update - --why="Omarchy update in progress" --mode=block - /usr/bin/setpriv --reuid "$UID" --regid "$(id -g)" --clear-groups + --why="Omarchy update in progress [$token]" --mode=block + /usr/bin/setpriv --reuid "$caller_uid" --regid "$(/usr/bin/id -g)" --clear-groups /usr/bin/bash -p -c ' + set -e + umask 077 + state_dir=$1 + token=$2 + owner=$3 + control=$4 + expected="--why=Omarchy update in progress [$token]" + temporary="" + cleanup() { [[ -z $temporary ]] || /usr/bin/rm -f -- "$temporary"; } + trap cleanup EXIT read -r process_stat <"/proc/$$/stat" process_stat=${process_stat##*) } - read -r -a fields <<< "$process_stat" - printf "%s %s\n" "$$" "${fields[19]}" >"$1" - exec /usr/bin/sleep infinity - ' omarchy-update-inhibitor "$inhibit_pid_file" + read -r -a fields <<<"$process_stat" + temporary=$(/usr/bin/mktemp "$state_dir/.inhibit-pid.XXXXXXXX") + /usr/bin/chmod 600 "$temporary" + printf "1 %s %s %s %s\n" "$$" "${fields[19]}" "$owner" "$token" >"$temporary" + exec {control_fd}<"$control" + /usr/bin/flock -x "$control_fd" + IFS= read -r control_record <&"$control_fd" + [[ $control_record == "active $token" ]] + /usr/bin/mv -fT -- "$temporary" "$state_dir/inhibit-pid" + temporary="" + /usr/bin/flock -u "$control_fd" + exec {control_fd}>&- + trap - EXIT + exec -a "$expected" /usr/bin/sleep infinity + ' "--why=Omarchy update in progress [$token]" "$state_dir" "$token" "$caller_uid" "$launch_control_file" ) if (( EUID == 0 )); then - ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- - exec "${hold_command[@]}" ) & + ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec "${hold_command[@]}" + ) & launcher_pid=$! elif [[ -t 0 ]]; then - /usr/bin/sudo -N -b -- "${hold_command[@]}" + if ! ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec /usr/bin/sudo -N -b -- "${hold_command[@]}" + ); then + return 1 + fi else - ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- - exec pkexec "${hold_command[@]}" ) & + ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec /usr/bin/pkexec "${hold_command[@]}" + ) & launcher_pid=$! fi - # For graphical authentication the launcher may wait for a password. Wait for - # either the user-owned held command to become ready or the launcher to fail. - # sudo -b backgrounds internally, so Bash has no launcher PID in the TTY path; - # $! there can still refer to a completed startup process substitution. - local readiness_attempts=0 - while [[ ! -s $inhibit_pid_file ]]; do + while :; do + inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true) + if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($token)$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + inhibit_start_time="${BASH_REMATCH[2]}" + inhibit_owner="${BASH_REMATCH[3]}" + process_matches "$inhibit_pid" "$inhibit_start_time" "$inhibit_owner" "$token" && break + fi if [[ -n $launcher_pid ]] && ! kill -0 "$launcher_pid" 2>/dev/null; then wait "$launcher_pid" || return 1 echo "The update sleep inhibitor did not start." >&2 return 1 fi - readiness_attempts=$((readiness_attempts + 1)) + (( readiness_attempts += 1 )) if [[ -t 0 ]] && (( EUID != 0 && readiness_attempts >= 100 )); then echo "The update sleep inhibitor did not become ready." >&2 return 1 fi - sleep 0.05 + /usr/bin/sleep 0.05 done + if [[ -e $launch_control_file || -L $launch_control_file ]]; then + state_file_is_private "$launch_control_file" || return 1 + rm -f -- "$launch_control_file" + fi if [[ ! -f $stay_awake_state ]]; then - printf '%s\n' "$idle_owner" >"$idle_owner_file" - mkdir -p "$(dirname "$stay_awake_state")" - if omarchy-toggle-idle stay-awake >/dev/null 2>&1; then - printf '%s\n' "$idle_owner" >"$stay_awake_state" - else - rm -f "$idle_owner_file" + atomic_write_state "$idle_owner_file" "$idle_owner" || return 1 + mkdir -p "$(dirname "$stay_awake_state")" || return 1 + # The idle toggle uses this marker. Publish its owner in the same write so + # cancellation cannot leave an unowned Stay Awake setting behind. + if ! printf '%s\n' "$idle_owner" >"$stay_awake_state"; then + omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true + return 1 fi fi + launch_pending=0 + omarchy_security_install_sudo_cleanup_traps } case "${1:-}" in - start) - start - ;; - stop) - stop - ;; + start | stop) ;; *) echo "Usage: omarchy-update-stay-awake " >&2 exit 2 ;; esac + +initialize_state_boundary +exec {state_lock_fd}<"$state_base" || fail_state_boundary +flock -x "$state_lock_fd" || fail_state_boundary +directory_is_private "$state_base" "$caller_uid" || fail_state_boundary + +case "$1" in + start) + start_locked + ;; + stop) + stop_locked + ;; +esac diff --git a/docs/update-process.md b/docs/update-process.md index f96b28c4..d486c4c9 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -22,6 +22,7 @@ The design goal is: | Path | Owner | Purpose | | --- | --- | --- | | `${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.lock` | user | Prevent overlapping update runs. Owned by `omarchy-update-lock`; compatibility wrappers inherit/respect it. | +| `${XDG_RUNTIME_DIR}/omarchy-update-stay-awake/` | user | Private mode-0700 inhibitor coordination state. If no runtime directory is available, the helper uses the validated mode-0700 `/tmp/omarchy-$UID/` fallback. | | `/tmp/omarchy-update.log` | user | Transcript of `omarchy update`, used by `omarchy-update-analyze-logs`. | | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | @@ -154,6 +155,7 @@ Important behavior: - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. - This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. +- Sleep inhibition authenticates before detaching, drops the held command back to the caller, and closes both update lock descriptors before the persistent process starts. Cleanup accepts only caller-owned, mode-0600, single-link state and revalidates the recorded PID, process start time, owner, and random token immediately before every signal. - Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and checks after each package transaction that the wrapper still exists before any further privileged step, since a transaction can replace the running tree with a release that predates it; when it is gone, or the destination otherwise lacks it, the switch stops after the package switch with instructions to run that release's update from a fresh session rather than letting a bare `sudo` or an updater that authenticates without `--no-update` publish a timestamp. Failed and interrupted channel switches revoke on exit. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index 43b0a66f..31f9759d 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -21,7 +21,7 @@ source, target, name = map(Path,sys.argv[1:]) p=target/name p.parent.mkdir(parents=True,exist_ok=True) s=(source/name).read_text().replace('$HOME', '$SUDO_TEST_HOME') -for command in ['sudo','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']: +for command in ['sudo','pkexec','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']: s=s.replace('/usr/bin/'+command, str(target/'mock'/command)) s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"') p.write_text(s) diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index f8f3bc90..72b2bdb1 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -8,12 +8,21 @@ source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" test_tmp="$boundary_tmp" stub_bin="$SUDO_TEST_ROOT/bin" test_home="$SUDO_TEST_HOME" -runtime_dir="$test_tmp/runtime" -mkdir -p "$runtime_dir" +runtime_dir="/run/user/$(id -u)" +test_run_id="test-$BASHPID-$RANDOM" +update_lock_name="omarchy-update-$test_run_id.lock" +stay_awake_dir_name="omarchy-update-stay-awake-$test_run_id" +trap 'rm -rf -- "$runtime_dir/$stay_awake_dir_name"; rm -f -- "$runtime_dir/$update_lock_name"; rm -rf -- "$boundary_tmp"' EXIT for command in omarchy-update omarchy-update-lock omarchy-update-stay-awake; do rm -f "$SUDO_TEST_ROOT/bin/$command" copy_boundary_file "bin/$command" done +sed -i \ + -e "s/omarchy-update\.lock/$update_lock_name/g" \ + -e "s#state_dir=\"\$state_base/omarchy-update-stay-awake\"#state_dir=\"\$state_base/$stay_awake_dir_name\"#" \ + "$SUDO_TEST_ROOT/bin/omarchy-update" \ + "$SUDO_TEST_ROOT/bin/omarchy-update-lock" \ + "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" cat >"$SUDO_TEST_ROOT/mock/setpriv" <<'STUB' #!/bin/bash while [[ ${1:-} == --* ]]; do @@ -67,6 +76,7 @@ for command in \ done write_stub omarchy-update-available 'exit 1' write_stub pkexec 'exec "$@"' +ln -s ../bin/pkexec "$SUDO_TEST_ROOT/mock/pkexec" write_stub systemd-inhibit 'while [[ $1 == --* ]]; do shift; done; exec "$@"' ln -s ../bin/systemd-inhibit "$SUDO_TEST_ROOT/mock/systemd-inhibit" @@ -119,7 +129,7 @@ done inhibitor_pid=$(<"$inhibit_pid_file") kill -0 "$inhibitor_pid" 2>/dev/null || fail "sleep inhibitor is still running when its descriptors are inspected" -lock_target=$(readlink -f "$runtime_dir/omarchy-update.lock") +lock_target=$(readlink -f "$runtime_dir/$update_lock_name") inhibitor_holds_lock=0 for fd in /proc/"$inhibitor_pid"/fd/*; do [[ -e $fd ]] || continue @@ -150,10 +160,11 @@ if (( EUID != 0 )); then #!/bin/bash set -euo pipefail omarchy-update-stay-awake start -[[ -s $XDG_RUNTIME_DIR/omarchy-update-stay-awake/inhibit-pid ]] +[[ -s $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]] omarchy-update-stay-awake stop -[[ ! -e $XDG_RUNTIME_DIR/omarchy-update-stay-awake/inhibit-pid ]] +[[ ! -e $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]] SH + sed -i "s/REPLACE_STAY_AWAKE_DIR/$stay_awake_dir_name/g" "$terminal_driver" chmod +x "$terminal_driver" SUDO_LOG="$sudo_log" PKEXEC_MARKER="$pkexec_marker" INHIBIT_PID_FILE="$terminal_inhibit_pid_file" \ @@ -163,6 +174,76 @@ SH [[ ! -e $pkexec_marker ]] || fail "terminal sleep inhibition does not use pkexec" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop pass "terminal updates use sudo instead of Polkit for sleep inhibition" + + wait_for_process_exit() { + local process_pid="$1" + + for _ in {1..100}; do + kill -0 "$process_pid" 2>/dev/null || return 0 + [[ $(awk '{ print $3 }' "/proc/$process_pid/stat" 2>/dev/null || true) == "Z" ]] && return 0 + sleep 0.02 + done + return 1 + } + + delayed_marker="$test_tmp/delayed-inhibitor" + delayed_helper_pid_file="$test_tmp/delayed-helper-pid" + write_stub systemd-inhibit 'echo "$$" >"$DELAYED_MARKER"; sleep 0.4; while [[ $1 == --* ]]; do shift; done; exec "$@"' + + # Keep the start helper's stdin attached to the private PTY so it takes the + # sudo -b branch, then signal only that helper before the held child publishes. + delayed_terminal_driver="$test_tmp/delayed-terminal-stay-awake" + cat >"$delayed_terminal_driver" <<'SH' +#!/bin/bash +set +e +omarchy-update-stay-awake start "$DELAYED_HELPER_PID_FILE" +wait "$helper_pid" +exit $? +SH + chmod +x "$delayed_terminal_driver" + DELAYED_MARKER="$delayed_marker" DELAYED_HELPER_PID_FILE="$delayed_helper_pid_file" \ + run_with_lock_env script -qefc "$delayed_terminal_driver" /dev/null >"$test_tmp/delayed-terminal.out" 2>&1 & + delayed_terminal_driver_pid=$! + for _ in {1..100}; do + [[ -s $delayed_marker && -s $delayed_helper_pid_file ]] && break + sleep 0.02 + done + [[ -s $delayed_marker && -s $delayed_helper_pid_file ]] || fail "terminal cancellation reaches the delayed launch window" + kill -TERM "$(<"$delayed_helper_pid_file")" + wait "$delayed_terminal_driver_pid" || true + delayed_inhibitor_pid=$(<"$delayed_marker") + wait_for_process_exit "$delayed_inhibitor_pid" || fail "terminal cancellation leaves no delayed inhibitor" + [[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "terminal cancellation leaves no launch state" + pass "terminal cancellation rolls back delayed publication" + + # With redirected stdin the same helper takes the graphical pkexec branch. + : >"$delayed_marker" + delayed_graphical_helper_pid_file="$test_tmp/delayed-graphical-helper-pid" + delayed_graphical_driver="$test_tmp/delayed-graphical-stay-awake" + cat >"$delayed_graphical_driver" <<'SH' +#!/bin/bash +echo "$$" >"$DELAYED_HELPER_PID_FILE" +exec omarchy-update-stay-awake start "$test_tmp/delayed-graphical.out" 2>&1 & + delayed_graphical_driver_pid=$! + for _ in {1..100}; do + [[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] && break + sleep 0.02 + done + [[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] || fail "graphical cancellation reaches the delayed launch window" + delayed_graphical_helper_pid=$(<"$delayed_graphical_helper_pid_file") + kill -TERM "$delayed_graphical_helper_pid" + wait "$delayed_graphical_driver_pid" || true + delayed_inhibitor_pid=$(<"$delayed_marker") + wait_for_process_exit "$delayed_inhibitor_pid" || fail "graphical cancellation leaves no delayed inhibitor" + [[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "graphical cancellation leaves no launch state" + pass "graphical cancellation rolls back delayed publication" + write_stub systemd-inhibit 'while [[ $1 == --* ]]; do shift; done; exec "$@"' fi # Update-owned Stay Awake state must be cleared before the restart helper can @@ -198,12 +279,45 @@ OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$SUDO_TEST_ROOT/b [[ -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update preserves pre-existing Stay Awake state" pass "omarchy-update restores only its own Stay Awake state before restart handling" +# Model package replacement while the existing updater is still running: +# start writes the old two-field state; the transaction installs the real new +# helper, whose stop must clean that state before reboot handling. +cp "$stub_bin/omarchy-update-stay-awake" "$test_tmp/inhibitor-after-upgrade" +write_stub omarchy-update-stay-awake ' +set -e +[[ $1 == "start" ]] +umask 022 +state="$XDG_RUNTIME_DIR/$LEGACY_STATE_NAME" +mkdir -p "$state" "$SUDO_TEST_HOME/.local/state/omarchy/indicators" +( exec {OMARCHY_UPDATE_LOCK_FD}>&-; exec sleep infinity ) & +pid=$! +printf "%s %s\n" "$pid" "$(awk '\''{ print $22 }'\'' /proc/$pid/stat)" >"$state/inhibit-pid" +printf "%s:1:1\n" "$$" >"$state/idle-owner" +/usr/bin/cp "$state/idle-owner" "$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake"' +write_stub omarchy-update-system-pkgs ' +/usr/bin/cp "$INHIBITOR_AFTER_UPGRADE" "$OMARCHY_PATH/bin/omarchy-update-stay-awake"' +write_stub omarchy-update-restart ' +if [[ $1 == "--reboot-only" ]]; then + [[ ! -e $SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake ]] || exit 91 + [[ ! -e $XDG_RUNTIME_DIR/$LEGACY_STATE_NAME ]] || exit 92 + touch "$UPGRADE_RESTARTED" +fi' +rm -f "$test_home/.local/state/omarchy/indicators/stay-awake" +OMARCHY_UPDATE_LOGGED=1 LEGACY_STATE_NAME="$stay_awake_dir_name" \ + INHIBITOR_AFTER_UPGRADE="$test_tmp/inhibitor-after-upgrade" \ + UPGRADE_RESTARTED="$test_tmp/upgrade-restarted" \ + run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y +[[ -e $test_tmp/upgrade-restarted ]] || fail "first upgrade did not reach reboot handling" +pass "first upgrade cleans old inhibitor state with the newly installed helper" + # Stale cleanup state from a killed update must not override a Stay Awake choice # the user made afterward. -stay_awake_helper_state="$runtime_dir/omarchy-update-stay-awake" +stay_awake_helper_state="$runtime_dir/$stay_awake_dir_name" stay_awake_state="$test_home/.local/state/omarchy/indicators/stay-awake" -mkdir -p "$stay_awake_helper_state" "$(dirname "$stay_awake_state")" -printf '%s\n' "old-update-owner" >"$stay_awake_helper_state/idle-owner" +mkdir -m 700 -p "$stay_awake_helper_state" +mkdir -p "$(dirname "$stay_awake_state")" +printf '%s\n' "123:456:789" >"$stay_awake_helper_state/idle-owner" +chmod 600 "$stay_awake_helper_state/idle-owner" printf '%s\n' "user-choice" >"$stay_awake_state" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop @@ -215,8 +329,9 @@ pass "stale update ownership preserves a newer Stay Awake choice" sleep 30 >/dev/null & unrelated_pid=$! unrelated_start_time=$(awk '{ print $22 }' "/proc/$unrelated_pid/stat") -mkdir -p "$stay_awake_helper_state" -printf '%s %s\n' "$unrelated_pid" "$((unrelated_start_time + 1))" >"$stay_awake_helper_state/inhibit-pid" +mkdir -m 700 -p "$stay_awake_helper_state" +printf '1 %s %s %s %032x\n' "$unrelated_pid" "$((unrelated_start_time + 1))" "$(id -u)" 1 >"$stay_awake_helper_state/inhibit-pid" +chmod 600 "$stay_awake_helper_state/inhibit-pid" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop kill -0 "$unrelated_pid" 2>/dev/null || diff --git a/test/shell.d/update-stay-awake-security-test.sh b/test/shell.d/update-stay-awake-security-test.sh new file mode 100644 index 00000000..08000e85 --- /dev/null +++ b/test/shell.d/update-stay-awake-security-test.sh @@ -0,0 +1,626 @@ +#!/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 +exec "$@" +SH + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +case ${1:-} in + -h) echo 'usage: sudo [-bHkNnPS] command'; exit 0 ;; + -k|-K|-v) exit 0 ;; +esac +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" + +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