Merge pull request #9467 from AFOliveira/codex/om-sec-12-update-inhibitor-identity

[codex] OM-SEC-12: Bind update inhibitor cleanup to process identity

Reported-by: Afonso "AFOliveira" Oliveira
This commit is contained in:
Erik Melton authored and GitHub committed 2026-09-25 15:56:39 +02:00
commit d201fb9564
38 files changed
+3548 -422

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience:
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument.
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
# Command Naming
+52 -5
View File
@@ -1,10 +1,25 @@
#!/bin/bash
#!/bin/bash -p
# omarchy:summary=Set the Omarchy package channel.
# omarchy:args=<stable|rc|edge|dev>
# omarchy:requires-sudo=true
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup for channel switching." >&2
exit 126
fi
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
omarchy_security_require_privileged_bash_startup || exit 126
set -euo pipefail
omarchy_security_sanitize_bash_environment "$0" "$@"
omarchy_security_require_source_root "$0"
user_path=$PATH
# Traps first, so a signal or failure during the entry revocation still
# exits through the cleanup path.
omarchy_security_install_sudo_cleanup_traps
omarchy_security_revoke_sudo_timestamp || exit 1
omarchy_security_enable_no_update_sudo
usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; }
fail() { echo "Error: $*" >&2; exit 1; }
@@ -33,12 +48,40 @@ validate_dev_checkout() {
}
link_dev_checkout() {
local checkout="$1"
local checkout="$1" required
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
# Check the destination before changing /etc/omarchy.conf or sudo's path.
# An existing checkout is not pulled automatically and may predate this policy.
for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then
fail "Update the checkout before switching to dev; missing required update support in $required."
fi
done
omarchy-dev-link "$checkout" --no-reboot
}
# A packaged destination cannot be inspected before its package is installed,
# and a package transaction can replace the running tree with a release that
# predates the command-scoped wrapper. After that, a bare sudo would resolve to
# /usr/bin/sudo and publish a timestamp, and the destination's own updater
# authenticates the same way. Neither may run from a flow that has just run
# user hooks: stop at a consistent point and say how to finish from a fresh
# session.
stop_for_older_destination() {
cat >&2 <<EOF
The destination predates command-scoped sudo, so this switch stops before its update.
Packages are switched. Run 'omarchy update' from a new terminal to finish, then reboot if prompted.
EOF
exit 3
}
wrapper_present() {
[[ -f $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo && -x $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo ]]
}
(( $# > 0 )) || { usage; exit 1; }
dev_checkout=""
@@ -83,13 +126,16 @@ fi
if [[ -n $dev_checkout ]]; then
link_dev_checkout "$dev_checkout"
export OMARCHY_PATH="$dev_checkout"
export PATH="$OMARCHY_PATH/bin:$PATH"
omarchy_security_enable_no_update_sudo
omarchy-state set reboot-required
fi
omarchy-refresh-pacman "$pacman_channel"
OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-refresh-pacman "$pacman_channel"
# Each transaction can have replaced this tree; check before the next sudo.
wrapper_present || stop_for_older_destination
# --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites.
omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
wrapper_present || stop_for_older_destination
if [[ -z $dev_checkout ]]; then
omarchy-dev-unlink --no-reboot
@@ -98,6 +144,7 @@ if [[ -z $dev_checkout ]]; then
if (( leaving_dev )); then
omarchy-state set reboot-required
fi
omarchy_security_enable_no_update_sudo 2>/dev/null || stop_for_older_destination
fi
omarchy-update -y
OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y
+37 -13
View File
@@ -1,26 +1,50 @@
#!/bin/bash
#!/bin/bash -p
# omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages.
# omarchy:requires-sudo=true
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
channel="${1:-stable}"
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
echo "Error: Invalid channel '$channel'. Must be one of: stable, rc, edge"
exit 1
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup." >&2
exit 126
fi
echo "Setting channel to $channel"
echo
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
omarchy_security_require_privileged_bash_startup || exit 126
set -e
omarchy_security_sanitize_bash_environment "$0" "$@"
omarchy_security_require_source_root "$0"
# Channel switching calls this behind its own sanitized PATH and hands the
# caller's original path over the same way the updater receives it.
user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH}
unset OMARCHY_UPDATE_USER_PATH
# Traps first, so a signal or failure during the entry revocation still
# exits through the cleanup path.
omarchy_security_install_sudo_cleanup_traps
omarchy_security_revoke_sudo_timestamp || exit 1
omarchy_security_enable_no_update_sudo
channel="${1:-stable}"
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
echo "Invalid channel: $channel" >&2
exit 2
fi
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
echo "Setting channel to $channel"
sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf
sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist
# Allow user customization of /etc/pacman.conf before the upgrade runs
omarchy-hook pre-refresh-pacman
# Allow user customization of /etc/pacman.conf before the upgrade runs, so
# custom repositories and IgnorePkg entries shape the downgrade-capable
# transaction below. The hook is user code: it runs cold behind the no-update
# wrapper, and the timestamp is revoked again afterwards so any authorization
# the hook obtained for itself cannot carry into the transaction.
omarchy_security_revoke_sudo_timestamp
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \
"$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman
omarchy_security_revoke_sudo_timestamp
# Reset all package DBs and then update
omarchy-update-pacman -Syyuu --noconfirm
+1 -1
View File
@@ -220,7 +220,7 @@ fi
# never owned, a yes takes that with it, and saying so is the prompt's job.
# Without a terminal to ask in, keeping everything is the answer.
data_removed=false
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cmd-present gum; then
if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then
# du answers non-zero when either directory is missing, and pipefail would
# turn that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
+1 -1
View File
@@ -61,7 +61,7 @@ gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
# front of the user with the size rather than left silent. Without a terminal
# to ask in, keeping it is the answer.
state_removed=false
if [[ -d $HOME/.openclaw && -t 0 ]] && omarchy-cmd-present gum; then
if [[ -d $HOME/.openclaw && -t 0 ]]; then
size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)
if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then
rm -rf "$HOME/.openclaw"
+56 -16
View File
@@ -61,6 +61,21 @@ relock_session() {
return 1
}
# A short timeout keeps an unresponsive user bus from stalling the restart;
# busctl would otherwise wait 25 seconds per probe.
notifications_ready() {
[[ $(busctl --user --timeout=1s call org.freedesktop.DBus /org/freedesktop/DBus \
org.freedesktop.DBus NameHasOwner s org.freedesktop.Notifications 2>/dev/null) == "b true" ]]
}
# Core IPC can answer before the notification plugin has registered its bus
# name. Restore an existing notification service before update hooks or setup
# invitations send their one-time toasts; a disabled service need not appear.
notifications_were_running=0
if notifications_ready; then
notifications_were_running=1
fi
# Each kill stops the oldest matching instance and only returns once it has
# fully exited, so the no-duplicate launch below can't race a dying shell.
while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done
@@ -69,25 +84,50 @@ while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1;
# not transient variables from a terminal, SSH connection, or development tool.
hyprctl dispatch 'hl.dsp.exec_cmd("omarchy-launch-shell")' >/dev/null
shell_ready=0
for (( attempt = 0; attempt < 20; attempt++ )); do
if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1; then
# The session stays compositor-locked after the old lock client died, so
# re-acquire the lock and let the user authenticate out of it.
if (( relock )) && ! relock_session; then
echo "Omarchy shell restarted, but the session lock was not re-secured." >&2
exit 1
fi
# Invitation toasts (like Voxtype/fingerprint setup) die with the old
# shell, and their notify-send waiters hang forever: the dying server
# never emits NotificationClosed. A still-running omarchy-*-invitation
# unit is therefore an unanswered invitation — re-run it so its toast
# reappears on the new shell. Answered invitations have already exited
# and been collected, so the glob no longer matches them.
systemctl --user try-restart 'omarchy-*-invitation.service' 2>/dev/null || true
exit 0
shell_ready=1
break
fi
sleep 0.1
done
if (( shell_ready == 0 )); then
echo "Omarchy shell did not become ready after restart." >&2
exit 1
fi
echo "Omarchy shell did not become ready after restart." >&2
exit 1
# The session stays compositor-locked after the old lock client died, so
# re-acquire the lock and let the user authenticate out of it. This comes
# first and depends on nothing else: a user stranded behind the failsafe must
# not wait on the notification plugin, which may be slow or absent.
if (( relock )) && ! relock_session; then
echo "Omarchy shell restarted, but the session lock was not re-secured." >&2
exit 1
fi
# Core IPC answers before the notification plugin has registered its bus
# name, so wait for it separately before one-time toasts are sent.
if (( notifications_were_running )); then
notifications_restored=0
for (( attempt = 0; attempt < 20; attempt++ )); do
if notifications_ready; then
notifications_restored=1
break
fi
sleep 0.1
done
if (( notifications_restored == 0 )); then
echo "Omarchy shell restarted, but its notification service did not become ready." >&2
exit 1
fi
fi
# Invitation toasts (like Voxtype/fingerprint setup) die with the old
# shell, and their notify-send waiters hang forever: the dying server
# never emits NotificationClosed. A still-running omarchy-*-invitation
# unit is therefore an unanswered invitation — re-run it so its toast
# reappears on the new shell. Answered invitations have already exited
# and been collected, so the glob no longer matches them.
systemctl --user try-restart 'omarchy-*-invitation.service' 2>/dev/null || true
exit 0
+137
View File
@@ -0,0 +1,137 @@
#!/bin/bash
# omarchy:hidden=true
# omarchy:summary=Provide internal helpers for command-scoped sudo authentication
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
echo "omarchy-security-functions is an internal function library." >&2
exit 64
fi
omarchy_security_require_privileged_bash_startup() {
[[ $- == *p* ]] || return 1
/usr/bin/env -i /usr/bin/bash -p -c '
mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1
executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1
[[ $executable == "/usr/bin/bash" &&
( ${argv[0]:-} == "/bin/bash" || ${argv[0]:-} == "/usr/bin/bash" ) &&
${argv[1]:-} == "-p" ]]
' omarchy-bash-startup "$$"
}
omarchy_security_sanitize_bash_environment() {
local script=$1
shift
local entry name environment_fd environment_pid
local -a unsets=()
# Read the raw environment: privileged Bash ignores exported functions, but
# leaves their records for ordinary child interpreters to import later.
exec {environment_fd}< <(/usr/bin/env -0)
environment_pid=$!
while IFS= read -r -d '' entry <&"$environment_fd"; do
name=${entry%%=*}
case "$name" in
BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%)
unsets+=(-u "$name")
;;
esac
done
exec {environment_fd}<&-
wait "$environment_pid" || return 1
if (( ${#unsets[@]} > 0 )); then
exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@"
fi
}
omarchy_security_require_source_root() {
local command_source command_name=${1##*/}
command_source=$(/usr/bin/readlink -e -- "$1") || return 1
# A runtime root selects the code used by this invocation. Accept the
# canonical checkout containing the entrypoint or the package's bin links.
if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] ||
! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] ||
[[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then
echo "OMARCHY_PATH does not match this Omarchy command." >&2
return 1
fi
}
omarchy_security_sudo_supports_no_update() {
local help
help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1
/usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help"
}
omarchy_security_revoke_sudo_timestamp() {
/usr/bin/sudo -k
}
omarchy_security_exit_with_revoked_sudo() {
local status=$1
local message=${2:-Could not invalidate cached sudo authorization.}
trap - EXIT HUP INT TERM
if ! omarchy_security_revoke_sudo_timestamp; then
echo "$message" >&2
(( status != 0 )) || status=1
fi
exit "$status"
}
omarchy_security_install_signal_exit_traps() {
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
}
omarchy_security_install_sudo_cleanup_traps() {
OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.}
trap omarchy_security_run_sudo_cleanup_trap EXIT
omarchy_security_install_signal_exit_traps
}
omarchy_security_enable_no_update_sudo() {
local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update"
if ! omarchy_security_sudo_supports_no_update; then
echo "This sudo does not support --no-update; refusing mixed-trust work." >&2
return 1
fi
if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then
echo "The command-scoped sudo wrapper is missing." >&2
return 1
fi
PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin"
OMARCHY_SUDO_NO_UPDATE=1
export PATH OMARCHY_SUDO_NO_UPDATE
}
omarchy_security_run_sudo_cleanup_trap() {
local status=$?
omarchy_security_exit_with_revoked_sudo "$status" \
"${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}"
}
omarchy_security_assert_root_directory() {
local path=$1 expected_mode=$2 canonical owner actual_mode
[[ $path == /* && -d $path && ! -L $path ]] || return 1
canonical=$(/usr/bin/realpath -e -- "$path") || return 1
[[ $canonical == "$path" ]] || return 1
read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1
[[ $owner == "0" && $actual_mode == "$expected_mode" ]]
}
omarchy_security_prepare_private_root_directory() {
local path=$1 parent=$2
omarchy_security_assert_root_directory "$parent" 755 || return 1
if [[ -e $path || -L $path ]]; then
omarchy_security_assert_root_directory "$path" 700
else
/usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1
omarchy_security_assert_root_directory "$path" 700
fi
}
+417 -39
View File
@@ -1,70 +1,448 @@
#!/bin/bash
#!/bin/bash -p
# omarchy:summary=Toggle passwordless sudo for the current user.
# omarchy:args=[MINUTES]
# omarchy:requires-sudo=true
NOPASSWD_FILE="/etc/sudoers.d/99-omarchy-nopasswd-${USER}"
TIMER_NAME="omarchy-nopasswd-expire-${USER}"
MINUTES=${1:-15}
if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
exit 1
if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then
echo "Refusing an unsafe Bash startup for passwordless sudo." >&2
exit 126
fi
arm_expiry() {
if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \
rm -f -- "$NOPASSWD_FILE"; then
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
omarchy_security_require_privileged_bash_startup || {
echo "Refusing an unsafe Bash startup for passwordless sudo." >&2
exit 126
}
omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126
fi
set -euo pipefail
readonly DEFAULT_MINUTES=15
readonly MAX_MINUTES=1440
readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock
readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf
readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook
readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing
readonly MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635
readonly QUARANTINE_DIR=/var/lib/omarchy/sudoers-quarantine
readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
readonly STATUS_INACTIVE=3
usage() {
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
echo "MINUTES must be between 1 and $MAX_MINUTES." >&2
exit 1
}
valid_minutes() {
[[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES))
}
valid_uid() {
[[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294))
}
valid_account_name() {
[[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] && (( ${#1} <= 32 ))
}
resolve_account() {
local uid="$1" entry
valid_uid "$uid" || return 1
entry=$(/usr/bin/getent passwd "$((10#$uid))") || return 1
IFS=: read -r ACCOUNT_NAME _ ACCOUNT_UID _ _ _ _ <<<"$entry"
[[ $ACCOUNT_UID == "$((10#$uid))" ]] || return 1
# Sudoers has metacharacters, and it reads an upper-case word such as ALICE
# as an alias reference rather than a user. Accounts use this portable
# lower-case subset; refusing anything else is safer than attempting to
# quote privileged policy syntax.
valid_account_name "$ACCOUNT_NAME" || return 1
ACCOUNT_UID=$((10#$uid))
}
verify_sudo_caller() {
local requested_uid="$1"
((EUID == 0)) || return 1
valid_uid "$requested_uid" || return 1
[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]] || return 1
((10#$SUDO_UID == 10#$requested_uid)) || return 1
resolve_account "$requested_uid"
}
with_root_lock() {
local fd rc=0
# The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy:
# those are exactly the kinds of partial-install state it must fail closed
# through. /run/lock is established by the OS before sysinit services run.
omarchy_security_assert_root_directory /run 755 || return 1
[[ -d /run/lock && ! -L /run/lock ]] || return 1
[[ $(/usr/bin/stat -Lc '%u' /run/lock) == 0 ]] || return 1
! ((8#$(/usr/bin/stat -Lc '%a' /run/lock) & 022)) || return 1
exec {fd}>"$LOCK_FILE" || return 1
/usr/bin/chown root:root "$LOCK_FILE" || return 1
/usr/bin/chmod 0600 "$LOCK_FILE" || return 1
# Grant operations are short. A stalled holder must not hang a caller
# indefinitely, least of all pacman's pre-transaction hook.
/usr/bin/flock -x -w 60 "$fd" || return 1
"$@" || rc=$?
/usr/bin/flock -u "$fd" || rc=1
exec {fd}>&-
return "$rc"
}
rule_file() {
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1"
}
# The sudoers rule is the only grant record. A missing file is distinct from
# an unreadable, unsafe, or administrator-modified file.
read_grant() {
local file contents
file=$(rule_file "$1")
[[ -e $file || -L $file ]] || return "$STATUS_INACTIVE"
verify_root_path "$file" && [[ -f $file ]] || return 2
contents=$(/usr/bin/cat -- "$file") || return 2
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2
GRANT_NAME=${BASH_REMATCH[1]}
GRANT_DEADLINE=${BASH_REMATCH[2]}
valid_account_name "$GRANT_NAME" || return 2
}
# Returns 0 for a rule this command generated, 1 for anything else under the
# owned prefix (preserved as administrator policy), and 2 when unreadable.
classify_generated_rule() {
local file=$1 suffix contents name
[[ -f $file && ! -L $file ]] || return 1
contents=$(/usr/bin/cat -- "$file") || return 2
suffix=${file##*/99-omarchy-nopasswd-}
# The legacy command wrote the caller's unvalidated name into both the
# filename and the rule. That exact relationship is its fingerprint, so an
# account the current policy would reject still has its old grant removed.
if [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then
return 0
fi
echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2
if ! sudo rm -f -- "$NOPASSWD_FILE"; then
echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2
[[ $suffix =~ ^[0-9]+$ ]] || return 1
name=${contents%' ALL=(ALL) NOPASSWD: ALL'}
if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then
return 0
fi
return 1
name=${contents%%' ALL=(ALL) NOTAFTER='*}
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
}
cleanup_uid_locked() {
local file
file=$(rule_file "$1")
[[ -e $file || -L $file ]] || return 0
verify_root_path "$file" && classify_generated_rule "$file" || return 1
/usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]
}
# The generated prefix is reserved: boot cleanup and the package hook already
# remove everything in it, and the legacy writer could produce a rule whose
# body differs from its filename. Nothing unrecognized may stay live there, but
# its content is kept for the administrator instead of being deleted.
quarantine_foreign_rule() {
local file=$1 target
if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then
/usr/bin/install -d -o root -g root -m 0755 -- /var/lib/omarchy || return 1
fi
omarchy_security_prepare_private_root_directory "$QUARANTINE_DIR" /var/lib/omarchy || return 1
# A legacy filename can already be close to NAME_MAX, so the destination
# name is fixed and the original name travels beside it.
target=$(/usr/bin/mktemp -d "$QUARANTINE_DIR/XXXXXXXXXX") || return 1
/usr/bin/printf '%s\n' "${file##*/}" >"$target/name" || return 1
/usr/bin/mv -fT -- "$file" "$target/policy" && [[ ! -e $file && ! -L $file ]] || return 1
echo "Moved unrecognized sudoers policy $file to $target/policy" >&2
}
cleanup_all_locked() {
local file classification failed=0
verify_root_path /etc/sudoers.d || return 1
for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do
[[ -e $file || -L $file ]] || continue
if classify_generated_rule "$file"; then
if ! /usr/bin/rm -f -- "$file" || [[ -e $file || -L $file ]]; then
failed=1
fi
else
classification=$?
if (( classification != 1 )) || ! quarantine_foreign_rule "$file"; then
failed=1
fi
fi
done
return "$failed"
}
verify_root_path() {
local file=$1 owner mode canonical current
[[ ( -f $file || -d $file ) && ! -L $file ]] || return 1
canonical=$(/usr/bin/realpath -e -- "$file") || return 1
[[ $canonical == "$file" ]] || return 1
owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1
mode=$(/usr/bin/stat -Lc '%a' -- "$file") || return 1
[[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1
current=${file%/*}
while :; do
[[ -d $current && ! -L $current ]] || return 1
canonical=$(/usr/bin/realpath -e -- "$current") || return 1
[[ $canonical == "$current" ]] || return 1
read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1
[[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1
[[ $current == / ]] && break
current=${current%/*}
[[ -n $current ]] || current=/
done
}
verify_boot_cleanup() {
local active_rules hook
[[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1
verify_root_path "$BOOT_CLEANUP_FILE" || return 1
active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1
[[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1
verify_root_path "$PACKAGE_HOOK" || return 1
hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1
[[ $hook == '[Trigger]
Operation = Upgrade
Operation = Remove
Type = Package
Target = omarchy-settings
Target = omarchy-settings-dev
[Action]
Description = Revoking temporary Omarchy sudo grants before settings changes...
When = PreTransaction
Exec = /usr/bin/omarchy-sudo-passwordless __package-removing
AbortOnFail' ]]
}
package_removing_locked() {
# ALPM must abort before removing the helper or boot cleanup if revocation
# fails. The marker also blocks publication after this lock is released.
(umask 077; : >"$REMOVAL_BLOCKER") || return 1
/usr/bin/rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-* || return 1
cleanup_all_locked
}
migration_complete() {
[[ -f $MIGRATION_MARKER && ! -s $MIGRATION_MARKER ]] && verify_root_path "$MIGRATION_MARKER"
}
migrate_locked() {
local directory
if migration_complete; then
return 0
fi
[[ ! -e $MIGRATION_MARKER && ! -L $MIGRATION_MARKER ]] || return 1
verify_root_path /var/lib || return 1
for directory in /var/lib/omarchy /var/lib/omarchy/migrations; do
if [[ ! -e $directory && ! -L $directory ]]; then
/usr/bin/install -d -o root -g root -m 0755 -- "$directory" || return 1
fi
verify_root_path "$directory" || return 1
done
cleanup_all_locked || return 1
# The empty marker is written only after cleanup succeeds, under the same
# machine lock. Later accounts need no sudo and cannot revoke newer grants.
/usr/bin/install -o root -g root -m 0644 /dev/null "$MIGRATION_MARKER"
}
# Old callbacks only remove an expired current rule. Renewing a grant never
# needs a second state file or a stored timer generation to identify it.
expire_locked() {
local status now
if read_grant "$1"; then
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
[[ $now < $GRANT_DEADLINE ]] && return 0
cleanup_uid_locked "$1"
else
status=$?
if (( status == STATUS_INACTIVE )); then
return 0
else
cleanup_uid_locked "$1"
fi
fi
}
status_locked() {
local status now
resolve_account "$1" || return 2
if read_grant "$1"; then
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
if [[ $now < $GRANT_DEADLINE ]]; then
return 0
fi
cleanup_uid_locked "$1" || return 2
return "$STATUS_INACTIVE"
else
status=$?
return "$status"
fi
}
finish_enable() {
local status=$?
trap - EXIT HUP INT TERM
if (( status != 0 )); then
if cleanup_uid_locked "$uid"; then
[[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true
else
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
fi
fi
[[ -z $pending ]] || /usr/bin/rm -f -- "$pending"
exit "$status"
}
enable_locked() (
local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status
resolve_account "$uid" && valid_minutes "$minutes" || return 1
verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1
file=$(rule_file "$uid")
if read_grant "$uid"; then
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1
else
status=$?
(( status == STATUS_INACTIVE )) || return 1
fi
trap finish_enable EXIT
omarchy_security_install_signal_exit_traps
now=$(/usr/bin/date +%s) || return 1
expires=$((now + 10#$minutes * 60))
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
/usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1
/usr/sbin/visudo -cf "$pending" >/dev/null || return 1
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
timer="omarchy-nopasswd-expire-$uid-$token"
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
--timer-property=AccuracySec=1s --unit="$timer" \
-- "$INSTALLED_SELF" __expire "$uid" || return 1
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
# The temporary filename contains a dot, so sudo ignores it. Rename within
# sudoers.d publishes the complete validated policy in one operation.
/usr/bin/mv -fT -- "$pending" "$file" || return 1
pending=""
now=$(/usr/bin/date +%s) || return 1
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
)
root_dispatch() {
local action="$1"
shift
case "$action" in
__status)
(($# == 1)) && verify_sudo_caller "$1" || return 2
with_root_lock status_locked "$1"
;;
__enable)
(($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1
with_root_lock enable_locked "$1" "$2"
;;
__disable)
(($# == 1)) && verify_sudo_caller "$1" || return 1
with_root_lock cleanup_uid_locked "$1"
;;
__expire)
(($# == 1 || $# == 2)) && ((EUID == 0)) && valid_uid "$1" || return 1
[[ -z ${2:-} || $2 =~ ^omarchy-nopasswd-expire-${1}-[0-9a-f]{32}$ ]] || return 1
with_root_lock expire_locked "$@"
;;
__migration-complete)
(($# == 0)) && migration_complete
;;
__migrate)
(($# == 0)) && ((EUID == 0)) || return 1
with_root_lock migrate_locked
;;
__cleanup-all)
(($# == 0)) && ((EUID == 0)) || return 1
with_root_lock cleanup_all_locked
;;
__package-removing)
(($# == 0)) && ((EUID == 0)) || return 1
with_root_lock package_removing_locked
;;
*) return 1 ;;
esac
}
case "${1:-}" in
__status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete)
action=$1
shift
root_dispatch "$action" "$@"
exit
;;
esac
(($# <= 1)) || usage
minutes=${1:-$DEFAULT_MINUTES}
valid_minutes "$minutes" || usage
uid=$(/usr/bin/id -u)
valid_uid "$uid" || {
echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2
exit 1
}
omarchy_security_sudo_supports_no_update || {
echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2
exit 1
}
omarchy_security_install_sudo_cleanup_traps
/usr/bin/sudo -k >/dev/null 2>&1 || {
echo "Could not start from a cold sudo credential state." >&2
exit 1
}
echo "Toggle passwordless sudo..."
# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up
if sudo test -f "$NOPASSWD_FILE" && ! systemctl is-active "${TIMER_NAME}.timer" &>/dev/null; then
sudo rm "$NOPASSWD_FILE"
fi
# Check for the file directly — sudo -n can stay cached or be granted by other rules
if sudo test -f "$NOPASSWD_FILE"; then
if [[ $1 ]]; then
sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null
arm_expiry || exit 1
echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes."
else
sudo rm "$NOPASSWD_FILE"
sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null
if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then
if (($# == 0)); then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid"
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
else
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes."
fi
else
status=$?
if (( status != STATUS_INACTIVE )); then
echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2
exit 1
fi
echo ""
echo "⚠️ WARNING: This will allow ANY process running as your user to"
echo "execute ANY command as root WITHOUT a password for ${MINUTES} minutes."
echo "execute ANY command as root WITHOUT a password for ${minutes} minutes."
echo ""
echo "This is useful for AI agents that need to run sudo commands,"
echo "but it significantly weakens the security of your system."
echo "Anyone or anything with access to your user account gets full root."
echo ""
echo "Passwordless sudo will automatically disable after ${MINUTES} minutes."
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
echo "including if the machine reboots before the deadline."
echo "Run this command again to disable it early."
echo ""
if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then
echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null
sudo chmod 440 "$NOPASSWD_FILE"
arm_expiry || exit 1
if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
echo ""
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes."
echo "A restart removes the passwordless sudo rule as well."
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
else
echo "Aborted. No changes made."
fi
+73 -15
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/bash -p
# omarchy:summary=Update Omarchy and system packages
# omarchy:alias=omarchy up
@@ -6,24 +6,58 @@
# omarchy:examples=omarchy update | omarchy update -y
# omarchy:requires-sudo=true
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup." >&2
exit 126
fi
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
omarchy_security_require_privileged_bash_startup || exit 126
set -e
omarchy_security_sanitize_bash_environment "$0" "$@"
omarchy_security_require_source_root "$0"
# Logging and lock acquisition re-exec this command with a sanitized PATH.
# Preserve the caller's path only for the later unprivileged hook/mise phases.
user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH}
unset OMARCHY_UPDATE_USER_PATH
# Traps first, so a signal or failure during the entry revocation still
# exits through the cleanup path.
omarchy_security_install_sudo_cleanup_traps
omarchy_security_revoke_sudo_timestamp || exit 1
omarchy_security_enable_no_update_sudo
update_stay_awake_stopped=0
cleanup_update() {
local status=$?
trap - EXIT HUP INT TERM
if ! omarchy_security_revoke_sudo_timestamp; then
echo "Could not invalidate sudo before update cleanup." >&2
omarchy_security_exit_with_revoked_sudo 1
fi
if (( update_stay_awake_stopped == 0 )); then
omarchy-update-stay-awake stop || status=1
fi
omarchy_security_exit_with_revoked_sudo "$status"
}
if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then
script_command=$(printf '%q ' "$0" "$@")
exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log"
exec env OMARCHY_UPDATE_LOGGED=1 OMARCHY_UPDATE_USER_PATH="$user_path" script -qefc "$script_command" "/tmp/omarchy-update.log"
fi
if ! omarchy-update-lock held; then
exec omarchy-update-lock run "$0" "$@"
exec env OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-update-lock run "$0" "$@"
fi
trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR
trap 'omarchy-update-stay-awake stop' EXIT
trap cleanup_update EXIT
omarchy_security_install_signal_exit_traps
omarchy-update-requires-free-space
# -y is a promise not to ask anything. Steps that would need an answer report
# and move on instead of waiting on a prompt nobody is here to give.
# -y suppresses Omarchy confirmation prompts; sudo authorization is still
# required. Interactive review steps report and move on instead of waiting.
[[ ${1:-} != "-y" ]] || export OMARCHY_UPDATE_UNATTENDED=1
if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
@@ -39,6 +73,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
omarchy-update-stay-awake start
# Preserve the established development-checkout update ordering.
omarchy-update-dev
omarchy-update-keyring
@@ -46,20 +81,43 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
# them, so everything below waits on this finishing. An upgrade that stopped
# takes the update with it rather than migrating against what is still on disk.
omarchy-update-system-pkgs
# Historical migrations are strictly ordered and mix user hooks/downloaded
# tooling with privileged repairs. The no-update sudo wrapper has covered the
# whole update, so neither the package transaction nor a later repair can
# publish a timestamp to a detached migration child.
omarchy_security_revoke_sudo_timestamp
omarchy-migrate
omarchy-hook post-update
omarchy-update-aur-pkgs
omarchy-update-mise
omarchy-update-orphan-pkgs
omarchy-update-analyze-logs
omarchy-update-status
# Release update-owned inhibitors before offering a reboot. A confirmed
# reboot can terminate this process before its EXIT trap gets a chance to
# remove the persistent Stay Awake marker.
omarchy-update-stay-awake stop
trap - EXIT
# Service restart helpers can need sudo. Run them before any user-controlled
# update tooling; the reboot-only phase below performs no privileged work.
omarchy-update-restart --services-only
omarchy-update-restart
# AUR package installation must also use the no-update wrapper. Finish
# update-owned system work before build code, hooks, or mise can run.
omarchy_security_revoke_sudo_timestamp
omarchy-update-aur-pkgs
omarchy_security_revoke_sudo_timestamp
# Hooks and mise execute user-controlled code. Give each a cold credential
# boundary and run mise last so it cannot wait for a legitimate hook sudo.
# Only the unprivileged reboot prompt follows them.
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update
omarchy_security_revoke_sudo_timestamp
PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise"
omarchy_security_revoke_sudo_timestamp
# The sleep inhibitor covers AUR builds, hooks and mise as well; releasing it
# needs no privilege because the held command already dropped to this user.
# Release it before offering a reboot: a confirmed reboot can terminate this
# process before its EXIT trap gets a chance to remove the persistent Stay
# Awake marker.
omarchy-update-stay-awake stop
update_stay_awake_stopped=1
"$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only
fi
+8 -1
View File
@@ -2,10 +2,17 @@
# omarchy:summary=Update AUR packages if any are installed
sudo_options=()
if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == "1" ]]; then
sudo_wrapper="$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"
[[ -x $sudo_wrapper ]] || exit 1
sudo_options=(--sudo "$sudo_wrapper" --sudoloop=false)
fi
if pacman -Qem >/dev/null; then
if omarchy-pkg-aur-accessible; then
echo -e "\e[32m\nUpdate AUR packages\e[0m"
yay -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs
yay "${sudo_options[@]}" -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs || exit 1
echo
else
echo -e "\e[31m\nAUR is unavailable (so skipping updates)\e[0m"
+51 -36
View File
@@ -1,51 +1,66 @@
#!/bin/bash
# omarchy:summary=Prompt for required reboot or service restarts after updates
# omarchy:args=[--services-only|--reboot-only]
mode="${1:-all}"
case "$mode" in
all|--services-only|--reboot-only) ;;
*) echo "Unknown restart phase: $mode" >&2; exit 2 ;;
esac
echo
confirm_reboot() {
gum confirm "$1" && { omarchy-system-reboot; exit 0; }
if [[ ${OMARCHY_UPDATE_UNATTENDED:-0} == "1" ]]; then
echo "$1 Run omarchy-system-reboot when ready."
elif gum confirm "$1"; then
omarchy-system-reboot
exit 0
fi
}
running_kernel=$(uname -r)
kernel_updated=true
if [[ $mode != "--services-only" ]]; then
running_kernel=$(uname -r)
kernel_updated=true
for kernel in /usr/lib/modules/*/vmlinuz; do
if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then
installed_kernel=$(basename "$(dirname "$kernel")")
for kernel in /usr/lib/modules/*/vmlinuz; do
if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then
installed_kernel=$(basename "$(dirname "$kernel")")
if [[ $installed_kernel == $running_kernel ]]; then
kernel_updated=false
break
if [[ $installed_kernel == $running_kernel ]]; then
kernel_updated=false
break
fi
fi
fi
done
done
if [[ $kernel_updated == "true" ]]; then
confirm_reboot "Linux kernel has been updated. Reboot?"
elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then
confirm_reboot "Updates require reboot. Ready?"
if [[ $kernel_updated == "true" ]]; then
confirm_reboot "Linux kernel has been updated. Reboot?"
elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then
confirm_reboot "Updates require reboot. Ready?"
fi
running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null)
if [[ $running_hyprland == *"(deleted)"* ]]; then
confirm_reboot "Hyprland has been updated. Reboot?"
fi
fi
running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null)
if [[ $running_hyprland == *"(deleted)"* ]]; then
confirm_reboot "Hyprland has been updated. Reboot?"
if [[ $mode != "--reboot-only" ]]; then
for file in "$HOME"/.local/state/omarchy/restart-*-required; do
if [[ -f $file ]]; then
filename=$(basename "$file")
service=$(echo "$filename" | sed 's/restart-\(.*\)-required/\1/')
echo "Restarting $service"
omarchy-state clear "$filename"
omarchy-restart-"$service"
fi
done
# Updates routinely replace the shell's QML, and a stale process can lazy-load
# new files into old code. A restart failure (locked session, ssh, TTY) only
# prints its reason: the next update or login gets a fresh shell anyway.
echo -e "\e[32m\nRestarting shell\e[0m"
echo "All plugins have been reloaded"
omarchy-restart-shell || true
fi
for file in "$HOME"/.local/state/omarchy/restart-*-required; do
if [[ -f $file ]]; then
filename=$(basename "$file")
service=$(echo "$filename" | sed 's/restart-\(.*\)-required/\1/')
echo "Restarting $service"
omarchy-state clear "$filename"
omarchy-restart-"$service"
fi
done
# Updates routinely replace the shell's QML, and a stale process can lazy-load
# new files into old code. A restart failure (locked session, ssh, TTY) only
# prints its reason: the next update or login gets a fresh shell anyway.
echo -e "\e[32m\nRestarting shell\e[0m"
echo "All plugins have been reloaded"
omarchy-restart-shell || true
+520 -83
View File
@@ -1,15 +1,231 @@
#!/bin/bash
#!/bin/bash -p
# omarchy:summary=Manage sleep and idle inhibition during an update
# omarchy:args=<start|stop>
# omarchy:hidden=true
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup." >&2
exit 126
fi
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
omarchy_security_require_privileged_bash_startup || exit 126
set -e
omarchy_security_sanitize_bash_environment "$0" "$@"
omarchy_security_require_source_root "$0"
# Traps first, so a signal or failure during the entry revocation still
# exits through the cleanup path.
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"
@@ -21,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=""
@@ -34,113 +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_runner=()
local idle_owner="$$:$RANDOM:$RANDOM"
local inhibit_owner=""
local readiness_attempts=0
stop
mkdir -p "$state_dir"
stop_locked || return 1
ensure_private_directory "$state_dir" || fail_state_boundary
if omarchy-cmd-present systemd-inhibit; then
if (( EUID != 0 )); then
if [[ -t 0 ]]; then
sudo -v
inhibit_runner=(sudo)
else
inhibit_runner=(pkexec)
fi
fi
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
if [[ -n ${OMARCHY_UPDATE_LOCK_FD:-} ]]; then
"${inhibit_runner[@]}" systemd-inhibit \
--what=sleep:idle \
--who=omarchy-update \
--why="Omarchy update in progress" \
--mode=block \
sleep infinity >/dev/null 2>&1 {OMARCHY_UPDATE_LOCK_FD}>&- &
else
"${inhibit_runner[@]}" systemd-inhibit \
--what=sleep:idle \
--who=omarchy-update \
--why="Omarchy update in progress" \
--mode=block \
sleep infinity >/dev/null 2>&1 &
fi
inhibit_pid=$!
inhibit_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true)
if [[ -n $inhibit_start_time ]]; then
printf '%s %s\n' "$inhibit_pid" "$inhibit_start_time" >"$inhibit_pid_file"
# 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 [$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"
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 {state_lock_fd}>&-
exec "${hold_command[@]}"
) &
launcher_pid=$!
elif [[ -t 0 ]]; then
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 {state_lock_fd}>&-
exec /usr/bin/pkexec "${hold_command[@]}"
) &
launcher_pid=$!
fi
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 += 1 ))
if [[ -t 0 ]] && (( EUID != 0 && readiness_attempts >= 100 )); then
echo "The update sleep inhibitor did not become ready." >&2
return 1
fi
/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 <start|stop>" >&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
@@ -1,17 +1,17 @@
#!/bin/bash
# This hook is called by `omarchy refresh pacman` AFTER the channel template
# is copied to /etc/pacman.conf and BEFORE `pacman -Syyuu` runs. Use it to
# layer customizations onto the freshly-written pacman.conf so they're
# respected by the upgrade — common cases are adding a custom repository
# (e.g. CachyOS, Chaotic-AUR, an internal company repo) or extra IgnorePkg
# lines.
# is copied and BEFORE the package transaction runs, so your changes shape
# that transaction. Use it to layer customizations onto the freshly-written
# pacman.conf — common cases are adding a custom repository (e.g. CachyOS,
# Chaotic-AUR, an internal company repo) or extra IgnorePkg lines.
#
# The hook runs as the invoking user with a warm sudo cache.
# The hook runs as the invoking user after Omarchy invalidates its sudo cache.
# A sudo command here therefore requires its own explicit authorization.
#
# To put it into use, remove .sample from this file name.
# Example: add an Include line above [core] for a custom repo.
# Example: add an Include line above [core].
# Maintain the repo entries in /etc/pacman.d/custom-repos.conf yourself.
CONF=/etc/pacman.conf
+4 -2
View File
@@ -14,8 +14,8 @@ file first, if one exists.
├── battery-low.d/ # Low battery (percentage in $1)
├── font-set.d/ # After font change (font name in $1)
├── post-boot.d/ # After the desktop starts
├── post-update.d/ # During `omarchy update`, after system packages and migrations
├── pre-refresh-pacman.d/ # Before `omarchy refresh pacman` re-syncs packages
├── post-update.d/ # At the end of `omarchy update`, after privileged work
├── pre-refresh-pacman.d/ # After `omarchy refresh pacman` re-syncs the package config, before it updates packages
└── theme-set.d/ # After theme change (theme slug in $1)
```
@@ -26,3 +26,5 @@ THEME_NAME=$1
echo "Theme changed to: $THEME_NAME"
# Add custom actions here
```
Update-related hooks run as your user after Omarchy invalidates its sudo timestamp, behind the no-update wrapper. A hook that invokes `sudo` must therefore request its own explicit authorization, and Omarchy revokes the timestamp again before continuing. `post-update` runs after every sudo-capable update stage. `pre-refresh-pacman` runs after `omarchy refresh pacman` re-syncs the package config and before the package transaction, so custom repositories and `IgnorePkg` entries shape that transaction; every later privileged command still authenticates without publishing a reusable timestamp, so a detached child left behind by the hook has nothing to wait for.
@@ -0,0 +1,12 @@
[Trigger]
Operation = Upgrade
Operation = Remove
Type = Package
Target = omarchy-settings
Target = omarchy-settings-dev
[Action]
Description = Revoking temporary Omarchy sudo grants before settings changes...
When = PreTransaction
Exec = /usr/bin/omarchy-sudo-passwordless __package-removing
AbortOnFail
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash -p
# Preserve sudo options while preventing authentication from refreshing the
# credential cache. Timestamp maintenance and informational modes stand alone.
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup for the sudo boundary." >&2
exit 126
fi
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
source "${security_entrypoint%/*}/../../../bin/omarchy-security-functions" || exit 126
omarchy_security_require_privileged_bash_startup || exit 126
if (( $# == 1 )); then
case "$1" in
-k|--reset-timestamp|-K|--remove-timestamp|-h|--help|-V|--version)
exec /usr/bin/sudo "$@"
;;
esac
fi
exec /usr/bin/sudo -N "$@"
+27
View File
@@ -0,0 +1,27 @@
# Temporary passwordless sudo
`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`.
## Grant lifecycle
The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists.
Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone.
Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under `/var/lib/omarchy/sudoers-quarantine/`, as `policy` with the original name stored beside it, rather than leaving it live or deleting its content.
## Package ownership
The packaging companion must put the publication/expiry command, `omarchy-security-functions`, `omarchy-nopasswd-sudo.conf`, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files.
Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant.
The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.
## Validation
The two passwordless-sudo test suites share a private filesystem and command fixture. They cover caller validation, the public prompt boundary, atomic publication, renewal failures, expiry, old callbacks, machine migration, and the source/package lock. Supply `OMARCHY_PKGS_PATH` as either a repository root or its `pkgbuilds` directory. An optional `OMARCHY_TEST_SUDOERS` path to sudo's upstream `testsudoers` executable evaluates the generated policy before and after its deadline without root or changing host policy.
These local tests do not establish release readiness. The simplified candidate needs fresh installed-package, suspend/resume, boot-cleanup, and package-removal validation in a disposable VM. The shared security library and its interface are unchanged for downstream PRs.
+24 -8
View File
@@ -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. |
@@ -56,6 +57,8 @@ privileged work should invoke the appropriate helper or privilege prompt.
Migrations must be idempotent; if one user already applied a machine-wide repair,
the migration should no-op for other users.
When invoked by the update, migrations inherit its cold credential state and no-update sudo wrapper. The standalone migration runner has its own security changes in the migration-boundary PR; this update change does not establish that standalone boundary. Historical migrations remain strictly ordered.
For watchers and diagnostics, `omarchy-migrate --pending` prints pending
migration names and exits `0` when any are pending. When no migrations are
pending, it prints nothing and exits non-zero.
@@ -130,21 +133,31 @@ omarchy-update
│ installed but unconfigured fails the snapshot loudly, pointing at
│ install/config/snapper.sh, and the update continues without one)
├─ omarchy-update-stay-awake start
├─ run package updates, migrations, hooks, and log analysis
├─ run system-package updates
├─ invalidate sudo, then run migrations and all later privileged work with
│ no-update authentication
├─ run orphan review and log analysis
├─ omarchy-update-status
│ └─ refresh or clear the shell update indicator
├─ restart marked services and the shell
├─ invalidate sudo credentials, then update AUR packages
├─ invalidate again, run the post-update hook, invalidate again, then update mise tools
├─ omarchy-update-stay-awake stop
│ └─ release the sleep inhibitor and restore shell idle state, if changed
└─ omarchy-update-restart
└─ offer the unprivileged reboot prompt
```
Important behavior:
- In dev-link mode, `omarchy update` fast-forwards the active checkout from its
configured upstream before changing system packages or running migrations.
- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` — a promise not to ask anything.
Steps that would prompt (orphan removal, conflict handoff) report and skip
instead of blocking.
- Protected update entrypoints require the session's canonical `OMARCHY_PATH` to match their own checkout or the packaged `/usr/bin` entrypoint before selecting commands or the sudo wrapper. This preserves intentionally trusted development checkouts while rejecting a command paired with a different source root. System phases use a fixed command search path; user PATH is restored behind the sudo wrapper for hooks and mise.
- Mixed-trust update entrypoints start Bash in privileged mode, discard `BASH_ENV`, `ENV`, and exported-function records before launching helpers, and reject an ordinary `bash path/to/command` invocation. Run them as executables (normally through the `omarchy` CLI); `/usr/bin/bash -p path/to/command` is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary.
- In dev-link mode, `omarchy update` fast-forwards the active checkout from its configured upstream before changing system packages or running migrations.
- 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
confirmation when it is not met. If free space cannot be determined, the
check is silently skipped. Set `OMARCHY_UPDATE_FORCE=1` to bypass the check.
@@ -256,6 +269,9 @@ which pacman repo the mirrorlist points at (and swap between the `omarchy` and
`omarchy-dev` packages through a guard-allowed pacman run), while `dev` links
the runtime to a git checkout via the dev-link mechanism, after which
`omarchy update` fast-forwards that checkout instead of upgrading a package.
Channel switching runs the `pre-refresh-pacman` hook once, during its refresh
step: cold, behind the no-update wrapper, after the package config is re-synced
and before the refresh transaction. It does not run if the switch fails earlier.
There is no version file at runtime. `omarchy-version` derives the version from
`pacman -Q` on whichever package is installed, or reports `dev (<hash>)` for a
@@ -291,7 +307,7 @@ scripts.
| `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. |
| `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. |
| `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. |
| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates, restarts components with `restart-*-required` markers, and always restarts the shell. | **Keep.** Important final step; may eventually include service-restart checks. |
| `omarchy-update-restart` | Restarts components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. |
| `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. |
| `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. |
+4 -4
View File
@@ -1,5 +1,5 @@
# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd-<user> and
# arms a transient systemd-run timer to remove it again. Transient units do not
# survive a reboot, so remove any remaining grant during early boot. Boot-only
# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short.
# omarchy-sudo-passwordless creates grants in this owned filename namespace.
# Transient expiry timers do not survive reboot, so early boot removes every
# remaining grant. The boot-only modifier prevents later tmpfiles runs from
# shortening a live, explicitly requested window.
r! /etc/sudoers.d/99-omarchy-nopasswd-*
+4 -2
View File
@@ -37,12 +37,14 @@ Omarchy fires hooks at a handful of moments, and you can hang your own scripts o
| Event | When it runs |
| ----- | ------------ |
| `post-boot` | Right after the desktop has started |
| `post-update` | During `omarchy update`, after packages and migrations |
| `pre-refresh-pacman` | Before `omarchy refresh pacman` re-syncs the package config |
| `post-update` | Near the end of `omarchy update`, after packages, migrations, and service restarts, before mise tools are updated |
| `pre-refresh-pacman` | After `omarchy refresh pacman` re-syncs the package config, before it updates packages; a channel switch runs it during that same refresh step |
| `theme-set` | After a theme change (theme name in `$1`) |
| `font-set` | After a font change (font name in `$1`) |
| `battery-low` | When the battery gets low (percentage in `$1`) |
The `pre-refresh-pacman` hook is where custom repositories or `IgnorePkg` lines belong, since it runs before the package transaction. Both update-related hooks run as your user after Omarchy clears its cached sudo authorization, so a hook that uses `sudo` needs its own authorization and may ask for your password.
Each of those directories already holds a `.sample` file showing the shape of a hook — drop the `.sample` from the name to put it to work. To install a script you've written elsewhere, use `omarchy hook install post-boot ~/my-hook`, which copies it in and makes it executable.
### Adding your own menu entries
+3 -1
View File
@@ -20,7 +20,9 @@ It works by restoring the baseline snapshot the installer takes, so it's only av
## Passwordless sudo
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well.
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough.
Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive.
Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk.
+6
View File
@@ -0,0 +1,6 @@
echo "Remove legacy temporary passwordless sudo grants"
# Migration queues are per-user; the privileged repair is once per machine.
if ! /usr/bin/omarchy-sudo-passwordless __migration-complete; then
sudo /usr/bin/omarchy-sudo-passwordless __migrate
fi
+200
View File
@@ -0,0 +1,200 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-channel-set
copy_boundary_file bin/omarchy-refresh-pacman
copy_boundary_file bin/omarchy-update
export OMARCHY_UPDATE_LOGGED=1
# Relocate the package root into the fixture, including the explicit handoff
# from the development checkout. All privileged operations remain stand-ins.
python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$SUDO_TEST_ROOT" <<'PY'
import sys
from pathlib import Path
p = Path(sys.argv[1])
p.write_text(p.read_text().replace('/usr/share/omarchy', sys.argv[2]))
PY
for command in omarchy-dev-link omarchy-dev-unlink omarchy-state gum git; do
cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB'
#!/bin/bash
set -euo pipefail
step=${0##*/}
printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG"
case "$step" in
omarchy-dev-link|omarchy-dev-unlink) sudo /usr/bin/true ;;
git)
[[ $1 == "clone" ]] || exit 90
/usr/bin/cp -a "$SUDO_TEST_ROOT" "${@: -1}"
mkdir -p "${@: -1}/.git" "${@: -1}/shell"
;;
esac
STUB
chmod +x "$SUDO_TEST_ROOT/bin/$command"
done
assert_scoped_channel() {
local label=$1
assert_boundary_cold "$label"
python3 - "$SUDO_TEST_LOG" <<'PY'
import sys
events = open(sys.argv[1]).read().splitlines()
assert events[0] == 'sudo -k', events
sudo = [event for event in events if event.startswith('sudo ')]
assert all(event in ('sudo -h', 'sudo -k') or event.startswith('sudo -N ') for event in sudo), events
hooks = [i for i, event in enumerate(events) if event.startswith('step:omarchy-hook ')]
assert len(hooks) == 2, events
assert events[hooks[0]] == 'step:omarchy-hook pre-refresh-pacman', events
assert events[hooks[1]] == 'step:omarchy-hook post-update', events
assert events[hooks[0] - 1] == 'sudo -k' and events[hooks[0] + 1] == 'sudo -k', events
transaction = next(i for i, event in enumerate(events) if event.startswith('step:pacman '))
assert hooks[0] < transaction, events
assert not any(event.startswith('sudo -N ') for event in events[hooks[1]:]), events
PY
}
run_channel() {
"$OMARCHY_PATH/bin/omarchy-channel-set" "$@" >"$boundary_tmp/output" 2>&1
}
for channel in stable rc edge dev; do
reset_boundary
run_channel "$channel" || fail "$channel failed" "$(<"$boundary_tmp/output")"
assert_scoped_channel "$channel"
pass "$channel starts cold, authorizes only individual commands, runs the refresh hook cold before its transaction and exits cold"
done
reset_boundary
wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo"
mv "$wrapper" "$boundary_tmp/saved-wrapper"
if run_channel dev; then fail "an old checkout without the wrapper was accepted"; fi
if grep -Eq '^step:omarchy-(dev-link|state)|^sudo -N ' "$SUDO_TEST_LOG"; then
fail "an incompatible dev checkout changed the system before rejection"
fi
grep -q 'Update the checkout before switching to dev' "$boundary_tmp/output" || fail "stale checkout rejection lacks recovery guidance"
assert_boundary_cold "stale checkout"
mv "$boundary_tmp/saved-wrapper" "$wrapper"
pass "a stale dev checkout is rejected before linking or privileged work"
reset_boundary
OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")"
assert_scoped_channel "dev to stable"
pass "leaving dev preserves no-update sudo through unlink and the packaged update"
# A packaged destination that predates the wrapper cannot be checked before its
# package is installed. Its updater authenticates without --no-update, so the
# switch must not launch it: it stops at a consistent point with instructions.
reset_boundary
mv "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper"
mv "$SUDO_TEST_ROOT/bin/omarchy-update" "$boundary_tmp/saved-package-update"
ln -s test-step "$SUDO_TEST_ROOT/bin/omarchy-update"
if OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable; then fail "an older packaged destination was updated with ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi
grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "an older packaged destination was not reported" "$(<"$boundary_tmp/output")"
grep -q "Run 'omarchy update' from a new terminal to finish" "$boundary_tmp/output" || fail "an older packaged destination lacks recovery guidance" "$(<"$boundary_tmp/output")"
grep -Fxq 'step:omarchy-dev-unlink --no-reboot' "$SUDO_TEST_LOG" || fail "the package switch was not completed before stopping" "$(<"$SUDO_TEST_LOG")"
grep -Fxq 'step:omarchy-state set reboot-required' "$SUDO_TEST_LOG" || fail "leaving dev for an older release did not mark the reboot" "$(<"$SUDO_TEST_LOG")"
if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "an older packaged updater was launched from the hardened switch" "$(<"$SUDO_TEST_LOG")"; fi
grep -q 'The channel switch did not complete' "$boundary_tmp/output" && fail "the stop was reported as an error needing a rerun" "$(<"$boundary_tmp/output")"
assert_boundary_cold "older packaged destination"
rm "$SUDO_TEST_ROOT/bin/omarchy-update"
mv "$boundary_tmp/saved-package-update" "$SUDO_TEST_ROOT/bin/omarchy-update"
mv "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo"
pass "an older packaged destination stops the switch cold with instructions instead of running its updater"
# A package-backed source can be downgraded by its own transaction to a release
# without the wrapper. From then on a bare sudo would be the real one, so no
# privileged step may follow either transaction without checking first. A decoy
# sudo in the package bin catches any such call instead of reaching the host.
cat >"$SUDO_TEST_ROOT/bin/sudo" <<'STUB'
#!/bin/bash
printf 'unwrapped-sudo %s\n' "$*" >>"$SUDO_TEST_LOG"
exit 97
STUB
chmod +x "$SUDO_TEST_ROOT/bin/sudo"
cp "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper"
for pattern in 'pacman -Syyuu*' 'pacman -S --needed*'; do
reset_boundary
export SUDO_TEST_REMOVE_WRAPPER_STEP=$pattern
if run_channel rc; then fail "a downgrade during '$pattern' was not detected" "$(<"$SUDO_TEST_LOG")"; fi
unset SUDO_TEST_REMOVE_WRAPPER_STEP
grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "downgrade during '$pattern' was not reported" "$(<"$boundary_tmp/output")"
if grep -q '^unwrapped-sudo ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' reached ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi
if grep -q '^step:omarchy-dev-unlink' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still unlinked" "$(<"$SUDO_TEST_LOG")"; fi
if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still updated" "$(<"$SUDO_TEST_LOG")"; fi
python3 - "$SUDO_TEST_LOG" "$pattern" <<'PY'
import sys
events = open(sys.argv[1]).read().splitlines()
transactions = [e for e in events if e.startswith('step:pacman ')]
assert len(transactions) == (1 if sys.argv[2].startswith('pacman -Syyuu') else 2), events
assert all(e in ('sudo -h', 'sudo -k') or e.startswith('sudo -N ') for e in events if e.startswith('sudo ')), events
PY
assert_boundary_cold "downgrade during $pattern"
cp "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo"
pass "a transaction that removes the wrapper stops the switch before any further sudo ($pattern)"
done
rm "$SUDO_TEST_ROOT/bin/sudo"
mkdir "$boundary_tmp/user tools"
cat >"$boundary_tmp/user tools/channel-user-tool" <<'STUB'
#!/bin/bash
printf 'user-tool:%s\n' "$*" >>"$SUDO_TEST_LOG"
STUB
chmod +x "$boundary_tmp/user tools/channel-user-tool"
for command in omarchy-hook omarchy-update-mise; do
rm "$SUDO_TEST_ROOT/bin/$command"
cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB'
#!/bin/bash
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
channel-user-tool "${0##*/}" "$@"
STUB
chmod +x "$SUDO_TEST_ROOT/bin/$command"
done
reset_boundary
PATH="$boundary_tmp/user tools:$PATH" run_channel stable || fail "channel hooks lost the user's PATH" "$(<"$boundary_tmp/output")"
for event in 'omarchy-hook post-update' 'omarchy-update-mise' 'omarchy-hook pre-refresh-pacman'; do
grep -Fxq "user-tool:$event" "$SUDO_TEST_LOG" || fail "user PATH was not preserved for $event"
done
assert_boundary_cold "channel user PATH"
for command in omarchy-hook omarchy-update-mise; do
ln -sfn test-step "$SUDO_TEST_ROOT/bin/$command"
done
pass "channel switching preserves user tools behind the wrapper for both hooks and mise"
for step in pacman omarchy-update-system-pkgs omarchy-hook; do
reset_boundary
if SUDO_TEST_FAIL_STEP="$step" run_channel stable; then fail "$step failure was ignored"; fi
assert_boundary_cold "$step failure"
if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$step failure reached the post-update hook"; fi
pass "$step failure exits cold without the post-update hook"
done
for signal in HUP INT TERM; do
reset_boundary
cat >"$SUDO_TEST_ROOT/bin/omarchy-dev-unlink" <<'STUB'
#!/bin/bash
sudo /usr/bin/true || exit 1
kill -s "$SUDO_TEST_CHANNEL_SIGNAL" "$PPID"
STUB
if SUDO_TEST_CHANNEL_SIGNAL="$signal" run_channel stable; then fail "$signal was ignored"; fi
assert_boundary_cold "$signal"
if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$signal reached the post-update hook"; fi
pass "$signal stops the channel transition and revokes authorization"
done
for refusal in unsupported-sudo failed-revocation ordinary-bash; do
reset_boundary
case "$refusal" in
unsupported-sudo) export SUDO_TEST_UNSUPPORTED=1 ;;
failed-revocation) export SUDO_TEST_REVOKE_FAIL=1 ;;
esac
if [[ $refusal == "ordinary-bash" ]]; then
if /usr/bin/bash "$SUDO_TEST_ROOT/bin/omarchy-channel-set" -p >"$boundary_tmp/output" 2>&1; then fail "$refusal was accepted"; fi
elif run_channel stable; then
fail "$refusal was accepted"
fi
if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "$refusal reached channel work"; fi
pass "$refusal is rejected before channel work"
done
+29 -11
View File
@@ -4,10 +4,18 @@ set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
test_tmp="$boundary_tmp"
package_root="$SUDO_TEST_ROOT"
copy_boundary_file bin/omarchy-channel-set
python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$package_root" <<'PYTHON'
import sys
from pathlib import Path
p = Path(sys.argv[1])
p.write_text(p.read_text().replace("/usr/share/omarchy", sys.argv[2]))
PYTHON
stub_bin="$test_tmp/bin"
stub_bin="$SUDO_TEST_ROOT/bin"
log_file="$test_tmp/channel.log"
mkdir -p "$stub_bin" "$test_tmp/home"
@@ -15,6 +23,7 @@ write_stub() {
local name="$1"
local body="$2"
rm -f "$stub_bin/$name"
cat >"$stub_bin/$name" <<<"$body"
chmod +x "$stub_bin/$name"
}
@@ -26,11 +35,17 @@ printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
'
write_stub sudo '#!/bin/bash
case "${1:-}" in
-h) echo "usage: sudo [-ABbEHkNnPS] command"; exit 0 ;;
-k|-K) exit 0 ;;
esac
printf "sudo" >>"$OMARCHY_CHANNEL_TEST_LOG"
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
'
cp "$stub_bin/sudo" "$SUDO_TEST_ROOT/mock/sudo"
write_stub omarchy-update-pacman '#!/bin/bash
printf "update-pacman" >>"$OMARCHY_CHANNEL_TEST_LOG"
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
@@ -69,7 +84,8 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
if [[ $1 == "clone" ]]; then
dest="${@: -1}"
mkdir -p "$dest/.git" "$dest/bin" "$dest/default" "$dest/shell"
/usr/bin/cp -a "$SUDO_TEST_ROOT" "$dest"
mkdir -p "$dest/.git" "$dest/shell"
fi
'
@@ -96,10 +112,10 @@ esac
run_channel() {
: >"$log_file"
OMARCHY_CHANNEL_TEST_LOG="$log_file" \
OMARCHY_PATH="${OMARCHY_TEST_PATH:-/usr/share/omarchy}" \
OMARCHY_PATH="${OMARCHY_TEST_PATH:-$package_root}" \
HOME="$test_tmp/home" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-channel-set" "$@"
"${OMARCHY_TEST_PATH:-$package_root}/bin/omarchy-channel-set" "$@"
}
assert_log_line() {
@@ -114,7 +130,7 @@ run_channel stable
assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel"
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages"
assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt"
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path"
assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "stable runs the normal update pipeline from the package-backed path"
if grep -q $'^state\tset\treboot-required$' "$log_file"; then
fail "stable does not require reboot when already package-backed" "$(cat "$log_file")"
fi
@@ -124,15 +140,17 @@ run_channel rc
assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel"
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages"
assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt"
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path"
assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "rc runs the normal update pipeline from the package-backed path"
OMARCHY_TEST_PATH="$ROOT" run_channel edge
active_checkout="$test_tmp/active-checkout"
cp -a "$package_root" "$active_checkout"
OMARCHY_TEST_PATH="$active_checkout" run_channel edge
assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel"
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages"
assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt"
assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev"
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path"
[[ $(grep -E $'^(unlink|state|update)\t' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] ||
assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "edge runs the normal update pipeline from the package-backed path"
[[ $(grep -E $'^(unlink|state|update)\t' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH='"$package_root" ]] ||
fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")"
pass "edge defers the reboot prompt until the update restart stage"
@@ -0,0 +1,125 @@
#!/bin/bash
# Exercise complete production functions with private paths and harmless
# command stand-ins. Never install sudo policy or start a host timer.
test_tmp=$(mktemp -d)
children=()
cleanup_grant_fixture() {
local status=$?
trap - EXIT
if (( ${#children[@]} )); then
kill "${children[@]}" 2>/dev/null || true
wait "${children[@]}" 2>/dev/null || true
fi
rm -rf "$test_tmp"
exit "$status"
}
trap cleanup_grant_fixture EXIT
export TEST_GRANT_ROOT=$test_tmp
mkdir -p "$test_tmp/bin" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/var/lib" "$test_tmp/hooks"
cat >"$test_tmp/bin/mock" <<'STUB'
#!/bin/bash
set -euo pipefail
name=${0##*/}
printf '%s %s\n' "$name" "$*" >>"$TEST_GRANT_ROOT/commands"
case "$name" in
stat)
path=${@: -1}
owner=0
mode=$(/usr/bin/stat -Lc '%a' -- "$path")
[[ $path != /tmp ]] || mode=755
[[ $path != "${TEST_BAD_PATH:-}" ]] || owner=1000
case $2 in
'%u') echo "$owner" ;;
'%a') echo "$mode" ;;
'%u %a') echo "$owner $mode" ;;
*) exec /usr/bin/stat "$@" ;;
esac
;;
chown) exit 0 ;;
install)
args=()
while (($#)); do
case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac
done
exec /usr/bin/install "${args[@]}"
;;
rm)
for path in "$@"; do
if [[ ${TEST_DELETE_FAIL:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/99-omarchy-nopasswd-1000" ]]; then exit 1; fi
done
exec /usr/bin/rm "$@"
;;
mv)
[[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1
/usr/bin/mv "$@"
[[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing"
;;
systemd-run)
[[ ${TEST_TIMER_FAIL:-0} != 1 ]] || exit 1
if [[ ${TEST_CANCEL_ENABLE:-0} == 1 ]]; then kill -TERM "$PPID"; fi
;;
systemctl)
[[ $1 != "is-active" || ${TEST_INACTIVE_TIMER:-0} != 1 ]]
;;
date)
if [[ ${TEST_EXPIRED:-0} == 1 && $* == '-u +%Y%m%d%H%M%SZ' ]]; then echo 99991231235959Z; else /usr/bin/date "$@"; fi
;;
getent) printf '%s:x:1000:1000:Test:/nonexistent:/bin/bash\n' "${TEST_ACCOUNT:-audituser}" ;;
sudo)
if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi
if [[ ${1:-} == -k ]]; then exit 0; fi
if [[ ${1:-} == -N ]]; then shift; fi
if [[ ${1:-} == -- ]]; then shift; fi
if [[ ${TEST_MIGRATION:-0} == 1 ]]; then
[[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1
TEST_EUID=0 /usr/bin/bash -p "$@"
else
[[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}"
fi
;;
gum) exit 1 ;;
*) exit 99 ;;
esac
STUB
chmod +x "$test_tmp/bin/mock"
for name in stat chown install rm mv systemd-run systemctl date getent sudo gum; do
ln -s mock "$test_tmp/bin/$name"
done
python3 - "$ROOT" "$test_tmp" <<'PY'
from pathlib import Path
import sys
root, temp = map(Path, sys.argv[1:])
for name in ('omarchy-sudo-passwordless', 'omarchy-security-functions'):
text = (root/'bin'/name).read_text()
for path in ('/etc/', '/var/lib', '/run/', '/usr/share/libalpm/hooks'):
target = str(temp/'hooks') if path == '/usr/share/libalpm/hooks' else str(temp) + path
text = text.replace(path, target)
text = text.replace('((EUID == 0))', '((${TEST_EUID:-1} == 0))')
for command in ('stat', 'chown', 'install', 'rm', 'mv', 'systemd-run', 'systemctl', 'date', 'getent', 'sudo', 'gum'):
text = text.replace('/usr/bin/' + command, str(temp/'bin'/command))
(temp/name).write_text(text)
(temp/name).chmod(0o755)
PY
library="$test_tmp/functions.sh"
{
printf 'source %q\n' "$test_tmp/omarchy-security-functions"
awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$test_tmp/omarchy-sudo-passwordless"
} >"$library"
cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/"
sed "s|/etc/|$test_tmp/etc/|g" "$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
: >"$test_tmp/commands"
# New subshell per case prevents one test's overrides and readonly constants
# from affecting the next. External commands log enough to verify ordering.
assert_status() {
local expected=$1 actual=0
shift
"$@" || actual=$?
(( actual == expected )) || fail "expected status $expected, got $actual from $*"
}
reset_grant() {
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
: >"$test_tmp/commands"
}
+136
View File
@@ -0,0 +1,136 @@
#!/bin/bash
# Test the real orchestration with fixed privileged paths redirected to harmless
# stand-ins. No host sudo, package transaction, namespace root, or exploit runs.
boundary_tmp=$(mktemp -d)
trap 'rm -rf "$boundary_tmp"' EXIT
export SUDO_TEST_ROOT="$boundary_tmp/omarchy"
export SUDO_TEST_LOG="$boundary_tmp/events"
export SUDO_TEST_CACHE="$boundary_tmp/cache"
export OMARCHY_PATH="$SUDO_TEST_ROOT"
export SUDO_TEST_HOME="$boundary_tmp/home"
mkdir -p "$SUDO_TEST_HOME"
mkdir -p "$SUDO_TEST_ROOT/bin" "$SUDO_TEST_ROOT/mock" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update"
: >"$SUDO_TEST_LOG"
copy_boundary_file() {
python3 - "$ROOT" "$SUDO_TEST_ROOT" "$1" <<'PY'
import sys
from pathlib import Path
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','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)
p.chmod((source/name).stat().st_mode & 0o777)
PY
}
copy_boundary_file bin/omarchy-security-functions
copy_boundary_file bin/omarchy-update-pacman
copy_boundary_file default/omarchy/sudo-no-update/sudo
cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB'
#!/bin/bash
set -euo pipefail
printf 'sudo' >>"$SUDO_TEST_LOG"
printf ' %q' "$@" >>"$SUDO_TEST_LOG"
printf '\n' >>"$SUDO_TEST_LOG"
if [[ ${1:-} == "-h" ]]; then
if [[ ${SUDO_TEST_UNSUPPORTED:-0} == "1" ]]; then
echo 'usage: sudo [-ABbEHknPS] command'
else
echo 'usage: sudo [-ABbEHkNnPS] command'
fi
exit 0
fi
if [[ ${1:-} == "-k" || ${1:-} == "-K" ]]; then
[[ ${SUDO_TEST_REVOKE_FAIL:-0} != "1" ]] || exit 1
/usr/bin/rm -f "$SUDO_TEST_CACHE"
exit 0
fi
if [[ ${1:-} == "-N" ]]; then
shift
else
touch "$SUDO_TEST_CACHE"
fi
[[ ${SUDO_TEST_SUDO_FAIL:-0} != "1" ]] || exit 1
background=0
while (( $# )); do
case "$1" in
-N|-n) shift ;;
-b) background=1; shift ;;
-v) exit 0 ;;
-u|--user) shift 2 ;;
--) shift; break ;;
*) break ;;
esac
done
(( $# )) || exit 0
if (( background )); then
"$@" &
else
"$@"
fi
STUB
chmod +x "$SUDO_TEST_ROOT/mock/sudo"
cat >"$SUDO_TEST_ROOT/bin/test-step" <<'STUB'
#!/bin/bash
set -euo pipefail
step=${0##*/}
printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG"
if [[ $step == "systemd-run" ]]; then
# omarchy-update-pacman registers the transaction as a PID 1 scope on booted
# hosts. Run the wrapped command in place so the pacman step still executes.
while (( $# )) && [[ $1 == -* ]]; do shift; done
exec "$@"
fi
if [[ $step == "omarchy-hook" || $step == "omarchy-update-mise" ]]; then
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
fi
if [[ -n ${SUDO_TEST_REMOVE_WRAPPER_STEP:-} && "$step $*" == $SUDO_TEST_REMOVE_WRAPPER_STEP ]]; then
# Model a package transaction replacing the running tree with a release
# that predates the wrapper.
/usr/bin/rm -f "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"
fi
if [[ ${SUDO_TEST_FAIL_STEP:-} == "$step" ]]; then
# Model a misbehaving child leaving state behind, then failing. Cleanup must
# still revoke it. This never invokes real sudo or exercises a privilege flaw.
touch "$SUDO_TEST_CACHE"
exit 17
fi
if [[ ${SUDO_TEST_SIGNAL_STEP:-} == "$step" ]]; then
touch "$SUDO_TEST_CACHE"
kill -TERM "$PPID"
exit 0
fi
case "$step" in
omarchy-update-system-pkgs|omarchy-update-keyring|omarchy-snapshot)
sudo /usr/bin/true
;;
pacman) exit 0 ;;
yay)
[[ $* == *"--sudo $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"* ]] || exit 92
[[ $* == *"--sudoloop=false"* ]] || exit 93
;;
esac
STUB
chmod +x "$SUDO_TEST_ROOT/bin/test-step"
for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do
ln -s test-step "$SUDO_TEST_ROOT/bin/$step"
done
ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman"
reset_boundary() {
: >"$SUDO_TEST_LOG"
/usr/bin/rm -f "$SUDO_TEST_CACHE"
unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP
}
assert_boundary_cold() {
[[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization"
[[ $(tail -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "$1 did not revoke at exit" "$(<"$SUDO_TEST_LOG")"
}
+156 -113
View File
@@ -1,123 +1,166 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh"
source "$(dirname "$0")/base-test.sh"
(
source "$library"
for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || exit 1; done
for minutes in 0 1441 -1 1m '' 18446744073709551617; do ! valid_minutes "$minutes" || exit 1; done
for name in audituser 'buildbot$'; do valid_account_name "$name" || exit 1; done
# Upper-case words are sudoers alias references, so ALICE must never publish.
for name in 'a$b' '$' 'a b' 'a#b' Alice ALICE ALL aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done
! valid_uid 18446744073709551617
)
pass "duration and account validation retains bounded inputs and trailing-dollar usernames"
script="$ROOT/bin/omarchy-sudo-passwordless"
tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
(
source "$library"
assert_status 2 root_dispatch __status 1000
assert_status 2 env TEST_EUID=0 SUDO_UID=1001 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
assert_status 3 env TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
)
pass "internal actions reject missing root and mismatched sudo identity"
mock_bin="$test_tmp/bin"
grant="$test_tmp/grant"
calls="$test_tmp/calls"
mkdir -p "$mock_bin"
cat >"$mock_bin/gum" <<'SH'
#!/bin/bash
exit 0
SH
cat >"$mock_bin/systemctl" <<'SH'
#!/bin/bash
printf 'systemctl %s\n' "$*" >>"$TEST_CALLS"
[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]]
SH
cat >"$mock_bin/sudo" <<'SH'
#!/bin/bash
printf 'sudo %s\n' "$*" >>"$TEST_CALLS"
case ${1:-} in
test)
[[ ${2:-} == "-f" && -f $TEST_GRANT ]]
;;
tee)
/usr/bin/tee "$TEST_GRANT"
;;
chmod)
/usr/bin/chmod "$2" "$TEST_GRANT"
;;
systemd-run)
[[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]]
;;
rm)
/usr/bin/rm -f -- "$TEST_GRANT"
;;
systemctl)
exit 0
;;
*)
echo "unexpected sudo command: $*" >&2
exit 90
;;
esac
SH
chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl"
run_command() {
TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \
"$script" "$@"
}
: >"$calls"
enable_output=$(run_command 15)
[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled"
[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] ||
fail "the enabled grant belongs to the current user" "$(cat "$grant")"
grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" ||
fail "enabling arms the expiry timer" "$(cat "$calls")"
[[ $enable_output == *"automatically disable in 15 minutes"* ]] ||
fail "success is reported after the timer is armed" "$enable_output"
pass "enabling arms expiry before reporting success"
: >"$calls"
rm -f "$grant"
if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then
fail "enabling fails when the expiry timer cannot be armed"
fi
[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant"
[[ $failure_output == *"Revoking access now"* ]] ||
fail "timer setup failure explains the fail-closed revocation" "$failure_output"
[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] ||
fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output"
pass "timer setup failure revokes a new grant"
: >"$calls"
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant"
if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then
fail "updating fails when the replacement expiry timer cannot be armed"
fi
[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant"
[[ $update_output != *"timer updated"* ]] ||
fail "timer update failure does not report success" "$update_output"
pass "timer update failure revokes the existing grant"
mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file")
(( ${#tmpfiles_rules[@]} == 1 )) ||
fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}"
fake_root="$test_tmp/root"
sudoers_dir="$fake_root/etc/sudoers.d"
mkdir -p "$sudoers_dir"
grant_names=(alice buildbot-2 user.123 'service$')
for grant_name in "${grant_names[@]}"; do
touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name"
for status in 1 2 3; do
: >"$test_tmp/commands"
result=0
TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 || result=$?
if (( status == 3 )); then
(( result == 0 )) && grep -q '^gum confirm ' "$test_tmp/commands" || fail "inactive status must allow confirmation"
else
(( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement"
fi
grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization"
done
touch "$sudoers_dir/omarchy-dns"
pass "public status distinguishes inactive from errors and revokes authorization on exit"
systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}"
[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] ||
fail "boot-only cleanup leaves a live grant alone outside boot"
printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\nunset BASH_ENV\n' >"$test_tmp/startup"
: >"$test_tmp/commands"
if TEST_STARTUP_MARKER="$test_tmp/startup-ran" BASH_ENV="$test_tmp/startup" bash "$test_tmp/omarchy-sudo-passwordless" -p >/dev/null 2>&1; then
fail "ordinary Bash with a decoy -p was accepted"
fi
[[ -f $test_tmp/startup-ran && ! -s $test_tmp/commands ]] || fail "startup rejection must precede sudo"
pass "startup validation rejects ordinary Bash before authorization"
systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}"
for grant_name in "${grant_names[@]}"; do
stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name"
[[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant"
(
source "$library"
enable_locked 1000 15
read_grant 1000
[[ $GRANT_NAME == audituser && $(stat -c '%a' "$(rule_file 1000)") == 440 ]]
/usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null
expiry=$(sed -n 's/^systemd-run .*--on-calendar=@\([0-9]*\).*$/\1/p' "$test_tmp/commands" | tail -1)
[[ $GRANT_DEADLINE == "$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ)" ]]
if [[ -n ${OMARCHY_TEST_SUDOERS:-} ]]; then
[[ -x $OMARCHY_TEST_SUDOERS ]] || fail "OMARCHY_TEST_SUDOERS must name an executable"
printf 'root:x:0:0:root:/root:/bin/bash\naudituser:x:1000:1000:Test:/nonexistent:/bin/bash\n' >"$test_tmp/passwd"
printf 'root:x:0:\naudituser:x:1000:\n' >"$test_tmp/group"
{ printf 'audituser ALL=(ALL) ALL\n'; cat "$(rule_file 1000)"; } >"$test_tmp/policy"
for offset in -1 1; do
when=$(/usr/bin/date -u -d "@$((expiry + offset))" +%Y%m%d%H%M%SZ)
"$OMARCHY_TEST_SUDOERS" -p "$test_tmp/passwd" -P "$test_tmp/group" -T "$when" audituser /usr/bin/true <"$test_tmp/policy" >"$test_tmp/policy-result"
if (( offset < 0 )); then
! grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy requires a password before expiry"
else
grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy remains passwordless after expiry"
fi
done
pass "native sudoers evaluation requires authentication after the generated deadline"
fi
assert_status 0 status_locked 1000
TEST_INACTIVE_TIMER=1 assert_status 0 status_locked 1000
[[ ! -e $test_tmp/var/lib/omarchy/sudo-passwordless ]]
! compgen -G "$test_tmp/etc/sudoers.d/.omarchy-nopasswd.*"
)
pass "one complete mode-0440 sudoers rule holds the deadline with no separate grant state"
(
source "$library"
before=$(cat "$(rule_file 1000)")
expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff
[[ $(cat "$(rule_file 1000)") == "$before" ]]
enable_locked 1000 30
renewed=$(cat "$(rule_file 1000)")
[[ $renewed != "$before" ]]
expire_locked 1000
[[ $(cat "$(rule_file 1000)") == "$renewed" ]]
TEST_EXPIRED=1 expire_locked 1000
[[ ! -e $(rule_file 1000) ]]
)
pass "legacy and current callbacks preserve renewed grants and remove expired ones"
(
source "$library"
enable_locked 1000 1
assert_status 2 env TEST_EXPIRED=1 TEST_DELETE_FAIL=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
[[ -e $(rule_file 1000) ]]
TEST_EXPIRED=1 assert_status 3 status_locked 1000
[[ ! -e $(rule_file 1000) ]]
)
pass "expired status reports cleanup failure separately from confirmed inactivity"
for failure in TEST_TIMER_FAIL TEST_INACTIVE_TIMER TEST_PUBLISH_FAIL TEST_POST_PUBLISH_FAIL TEST_CANCEL_ENABLE; do
reset_grant
expected=1
if [[ $failure == "TEST_CANCEL_ENABLE" ]]; then expected=143; fi
(
source "$library"
enable_locked 1000 15
assert_status "$expected" env "$failure=1" TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 30
[[ ! -e $(rule_file 1000) ]]
)
done
[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules"
pass "systemd-tmpfiles removes generated grants only during boot"
pass "timer, publication, post-publication and cancellation failures revoke renewed access"
reset_grant
(
source "$library"
TEST_POST_PUBLISH_FAIL=1 TEST_DELETE_FAIL=1 assert_status 1 enable_locked 1000 15
[[ -e $(rule_file 1000) ]]
! grep -q '^systemctl stop ' "$test_tmp/commands"
)
pass "failed policy deletion retains the timer and reports failure"
reset_grant
(
source "$library"
printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$(rule_file 1000)"
cp "$(rule_file 1000)" "$test_tmp/admin-rule"
assert_status 2 status_locked 1000
assert_status 1 enable_locked 1000 15
assert_status 1 cleanup_uid_locked 1000
cmp "$(rule_file 1000)" "$test_tmp/admin-rule"
rm "$(rule_file 1000)"
ln -s "$test_tmp/admin-rule" "$(rule_file 1000)"
assert_status 2 status_locked 1000
assert_status 1 cleanup_uid_locked 1000
[[ -L $(rule_file 1000) ]]
)
pass "grant operations preserve administrator policies and reject symlinks"
reset_grant
(
source "$library"
TEST_BAD_PATH="$test_tmp/etc/sudoers.d" assert_status 1 enable_locked 1000 15
[[ ! -e $(rule_file 1000) ]]
rm "$PACKAGE_HOOK"
assert_status 1 enable_locked 1000 15
)
pass "publication requires trusted paths and the packaged cleanup hook"
reset_grant
cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/"
(
source "$library"
TEST_ACCOUNT='buildbot$' enable_locked 1000 15
read_grant 1000
[[ $GRANT_NAME == 'buildbot$' ]]
/usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null
cleanup_uid_locked 1000
[[ ! -e $(rule_file 1000) ]]
TEST_ACCOUNT=ALICE assert_status 1 enable_locked 1000 15
[[ ! -e $(rule_file 1000) ]]
)
pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish"
@@ -0,0 +1,216 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh"
quarantine="$test_tmp/var/lib/omarchy/sudoers-quarantine"
quarantined_policy() {
local entry
for entry in "$quarantine"/*/; do
if [[ $(cat "$entry/name") == "$1" ]]; then
cat "$entry/policy"
return 0
fi
done
return 1
}
# The old writer accepted any $USER, so a basename can sit just under NAME_MAX.
long_suffix=$(printf 'l%.0s' {1..223})
(
source "$library"
printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)"
printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$"
# The legacy command never validated the account name, so a manual or NSS
# account outside the current policy still has its exact old grant removed.
printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice"
# The legacy writer produced the body with echo. Under BASH_ENV with
# xpg_echo, USER='ali\0143e' yields this filename with an 'alice' rule, so
# a suffix/body mismatch does not prove administrator authorship.
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-ali\\0143e"
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-$long_suffix"
printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom"
TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked
[[ -e $(rule_file 1000) ]]
cleanup_all_locked
! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*"
[[ $(stat -c '%a' "$quarantine") == 700 ]]
[[ $(quarantined_policy '99-omarchy-nopasswd-ali\0143e') == 'alice ALL=(ALL) NOPASSWD: ALL' ]]
[[ $(quarantined_policy "99-omarchy-nopasswd-$long_suffix") == 'alice ALL=(ALL) NOPASSWD: ALL' ]]
[[ $(quarantined_policy 99-omarchy-nopasswd-custom) == 'admin ALL=(ALL) NOPASSWD: /usr/bin/true' ]]
(( $(ls -A "$quarantine" | wc -l) == 3 ))
)
pass "legacy cleanup removes generated rules for any account and quarantines everything else in the prefix"
# Run the actual migration queue for separate temporary homes. Sudo only calls
# the mapped helper and can be refused without requesting host authorization.
mkdir -p "$test_tmp/source/migrations"
sed "s|/usr/bin/omarchy-sudo-passwordless|$test_tmp/omarchy-sudo-passwordless|g" \
"$ROOT/migrations/1788163635.sh" >"$test_tmp/source/migrations/1788163635.sh"
printf 'echo "later migration ran"\n' >"$test_tmp/source/migrations/1788163636.sh"
run_migrations() {
TEST_MIGRATION=1 OMARCHY_PATH="$test_tmp/source" OMARCHY_MIGRATION_STATE="$test_tmp/$1" \
PATH="$test_tmp/bin:$PATH" /usr/bin/bash "$ROOT/bin/omarchy-migrate" >"$test_tmp/migrations.log" 2>&1
}
marker="$test_tmp/var/lib/omarchy/migrations/1788163635"
(
source "$library"
# A quarantine that cannot be trusted keeps the migration pending.
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch"
TEST_BAD_PATH="$test_tmp/var/lib/omarchy" assert_status 1 run_migrations first
[[ ! -e $marker && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch ]]
printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)"
printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice"
TEST_DELETE_FAIL=1 assert_status 1 run_migrations first
[[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]]
run_migrations first
[[ -f $marker && -f $test_tmp/first/1788163636.sh ]]
! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*"
[[ $(quarantined_policy 99-omarchy-nopasswd-mismatch) == 'alice ALL=(ALL) NOPASSWD: ALL' ]]
enable_locked 1000 15
cp "$(rule_file 1000)" "$test_tmp/renewed"
: >"$test_tmp/commands"
TEST_NO_SUDO=1 run_migrations second
[[ -f $test_tmp/second/1788163636.sh ]]
! grep -q '^sudo ' "$test_tmp/commands"
cmp "$(rule_file 1000)" "$test_tmp/renewed"
)
pass "migration completion is machine-wide, retryable, and needs no sudo for later users"
(
source "$library"
TEST_BAD_PATH="$marker" assert_status 1 migration_complete
rm "$marker"
ln -s "$test_tmp/renewed" "$marker"
assert_status 1 migration_complete
assert_status 1 migrate_locked
[[ -L $marker ]]
rm "$marker"
)
pass "migration checks marker ownership and rejects symlinks"
# Keep real package scripts in the contract: source and packaging share the
# same lock and blocker, including the legacy scriptlet fallback.
pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs}
[[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds
for name in omarchy-settings omarchy-settings-dev; do
script="$pkgs_path/$name/$name.install"
[[ -f $script ]] || fail "set OMARCHY_PKGS_PATH to the companion package checkout"
sed -e "s|/etc/|$test_tmp/etc/|g" -e "s|/run|$test_tmp/run|g" \
-e "s|/usr/bin/stat|$test_tmp/bin/stat|g" -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \
"$script" >"$test_tmp/$name.install"
reset_grant
(
source "$library"
source "$test_tmp/$name.install"
_etc_overrides_apply() { :; }
enable_locked 1000 15
TEST_DELETE_FAIL=1 assert_status 1 pre_remove
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
assert_status 1 enable_locked 1000 15
pre_remove && post_remove
[[ ! -e $(rule_file 1000) ]]
post_install
[[ ! -e $REMOVAL_BLOCKER ]]
enable_locked 1000 15
pre_upgrade && post_upgrade
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
# pacman does not stop a transaction on a failed scriptlet, so a failed
# pre_upgrade can be followed directly by post_upgrade. Completion must
# not clear the blocker while a rule remains, and a clean retry recovers.
enable_locked 1000 15
TEST_DELETE_FAIL=1 assert_status 1 pre_upgrade
TEST_DELETE_FAIL=1 assert_status 1 post_upgrade
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
assert_status 1 enable_locked 1000 15
post_upgrade
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
# A stranded blocker plus a live rule from an interrupted removal is
# cleaned by the next completed installation, not merely unblocked.
enable_locked 1000 15
: >"$REMOVAL_BLOCKER"
post_install
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
# A fresh install has no earlier step, so completion must hold the blocker
# itself while it sweeps: a leftover rule it cannot remove leaves
# publication refused rather than merely reporting an error.
enable_locked 1000 15
rm -f "$REMOVAL_BLOCKER"
TEST_DELETE_FAIL=1 assert_status 1 post_install
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
assert_status 1 enable_locked 1000 15
post_install
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
# The sweep must not depend on the glob state pacman's shell inherits.
enable_locked 1000 15
( set -f; GLOBIGNORE='*' post_upgrade )
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
# A failure before the lock is even taken, such as an untrusted lock
# directory, must still leave publication refused.
enable_locked 1000 15
rm -f "$REMOVAL_BLOCKER"
TEST_BAD_PATH="$test_tmp/run/lock" assert_status 1 post_install
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
TEST_BAD_PATH="$test_tmp/run/lock" assert_status 1 pre_upgrade
[[ -e $REMOVAL_BLOCKER ]]
post_install
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
)
done
pass "both settings packages revoke grants, block publication, and recover on installation only with the namespace empty"
reset_grant
# Hold the source lock, then start package removal. A native flock on the
# mapped file must serialize both implementations.
cat >"$test_tmp/worker" <<'WORKER'
#!/bin/bash
set -euo pipefail
source "$TEST_LIBRARY"
critical() {
touch "$TEST_GRANT_ROOT/entered"
for (( attempt=0; attempt<500; attempt++ )); do
[[ ! -e $TEST_GRANT_ROOT/release ]] || break
sleep 0.01
done
[[ -e $TEST_GRANT_ROOT/release ]] || return 1
enable_locked 1000 15
}
with_root_lock critical
WORKER
TEST_LIBRARY="$library" /usr/bin/bash "$test_tmp/worker" >"$test_tmp/publisher.log" 2>&1 &
publisher=$!
children+=("$publisher")
for (( attempt=0; attempt<200; attempt++ )); do
[[ ! -e $test_tmp/entered ]] || break
sleep 0.01
done
[[ -f $test_tmp/entered ]] || fail "publisher failed to acquire the lock"
/usr/bin/bash -euo pipefail -c 'source "$1"; pre_remove; post_remove' bash "$test_tmp/omarchy-settings.install" >"$test_tmp/removal.log" 2>&1 &
removal=$!
children+=("$removal")
# The removal announces itself before waiting for the lock, so a publisher
# still holding it is refused rather than allowed to publish a rule that the
# removal would delete a moment later.
for (( attempt=0; attempt<200; attempt++ )); do
[[ ! -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || break
sleep 0.01
done
[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || fail "removal did not announce itself before waiting for the lock"
touch "$test_tmp/release"
if wait "$publisher"; then fail "publisher was allowed to publish after removal announced itself" "$(cat "$test_tmp/publisher.log")"; fi
wait "$removal" || fail "removal failed" "$(cat "$test_tmp/removal.log")"
children=()
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]]
[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]]
pass "an announced package removal refuses a waiting publisher and clears the namespace"
# systemd-tmpfiles operates on an explicit disposable root, never the host.
reset_grant
: >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000"
: >"$test_tmp/etc/sudoers.d/unrelated"
rule='r! /etc/sudoers.d/99-omarchy-nopasswd-*'
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --inline "$rule"
[[ -f $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "routine tmpfiles shortened a live grant"
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline "$rule"
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 && -f $test_tmp/etc/sudoers.d/unrelated ]] || fail "boot cleanup boundary"
pass "native boot cleanup removes grants while routine tmpfiles preserves them"
+59 -1
View File
@@ -172,7 +172,30 @@ else
fi
SH
chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl"
cat >"$restart_bin/busctl" <<'SH'
#!/bin/bash
if [[ -z ${OMARCHY_TEST_NOTIFICATION_CHECKS:-} ]]; then
echo 'b false'
else
checks=0
[[ ! -f $OMARCHY_TEST_NOTIFICATION_CHECKS ]] || read -r checks <"$OMARCHY_TEST_NOTIFICATION_CHECKS"
(( checks += 1 ))
printf '%s\n' "$checks" >"$OMARCHY_TEST_NOTIFICATION_CHECKS"
# The service was running before the restart and, when asked to, never
# comes back afterwards.
if [[ ${OMARCHY_TEST_NOTIFICATIONS_DIE:-0} == 1 ]]; then
(( checks == 1 )) && echo 'b true' || echo 'b false'
exit 0
fi
if (( checks == 1 || checks >= 4 )); then
echo 'b true'
else
echo 'b false'
fi
fi
SH
chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl" "$restart_bin/busctl"
sleep 30 &
restart_pid_one=$!
@@ -194,6 +217,7 @@ OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \
OMARCHY_TEST_IPC_LOG="$ipc_log" \
OMARCHY_TEST_SESSION_PATH="$restart_root" \
OMARCHY_TEST_TRANSIENT_ENV=leaked \
OMARCHY_TEST_NOTIFICATION_CHECKS="$test_tmp/notification-checks" \
timeout 5 "$ROOT/bin/omarchy-restart-shell"
if kill -0 "$restart_pid_one" 2>/dev/null; then
@@ -213,6 +237,8 @@ grep -F "kill -p $restart_root/shell --any-display" "$restart_log" >/dev/null ||
grep -F 'hl.dsp.exec_cmd("omarchy-launch-shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland"
grep -F "ipc -n -p $restart_root/shell call -- shell ping" "$ipc_log" >/dev/null || fail "restart checks readiness in the session checkout"
pass "restart replaces duplicate shell instances from the session checkout"
[[ $(<"$test_tmp/notification-checks") == 4 ]] || fail "restart waits for the existing notification service after core IPC is ready"
pass "restart waits for notification readiness before one-time update hooks"
: >"$restart_log"
printf '303\n' >"$restart_state"
@@ -265,3 +291,35 @@ restart_pid_one=""
grep -F "ipc -n -p $restart_root/shell call -- lock lock" "$ipc_log" >/dev/null || fail "dead-lock recovery re-acquires the session lock"
grep -F "ipc -n -p $restart_root/shell call -- lock status" "$ipc_log" >/dev/null || fail "dead-lock recovery waits for the lock to become secure"
pass "restart recovers a locked session whose lock client died"
# Lock recovery must not wait on the notification plugin: a stranded user gets
# the lock back even when notifications never return, and the restart then
# reports the missing service rather than claiming success.
sleep 30 &
restart_pid_one=$!
printf '%s\n' "$restart_pid_one" >"$restart_state"
rm -f "$restart_state.locked" "$test_tmp/notification-checks"
: >"$restart_log"
: >"$ipc_log"
if PATH="$restart_bin:$PATH" \
OMARCHY_PATH="$restart_root" \
XDG_RUNTIME_DIR="$runtime_dir" \
OMARCHY_TEST_SESSION_LOCKED=1 \
OMARCHY_TEST_QS_STATE="$restart_state" \
OMARCHY_TEST_QS_LOG="$restart_log" \
OMARCHY_TEST_QS_ENV_LOG="$restart_env_log" \
OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \
OMARCHY_TEST_IPC_LOG="$ipc_log" \
OMARCHY_TEST_SESSION_PATH="$restart_root" \
OMARCHY_TEST_NOTIFICATION_CHECKS="$test_tmp/notification-checks" \
OMARCHY_TEST_NOTIFICATIONS_DIE=1 \
timeout 10 "$ROOT/bin/omarchy-restart-shell" >"$test_tmp/dead-notifications.out" 2>&1; then
fail "a restart whose notification service never returns must not report success"
fi
wait "$restart_pid_one" 2>/dev/null || true
restart_pid_one=""
grep -F "ipc -n -p $restart_root/shell call -- lock lock" "$ipc_log" >/dev/null || fail "lock recovery waited on the notification service" "$(cat "$ipc_log")"
[[ -f $restart_state.locked ]] || fail "lock recovery did not re-secure the session without notifications"
grep -q "notification service did not become ready" "$test_tmp/dead-notifications.out" || fail "a missing notification service is not reported" "$(cat "$test_tmp/dead-notifications.out")"
pass "restart recovers the lock even when the notification service never returns"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
export OMARCHY_UPDATE_LOGGED=1
# The real fixed entrypoints run only fixture operations. The sibling library
# is a harmless sentinel: invoking a command through another directory must
# source the library beside the resolved command instead of this file.
mkdir "$boundary_tmp/links"
printf '%s\n' 'touch "$SUDO_TEST_HOME/wrong-library"' >"$boundary_tmp/links/omarchy-security-functions"
for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set; do
rm -f "$SUDO_TEST_ROOT/bin/$command"
copy_boundary_file "bin/$command"
ln -s "$SUDO_TEST_ROOT/bin/$command" "$boundary_tmp/links/$command"
reset_boundary
args=(unexpected)
[[ $command != "omarchy-update" ]] || args=(-y)
status=0
"$boundary_tmp/links/$command" "${args[@]}" >"$boundary_tmp/output" 2>&1 || status=$?
[[ ! -e $SUDO_TEST_HOME/wrong-library ]] || fail "$command sourced a library beside its symlink"
(( status != 126 )) || fail "$command failed to locate its actual library" "$(<"$boundary_tmp/output")"
[[ -s $SUDO_TEST_LOG ]] || fail "$command did not reach the protected fixture boundary"
assert_boundary_cold "$command symlink"
pass "$command resolves its own library when invoked through a symlink"
done
ln -s "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/links/sudo"
reset_boundary
"$boundary_tmp/links/sudo" -k || fail "symlinked sudo wrapper lost its source library"
[[ $(<"$SUDO_TEST_LOG") == "sudo -k" ]] || fail "symlinked wrapper did not reach the fixed sudo stand-in"
pass "the sudo wrapper resolves its source library independently of its invocation link"
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
source "$SUDO_TEST_ROOT/bin/omarchy-security-functions"
rm -f "$SUDO_TEST_ROOT/bin/omarchy-update"
copy_boundary_file bin/omarchy-update
omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" || fail "matching checkout root was rejected"
pass "a canonical checkout matches its own entrypoint"
mkdir "$boundary_tmp/other-root"
ln -s "$SUDO_TEST_ROOT" "$boundary_tmp/root-link"
for root in "$boundary_tmp/other-root" "$boundary_tmp/root-link" .; do
if OMARCHY_PATH="$root" omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then
fail "a different or noncanonical source root was accepted"
fi
done
pass "different, symlink and relative roots are rejected"
# Redirect only the two package-layout literals into the fixture. Resolution
# still uses real readlink/realpath; no host /usr/bin file is changed or run.
package_root="$boundary_tmp/usr/share/omarchy"
package_bin="$boundary_tmp/usr/bin"
mkdir -p "$package_root/bin" "$package_bin"
cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/omarchy-update"
cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/different-command"
ln -s "$package_bin/omarchy-update" "$package_root/bin/omarchy-update"
python3 - "$SUDO_TEST_ROOT/bin/omarchy-security-functions" "$boundary_tmp/package-library" "$package_root" "$package_bin" <<'PY'
import sys
from pathlib import Path
source, output, root, binaries = sys.argv[1:]
text = Path(source).read_text()
text = text.replace('"/usr/share/omarchy"', f'"{root}"')
text = text.replace('"/usr/bin/$command_name"', f'"{binaries}/$command_name"')
Path(output).write_text(text)
PY
source "$boundary_tmp/package-library"
OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_bin/omarchy-update" || fail "package binary was rejected"
OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" || fail "package link was rejected"
pass "the package binary and its matching source-tree link are accepted"
ln -sfn "$package_bin/different-command" "$package_root/bin/omarchy-update"
if OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then
fail "a package link to a different command was accepted"
fi
pass "a package link must resolve to its named command"
# Run the protected entrypoints themselves with a mismatched root. These must
# stop before any sudo or operational fixture command, not merely validate in
# an isolated library test.
for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set; do
rm -f "$SUDO_TEST_ROOT/bin/$command"
copy_boundary_file "bin/$command"
for root in "$boundary_tmp/other-root" .; do
reset_boundary
if OMARCHY_PATH="$root" "$SUDO_TEST_ROOT/bin/$command" >"$boundary_tmp/output" 2>&1; then
fail "$command accepted a mismatched root"
fi
[[ ! -s $SUDO_TEST_LOG ]] || fail "$command ran work before rejecting its root"
done
pass "$command rejects mismatched and relative roots before work"
done
+12 -8
View File
@@ -9,18 +9,21 @@ unset OMARCHY_UPDATE_FORCE
unset TEST_AVAILABLE_BYTES
unset TEST_DF_INVALID
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
test_home="$test_tmp/home"
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"
snapshot_marker="$test_tmp/snapshot"
gum_marker="$test_tmp/gum"
mkdir -p "$stub_bin" "$test_home" "$runtime_dir"
mkdir -p "$runtime_dir"
for command in omarchy-update omarchy-update-requires-free-space omarchy-update-confirm; do
rm -f "$stub_bin/$command"
copy_boundary_file "bin/$command"
done
run_update() {
HOME="$test_home" \
SUDO_TEST_HOME="$test_home" \
XDG_RUNTIME_DIR="$runtime_dir" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
LC_ALL=C \
@@ -30,13 +33,14 @@ run_update() {
SNAPSHOT_MARKER="$snapshot_marker" \
GUM_MARKER="$gum_marker" \
GUM_STATUS=${GUM_STATUS:-1} \
"$ROOT/bin/omarchy-update" "$@"
"$SUDO_TEST_ROOT/bin/omarchy-update" "$@"
}
write_stub() {
local name="$1"
local body="$2"
rm -f "$stub_bin/$name"
cat >"$stub_bin/$name" <<SH
#!/bin/bash
$body
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-update
copy_boundary_file bin/omarchy-refresh-pacman
# Replace the step symlink, preserving the real fixture dispatcher.
rm "$SUDO_TEST_ROOT/bin/omarchy-update-aur-pkgs"
copy_boundary_file bin/omarchy-update-aur-pkgs
export OMARCHY_UPDATE_LOGGED=1
run_update() {
"$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$boundary_tmp/output" 2>&1
}
for args in '-y' ''; do
reset_boundary
touch "$SUDO_TEST_CACHE"
run_update $args || fail "update failed" "$(<"$boundary_tmp/output")"
assert_boundary_cold "successful update"
grep -q '^sudo -N /usr/bin/true$' "$SUDO_TEST_LOG" || fail "update package helpers must use no-update sudo"
python3 - "$SUDO_TEST_LOG" <<'PY'
import sys
s=open(sys.argv[1]).read().splitlines()
positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-restart --services-only','step:yay','step:omarchy-hook post-update','step:omarchy-update-mise','step:omarchy-update-stay-awake stop','step:omarchy-update-restart --reboot-only']]
assert positions==sorted(positions), s
assert not any(line.startswith('sudo -N ') for line in s[positions[2]:]), s
PY
pass "update $args runs privileged phases before hooks and exits cold"
done
for step in omarchy-update-system-pkgs yay omarchy-hook omarchy-update-mise; do
reset_boundary
export SUDO_TEST_FAIL_STEP=$step
if run_update -y; then fail "$step failure must fail the update"; fi
assert_boundary_cold "failed $step"
python3 - "$SUDO_TEST_LOG" <<'PY'
import sys
s=open(sys.argv[1]).read().splitlines()
for i,line in enumerate(s):
if line=='step:omarchy-update-stay-awake stop': assert i>0 and s[i-1]=='sudo -k',s
PY
pass "update revokes credentials after $step fails"
done
reset_boundary
export SUDO_TEST_SIGNAL_STEP=omarchy-hook
if run_update -y; then fail "interrupted update must fail"; fi
assert_boundary_cold "interrupted update"
pass "update revokes credentials on TERM"
reset_boundary
export SUDO_TEST_REVOKE_FAIL=1
if run_update -y; then fail "failed initial revocation must fail the update"; fi
if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "failed revocation must precede update work"; fi
pass "a failed cold start prevents update work"
reset_boundary
export SUDO_TEST_UNSUPPORTED=1
if run_update -y; then fail "unsupported sudo must prevent mixed-trust work"; fi
assert_boundary_cold "unsupported sudo"
pass "unsupported sudo fails without running update steps"
reset_boundary
"$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable >"$boundary_tmp/output" 2>&1 || fail "refresh failed" "$(<"$boundary_tmp/output")"
assert_boundary_cold "refresh"
python3 - "$SUDO_TEST_LOG" <<'PY'
import sys
s=open(sys.argv[1]).read().splitlines()
hook=next(i for i,l in enumerate(s) if l=='step:omarchy-hook pre-refresh-pacman')
copies=[i for i,l in enumerate(s) if l.startswith('sudo -N cp ')]
transaction=next(i for i,l in enumerate(s) if l.startswith('step:pacman '))
assert len(copies)==4 and max(copies) < hook < transaction, s
assert s[hook-1]=='sudo -k' and s[hook+1]=='sudo -k', s
assert all(l in ('sudo -h','sudo -k') or l.startswith('sudo -N ') for l in s if l.startswith('sudo ')), s
PY
pass "refresh runs the hook cold between the config re-sync and the transaction"
for step in pacman omarchy-hook; do
reset_boundary
export SUDO_TEST_FAIL_STEP=$step
if "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable >"$boundary_tmp/output" 2>&1; then fail "refresh must propagate $step failure"; fi
assert_boundary_cold "failed refresh $step"
pass "refresh revokes after $step failure"
done
# The wrapper must preserve sudo's own option parser, including validation and
# explicit --, while standalone timestamp maintenance cannot be combined with N.
for args in '-v' '-n /usr/bin/true' '--user test -- /usr/bin/true' '-- /usr/bin/true' '-k' '-K'; do
reset_boundary
"$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" $args
case "$args" in
-k|-K) expected="sudo $args" ;;
*) expected="sudo -N $args" ;;
esac
[[ $(<"$SUDO_TEST_LOG") == "$expected" ]] || fail "wrapper changed options: $args" "$(<"$SUDO_TEST_LOG")"
[[ ! -e $SUDO_TEST_CACHE ]] || fail "wrapper refreshed credentials"
pass "sudo wrapper preserves $args"
done
for script in bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
reset_boundary
if /usr/bin/bash "$SUDO_TEST_ROOT/$script" -p >"$boundary_tmp/output" 2>&1; then fail "$script accepted an ordinary Bash launch"; fi
[[ ! -s $SUDO_TEST_LOG ]] || fail "$script reached sudo through an invalid interpreter"
pass "$script rejects a decoy privileged-mode argument"
done
reset_boundary
printf '%s\n' 'printf startup-ran >>"$SUDO_TEST_ROOT/startup-marker"' >"$boundary_tmp/startup"
BASH_ENV="$boundary_tmp/startup" ENV="$boundary_tmp/startup" run_update -y || fail "sanitized update failed" "$(<"$boundary_tmp/output")"
[[ ! -e $SUDO_TEST_ROOT/startup-marker ]] || fail "startup code leaked into an update helper"
pass "inherited startup files do not run in the updater or its child scripts"
reset_boundary
function printf() { /usr/bin/touch "$SUDO_TEST_ROOT/function-marker"; }
export -f printf
run_update -y || fail "update failed with inherited function" "$(<"$boundary_tmp/output")"
unset -f printf
[[ ! -e $SUDO_TEST_ROOT/function-marker ]] || fail "an inherited function reached an update helper"
pass "exported functions do not reach update helper interpreters"
+186 -43
View File
@@ -4,16 +4,40 @@ set -euo pipefail
source "$(dirname "$0")/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
test_home="$test_tmp/home"
runtime_dir="$test_tmp/runtime"
mkdir -p "$stub_bin" "$test_home" "$runtime_dir"
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="/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
case "$1" in
--reuid|--regid) shift 2 ;;
--clear-groups) shift ;;
*) exit 90 ;;
esac
done
exec "$@"
STUB
chmod +x "$SUDO_TEST_ROOT/mock/setpriv"
run_with_lock_env() {
HOME="$test_home" \
SUDO_TEST_HOME="$test_home" \
XDG_RUNTIME_DIR="$runtime_dir" \
XDG_STATE_HOME="$test_tmp/state" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
@@ -24,6 +48,7 @@ write_stub() {
local name="$1"
local body="$2"
rm -f "$stub_bin/$name"
cat >"$stub_bin/$name" <<SH
#!/bin/bash
$body
@@ -51,13 +76,16 @@ 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"
# omarchy-update should hold the lock before snapshotting, so a second update
# cannot even enter its pre-update snapshot.
update_snapshot_marker="$test_tmp/update-snapshot-started"
write_stub omarchy-snapshot 'echo started >"$TEST_MARKER"; sleep 2; exit 0'
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 &
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 &
update_pid=$!
for _ in {1..50}; do
@@ -67,7 +95,7 @@ done
[[ -f $update_snapshot_marker ]] || fail "first omarchy-update reached snapshot under lock"
set +e
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1
update_second_status=$?
set -e
@@ -85,11 +113,11 @@ pass "omarchy-update prevents overlapping top-level updates"
inhibit_pid_file="$test_tmp/inhibit-pid"
keyring_marker="$test_tmp/keyring-started"
write_stub omarchy-snapshot 'exit 0'
write_stub systemd-inhibit 'echo "$$" >"$INHIBIT_PID_FILE"; exec sleep 30'
write_stub systemd-inhibit '[[ -z ${INHIBIT_PID_FILE:-} ]] || echo "$$" >"$INHIBIT_PID_FILE"; while [[ $1 == --* ]]; do shift; done; exec "$@"'
write_stub omarchy-update-keyring 'echo started >"$TEST_MARKER"; sleep 3; exit 0'
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$keyring_marker" INHIBIT_PID_FILE="$inhibit_pid_file" \
run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 &
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 &
inhibit_update_pid=$!
for _ in {1..100}; do
@@ -101,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
@@ -118,39 +146,104 @@ kill -0 "$inhibitor_pid" 2>/dev/null &&
pass "omarchy-update waits for its sleep inhibitor to stop"
if (( EUID != 0 )); then
sudo_log="$test_tmp/sudo.log"
sudo_log="$SUDO_TEST_LOG"
: >"$sudo_log"
pkexec_marker="$test_tmp/pkexec-used"
terminal_inhibit_pid_file="$test_tmp/terminal-inhibit-pid"
write_stub sudo '
printf "%s\n" "$*" >>"$SUDO_LOG"
if [[ $1 == "-v" ]]; then
exit 0
fi
exec "$@"'
write_stub pkexec 'touch "$PKEXEC_MARKER"; exec "$@"'
write_stub pkexec '[[ -z ${PKEXEC_MARKER:-} ]] || touch "$PKEXEC_MARKER"; exec "$@"'
write_stub systemd-inhibit 'sleep 0.2; while [[ $1 == --* ]]; do shift; done; exec "$@"'
# start leaves the inhibitor running on purpose, but script tears the pty down
# the moment its command returns, which SIGHUPs that inhibitor before it can
# exec. Keep the session open from the inside until the stub has logged.
# sudo -b returns before its child is ready. Require start to wait for the
# delayed child and succeed, then stop it before script tears down the PTY.
terminal_driver="$test_tmp/terminal-stay-awake"
cat >"$terminal_driver" <<'SH'
#!/bin/bash
set -euo pipefail
omarchy-update-stay-awake start
for _ in {1..200}; do
grep -q '^systemd-inhibit ' "$SUDO_LOG" && break
sleep 0.05
done
[[ -s $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]]
omarchy-update-stay-awake stop
[[ ! -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" \
run_with_lock_env script -qefc "$terminal_driver" /dev/null >/dev/null
grep -qx -- '-v' "$sudo_log" || fail "terminal sleep inhibition validates sudo in the foreground"
grep -q '^systemd-inhibit ' "$sudo_log" || fail "terminal sleep inhibition runs through sudo"
grep -q -- '^sudo -N -b -- ' "$sudo_log" || fail "terminal inhibition authenticates its background command without a reusable timestamp"
[[ ! -e $pkexec_marker ]] || fail "terminal sleep inhibition does not use pkexec"
run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop
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 </dev/tty &
helper_pid=$!
echo "$helper_pid" >"$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 </dev/null
SH
chmod +x "$delayed_graphical_driver"
DELAYED_MARKER="$delayed_marker" DELAYED_HELPER_PID_FILE="$delayed_graphical_helper_pid_file" \
run_with_lock_env "$delayed_graphical_driver" >"$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
@@ -158,7 +251,7 @@ fi
write_stub omarchy-snapshot 'exit 0'
write_stub omarchy-update-keyring 'exit 0'
write_stub omarchy-toggle-idle '
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
state_file="$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake"
case "$1" in
stay-awake)
mkdir -p "$(dirname "$state_file")"
@@ -169,32 +262,65 @@ case "$1" in
;;
esac'
write_stub omarchy-update-restart '
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
if [[ ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then
state_file="$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake"
if [[ ${1:-} == "--services-only" || ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then
[[ -f $state_file ]]
else
[[ ! -f $state_file ]]
fi'
rm -f "$test_home/.local/state/omarchy/indicators/stay-awake"
OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y
OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y
[[ ! -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update clears its Stay Awake state before restart handling"
mkdir -p "$test_home/.local/state/omarchy/indicators"
touch "$test_home/.local/state/omarchy/indicators/stay-awake"
OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y
OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y
[[ -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 "$ROOT/bin/omarchy-update-stay-awake" stop
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop
[[ $(<"$stay_awake_state") == "user-choice" ]] ||
fail "stale update ownership does not remove a newer Stay Awake choice"
pass "stale update ownership preserves a newer Stay Awake choice"
@@ -203,12 +329,29 @@ 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 "$ROOT/bin/omarchy-update-stay-awake" stop
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop
kill -0 "$unrelated_pid" 2>/dev/null ||
fail "stale inhibitor state does not terminate a reused PID"
kill "$unrelated_pid"
wait "$unrelated_pid" 2>/dev/null || true
pass "stale inhibitor state does not terminate a reused PID"
# The hidden helper also establishes its own boundary when invoked directly.
reset_boundary
touch "$SUDO_TEST_CACHE"
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop
[[ $(head -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "standalone inhibitor cleanup did not start cold"
assert_boundary_cold "standalone inhibitor cleanup"
pass "standalone inhibitor cleanup revokes before and after session work"
reset_boundary
export SUDO_TEST_REVOKE_FAIL=1
if run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" start; then
fail "inhibitor started after failed initial revocation"
fi
[[ ! -e $stay_awake_helper_state/inhibit-pid ]] || fail "failed revocation started an inhibitor"
pass "failed initial revocation prevents standalone inhibition"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
rm "$SUDO_TEST_ROOT/bin/omarchy-update-restart"
copy_boundary_file bin/omarchy-update-restart
for step in omarchy-state omarchy-restart-sshd omarchy-restart-shell omarchy-system-reboot; do
ln -s test-step "$SUDO_TEST_ROOT/bin/$step"
done
cat >"$SUDO_TEST_ROOT/bin/gum" <<'STUB'
#!/bin/bash
printf 'prompt:%s\n' "$*" >>"$SUDO_TEST_LOG"
exit 1
STUB
chmod +x "$SUDO_TEST_ROOT/bin/gum"
mkdir -p "$SUDO_TEST_HOME/.local/state/omarchy"
touch "$SUDO_TEST_HOME/.local/state/omarchy/reboot-required" "$SUDO_TEST_HOME/.local/state/omarchy/restart-sshd-required"
for mode in --services-only --reboot-only; do
reset_boundary
PATH="$SUDO_TEST_ROOT/bin:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update-restart" "$mode" >"$boundary_tmp/output" 2>&1
if [[ $mode == "--services-only" ]]; then
grep -q '^step:omarchy-restart-sshd ' "$SUDO_TEST_LOG" || fail "service phase did not restart a marked service"
grep -q '^step:omarchy-restart-shell ' "$SUDO_TEST_LOG" || fail "service phase did not restart the shell"
if grep -q '^prompt:' "$SUDO_TEST_LOG"; then fail "service phase offered a reboot before update cleanup"; fi
else
grep -q '^prompt:' "$SUDO_TEST_LOG" || fail "reboot phase did not offer the required reboot"
if grep -q '^step:omarchy-restart-' "$SUDO_TEST_LOG"; then fail "reboot phase performed later service work"; fi
fi
pass "restart $mode performs only its selected phase"
done
reset_boundary
OMARCHY_UPDATE_UNATTENDED=1 PATH="$SUDO_TEST_ROOT/bin:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update-restart" --reboot-only >"$boundary_tmp/output" 2>&1
if grep -Eq "^(prompt:|step:omarchy-restart-|step:omarchy-system-reboot)" "$SUDO_TEST_LOG"; then
fail "unattended reboot phase prompted or performed service work"
fi
pass "unattended reboot phase reports a required reboot without prompting"
+11 -11
View File
@@ -2,13 +2,11 @@
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
mkdir -p "$stub_bin"
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-update
test_tmp="$boundary_tmp"
stub_bin="$SUDO_TEST_ROOT/bin"
# Every step omarchy-update runs, recorded in order with the unattended flag it
# was handed. One of them can be told to fail.
@@ -33,6 +31,7 @@ steps=(
)
for step in "${steps[@]}"; do
rm -f "$stub_bin/$step"
cat >"$stub_bin/$step" <<'STUB'
#!/bin/bash
printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG"
@@ -49,7 +48,7 @@ run_update() {
FAILING_STEP="${FAILING_STEP:-}" \
OMARCHY_UPDATE_LOGGED=1 \
PATH="$stub_bin:$PATH" \
bash "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err"
"$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err"
}
steps_run() {
@@ -70,12 +69,13 @@ expected_steps() {
omarchy-update-keyring \
omarchy-update-system-pkgs \
omarchy-migrate \
omarchy-hook \
omarchy-update-aur-pkgs \
omarchy-update-mise \
omarchy-update-orphan-pkgs \
omarchy-update-analyze-logs \
omarchy-update-status \
omarchy-update-restart \
omarchy-update-aur-pkgs \
omarchy-hook \
omarchy-update-mise \
omarchy-update-stay-awake \
omarchy-update-restart
}
@@ -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
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-update
# Model the two exec boundaries without a host update log or a real lock.
# Both child processes inherit the environment exactly as script/lock would.
cat >"$SUDO_TEST_ROOT/bin/script" <<'STUB'
#!/bin/bash
printf 'logged-reexec\n' >>"$SUDO_TEST_LOG"
[[ $1 == "-qefc" ]] || exit 90
exec /usr/bin/bash -p -c "$2"
STUB
rm "$SUDO_TEST_ROOT/bin/omarchy-update-lock"
cat >"$SUDO_TEST_ROOT/bin/omarchy-update-lock" <<'STUB'
#!/bin/bash
case "$1" in
held) [[ ${SUDO_TEST_LOCKED:-0} == "1" ]] ;;
run)
shift
printf 'locked-reexec\n' >>"$SUDO_TEST_LOG"
export SUDO_TEST_LOCKED=1
exec "$@"
;;
esac
STUB
mkdir "$boundary_tmp/user commands"
cat >"$boundary_tmp/user commands/update-user-tool" <<'STUB'
#!/bin/bash
printf 'user-tool:%s\n' "$1" >>"$SUDO_TEST_LOG"
STUB
chmod +x "$SUDO_TEST_ROOT/bin/script" "$SUDO_TEST_ROOT/bin/omarchy-update-lock" "$boundary_tmp/user commands/update-user-tool"
for step in omarchy-hook omarchy-update-mise; do
rm "$SUDO_TEST_ROOT/bin/$step"
cat >"$SUDO_TEST_ROOT/bin/$step" <<'STUB'
#!/bin/bash
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
update-user-tool "${0##*/}"
STUB
chmod +x "$SUDO_TEST_ROOT/bin/$step"
done
for entry in fresh logged locked; do
reset_boundary
unset OMARCHY_UPDATE_LOGGED OMARCHY_UPDATE_USER_PATH SUDO_TEST_LOCKED
case "$entry" in
logged) export OMARCHY_UPDATE_LOGGED=1 ;;
locked) export OMARCHY_UPDATE_LOGGED=1 SUDO_TEST_LOCKED=1 ;;
esac
PATH="$boundary_tmp/user commands:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$boundary_tmp/output" 2>&1 ||
fail "$entry update lost the original user PATH" "$(<"$boundary_tmp/output")"
grep -q '^user-tool:omarchy-hook$' "$SUDO_TEST_LOG" || fail "$entry hook could not run a user-installed tool"
grep -q '^user-tool:omarchy-update-mise$' "$SUDO_TEST_LOG" || fail "$entry mise could not run a user-installed tool"
if [[ $entry == "fresh" ]]; then
grep -q '^logged-reexec$' "$SUDO_TEST_LOG" || fail "fresh update did not exercise the logging exec"
fi
if [[ $entry != "locked" ]]; then
grep -q '^locked-reexec$' "$SUDO_TEST_LOG" || fail "$entry update did not exercise the lock exec"
fi
assert_boundary_cold "$entry update"
pass "$entry update preserves the original user PATH through logging and locking with no-update sudo first"
done