diff --git a/AGENTS.md b/AGENTS.md index e4a0084e..a25ea7a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 entrypoint may use `#!/bin/bash -p` when it must suppress inherited startup code before its first command; document the boundary and test rejection of ordinary Bash with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 7e112388..b711da93 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -83,7 +83,7 @@ if [[ -n $dev_checkout ]]; then omarchy-state set reboot-required fi -omarchy-refresh-pacman "$pacman_channel" +omarchy-refresh-pacman "$pacman_channel" defer-hook # --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites. sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}" @@ -97,3 +97,6 @@ if [[ -z $dev_checkout ]]; then fi omarchy-update -y + +# No channel-owned privileged work follows the historical refresh hook. +omarchy-refresh-pacman "$pacman_channel" run-deferred diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 612a31de..af552abb 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -4,242 +4,43 @@ # omarchy:requires-sudo=true if [[ $- != *p* ]]; then - echo "Refusing an unsafe Bash startup for pacman refresh." >&2 + echo "Refusing an unsafe Bash startup." >&2 exit 126 fi -require_privileged_bash_startup() { - [[ $- == *p* ]] || return 1 - /usr/bin/env -i /usr/bin/bash -p -c ' - [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 - 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 "$$" -} -if ! require_privileged_bash_startup; then - echo "Refusing an unsafe Bash startup for pacman refresh." >&2 - exit 126 -fi -unset -f require_privileged_bash_startup - +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +user_path=$PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo -sanitize_bash_startup_environment() { - local environment_entry environment_name - local needs_reexec=0 - local -a environment_unsets=(-u BASH_ENV -u ENV) - - [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 - while IFS= read -r -d '' environment_entry; do - environment_name="${environment_entry%%=*}" - if [[ $environment_name == BASH_FUNC_*%% ]]; then - environment_unsets+=(-u "$environment_name") - needs_reexec=1 - fi - done < <(/usr/bin/env -0) - - if (( needs_reexec )); then - exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" - fi -} -sanitize_bash_startup_environment "$@" -unset -f sanitize_bash_startup_environment - -usage() { - echo "Usage: omarchy-refresh-pacman [stable|rc|edge]" >&2 -} - -# Composite commands can postpone the legacy user hook until their own final -# privilege boundary. The two internal modes are deliberately paired: a caller -# that defers must invoke --run-deferred-hook exactly once after all of its -# sudo-capable work has finished. -channel=stable -hook_mode=normal -case "$#:$1:${2:-}" in - 0::) - ;; - 1:stable: | 1:rc: | 1:edge:) - channel="$1" - ;; - 1:--run-deferred-hook:) - hook_mode=run-deferred - ;; - 2:stable:--defer-hook | 2:rc:--defer-hook | 2:edge:--defer-hook) - channel="$1" - hook_mode=defer - ;; - *) - usage - exit 2 - ;; -esac - -trusted_directory_chain() { - local current="$1" allow_current_user="$2" canonical owner mode current_uid - current_uid=$(/usr/bin/id -u) || return 1 - - while :; do - [[ -d $current && ! -L $current ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$current") || return 1 - [[ $canonical == "$current" ]] || return 1 - [[ $current == / ]] && break - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 - if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then - return 1 - fi - (( (8#$mode & 0022) == 0 )) || return 1 - current=${current%/*} - [[ -n $current ]] || current=/ - done -} - -trusted_omarchy_source_root() { - local config=/etc/omarchy.conf default_root=/usr/share/omarchy configured_root="" canonical="" - local owner="" mode="" links="" size="" line="" encoded="" decoded="" character="" - local index=0 escaped=0 lines=() - - if [[ ! -e $config && ! -L $config ]]; then - configured_root="$default_root" - else - [[ -f $config && ! -L $config ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$config") || return 1 - [[ $canonical == "$config" ]] || return 1 - read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 - [[ $owner == "0" && $links == "1" ]] || return 1 - (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 - trusted_directory_chain /etc false || return 1 - mapfile -t lines <"$config" || return 1 - (( ${#lines[@]} == 1 )) || return 1 - line="${lines[0]}" - [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 - encoded="${line#'export OMARCHY_PATH="'}" - encoded="${encoded%'"'}" - for (( index = 0; index < ${#encoded}; index++ )); do - character="${encoded:index:1}" - if (( escaped )); then - case "$character" in - '\' | '"' | '$' | '`') decoded+="$character" ;; - *) return 1 ;; - esac - escaped=0 - elif [[ $character == '\' ]]; then - escaped=1 - elif [[ $character == '"' ]]; then - return 1 - else - decoded+="$character" - fi - done - (( escaped == 0 )) || return 1 - configured_root="$decoded" - fi - - [[ -d $configured_root && ! -L $configured_root ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 - [[ $canonical == "$configured_root" ]] || return 1 - if [[ $configured_root == "$default_root" ]]; then - trusted_directory_chain "$configured_root" false || return 1 - else - trusted_directory_chain "$configured_root" true || return 1 - fi - printf '%s\n' "$configured_root" -} - -trusted_omarchy_source_file() { - local relative="$1" source="$OMARCHY_PATH/$1" canonical owner mode links directory current_uid - current_uid=$(/usr/bin/id -u) || return 1 - [[ $relative != /* && $relative != ../* && $relative != */../* && $relative != */.. ]] || return 1 - [[ -f $source && ! -L $source ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$source") || return 1 - [[ $canonical == "$source" && $canonical == "$OMARCHY_PATH/"* ]] || return 1 - read -r owner mode links < <(/usr/bin/stat -Lc '%u %a %h' -- "$source") || return 1 - [[ $links == 1 ]] && (( (8#$mode & 0022) == 0 )) || return 1 - if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then - [[ $owner == 0 ]] || return 1 - else - [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 - fi - - directory=${source%/*} - while [[ $directory == "$OMARCHY_PATH" || $directory == "$OMARCHY_PATH/"* ]]; do - [[ -d $directory && ! -L $directory ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$directory") || return 1 - [[ $canonical == "$directory" ]] || return 1 - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$directory") || return 1 - (( (8#$mode & 0022) == 0 )) || return 1 - if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then - [[ $owner == 0 ]] || return 1 - else - [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 - fi - [[ $directory == "$OMARCHY_PATH" ]] && break - directory=${directory%/*} - done - printf '%s\n' "$source" -} - -if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then - echo "Refusing to refresh pacman from an untrusted Omarchy source root." >&2 - exit 1 +channel="${1:-stable}" +hook_mode="${2:-normal}" +if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then + echo "Invalid channel: $channel" >&2 + exit 2 fi -export OMARCHY_PATH -user_path="${PATH:-/usr/bin:/bin}" -PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" -export PATH - -as_root() { - if ((EUID == 0)); then - "$@" - elif [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then - /usr/bin/sudo -N -- "$@" - else - /usr/bin/sudo -- "$@" - fi -} - -cleanup_sudo_credentials() { - /usr/bin/sudo -k || true -} - -trap cleanup_sudo_credentials EXIT - -if [[ $hook_mode == "run-deferred" ]]; then - /usr/bin/sudo -k || exit 1 - PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman - exit +if [[ $hook_mode != "normal" && $hook_mode != "defer-hook" && $hook_mode != "run-deferred" ]]; then + echo "Invalid refresh hook mode: $hook_mode" >&2 + exit 2 fi -pacman_source=$(trusted_omarchy_source_file "default/pacman/pacman-$channel.conf") || { - echo "Refusing an untrusted pacman configuration source." >&2 - exit 1 -} -mirror_source=$(trusted_omarchy_source_file "default/pacman/mirrorlist-$channel") || { - echo "Refusing an untrusted pacman mirror source." >&2 - exit 1 -} - -as_root /usr/bin/cp -f -- /etc/pacman.conf /etc/pacman.conf.bak -as_root /usr/bin/cp -f -- /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak - -echo "Setting channel to $channel" -echo - -# The unprivileged shell opens the authorized source. Root consumes only the -# inherited descriptor, never a caller-writable development-checkout pathname. -as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.conf <"$pacman_source" -as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.d/mirrorlist <"$mirror_source" - -# Reset all package DBs and then update. -as_root /usr/bin/env OMARCHY_UPDATE_PACMAN=1 /usr/bin/pacman -Syyuu --noconfirm - -# This legacy hook used to run before pacman. Executable user code cannot -# safely precede a later sudo authentication: a child can wait for the new -# timestamp even if the parent invalidates around the hook. Keep the hook, but -# run it only after every privileged refresh step and with a cold credential. -if [[ $hook_mode == "normal" ]]; then - /usr/bin/sudo -k || exit 1 - PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman +if [[ $hook_mode != "run-deferred" ]]; then + 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 + sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm +fi + +# Keep the historical hook name, but finish every privileged refresh operation +# before running user code. Callers with later root work can defer the hook. +if [[ $hook_mode != "defer-hook" ]]; then + omarchy_security_revoke_sudo_timestamp + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \ + "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman fi diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100644 index 00000000..5c9bd9e4 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,91 @@ +#!/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_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 + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "Could not invalidate cached sudo authorization." >&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() { + trap 'omarchy_security_exit_with_revoked_sudo "$?"' 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 +} diff --git a/bin/omarchy-update b/bin/omarchy-update index 4b0b1cc8..a3cc94a9 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -6,210 +6,27 @@ # omarchy:requires-sudo=true if [[ $- != *p* ]]; then - echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 + echo "Refusing an unsafe Bash startup." >&2 exit 126 fi -require_privileged_bash_startup() { - [[ $- == *p* ]] || return 1 - /usr/bin/env -i /usr/bin/bash -p -c ' - [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 - 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 "$$" -} -if ! require_privileged_bash_startup; then - echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 - exit 126 -fi -unset -f require_privileged_bash_startup - +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +user_path=$PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo -# Privileged mode prevents BASH_ENV and exported functions from running before -# this boundary. Re-exec once without their raw environment records so ordinary -# Bash helpers cannot import them again and bypass the trusted command paths. -sanitize_bash_startup_environment() { - local environment_entry environment_name - local needs_reexec=0 - local -a environment_unsets=(-u BASH_ENV -u ENV) - - [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 - while IFS= read -r -d '' environment_entry; do - environment_name="${environment_entry%%=*}" - if [[ $environment_name == BASH_FUNC_*%% ]]; then - environment_unsets+=(-u "$environment_name") - needs_reexec=1 - fi - done < <(/usr/bin/env -0) - - if (( needs_reexec )); then - exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" - fi -} -sanitize_bash_startup_environment "$@" -unset -f sanitize_bash_startup_environment - -trusted_directory_chain() { - local current="$1" allow_current_user="$2" canonical owner mode current_uid - current_uid=$(/usr/bin/id -u) || return 1 - - while :; do - [[ -d $current && ! -L $current ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$current") || return 1 - [[ $canonical == "$current" ]] || return 1 - [[ $current == / ]] && break - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 - if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then - return 1 - fi - (( (8#$mode & 0022) == 0 )) || return 1 - current=${current%/*} - [[ -n $current ]] || current=/ - done -} - -trusted_omarchy_source_root() { - local config=/etc/omarchy.conf - local default_root=/usr/share/omarchy - local configured_root="" - local canonical="" - local owner="" - local mode="" - local links="" - local size="" - local line="" - local encoded="" - local decoded="" - local character="" - local index=0 - local escaped=0 - local lines=() - - if [[ ! -e $config && ! -L $config ]]; then - configured_root="$default_root" - else - [[ -f $config && ! -L $config ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$config") || return 1 - [[ $canonical == "$config" ]] || return 1 - read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 - [[ $owner == "0" && $links == "1" ]] || return 1 - (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 - trusted_directory_chain /etc false || return 1 - - mapfile -t lines <"$config" || return 1 - (( ${#lines[@]} == 1 )) || return 1 - line="${lines[0]}" - [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 - encoded="${line#'export OMARCHY_PATH="'}" - encoded="${encoded%'"'}" - - for (( index = 0; index < ${#encoded}; index++ )); do - character="${encoded:index:1}" - if (( escaped )); then - case "$character" in - '\' | '"' | '$' | '`') decoded+="$character" ;; - *) return 1 ;; - esac - escaped=0 - elif [[ $character == '\' ]]; then - escaped=1 - elif [[ $character == '"' ]]; then - return 1 - else - decoded+="$character" - fi - done - (( escaped == 0 )) || return 1 - configured_root="$decoded" - fi - - [[ -d $configured_root && ! -L $configured_root ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 - [[ $canonical == "$configured_root" ]] || return 1 - - if [[ $configured_root == "$default_root" ]]; then - trusted_directory_chain "$configured_root" false || return 1 - else - trusted_directory_chain "$configured_root" true || return 1 - fi - - printf '%s\n' "$configured_root" -} - -sudo_supports_no_update() { - LC_ALL=C /usr/bin/sudo -h 2>&1 | /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' -} - -validate_non_reusable_sudo() { - local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" - local wrapper="$wrapper_dir/sudo" canonical="" current="" owner="" mode="" - - sudo_supports_no_update || { - echo "This sudo does not support --no-update; refusing to run a mixed-trust update." >&2 - return 1 - } - [[ -f $wrapper && -x $wrapper && ! -L $wrapper ]] || { - echo "Trusted no-update sudo wrapper is missing; refusing to run a mixed-trust update." >&2 - return 1 - } - canonical=$(/usr/bin/realpath -e -- "$wrapper") || return 1 - [[ $canonical == "$wrapper" ]] || return 1 - if [[ $OMARCHY_PATH == "/usr/share/omarchy" ]]; then - current="$wrapper" - while :; do - [[ ! -L $current ]] || return 1 - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 - [[ $owner == "0" ]] || return 1 - (( (8#$mode & 0022) == 0 )) || return 1 - [[ $current == "$OMARCHY_PATH" ]] && break - current=${current%/*} - done - fi -} - -enable_non_reusable_sudo() { - local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" - - validate_non_reusable_sudo - PATH="$wrapper_dir:$PATH" - export PATH -} - -if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then - echo "Refusing to update from an untrusted Omarchy source root." >&2 - exit 1 -fi -export OMARCHY_PATH -user_path="${PATH:-/usr/bin:/bin}" -PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" -export PATH update_stay_awake_stopped=0 - -# Verify and enable the security primitive before any update-owned privileged -# work. Every authorization in this workflow is command-scoped (`sudo -N`): it -# may prompt for the command being run, but it never publishes a reusable -# timestamp to a dev hook, migration tool, AUR build, or detached child. -validate_non_reusable_sudo || exit 1 -if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then - enable_non_reusable_sudo -fi -/usr/bin/sudo -k || exit 1 -enable_non_reusable_sudo -export OMARCHY_SUDO_NO_UPDATE=1 - cleanup_update() { local status=$? - - trap - EXIT + trap - EXIT HUP INT TERM if (( update_stay_awake_stopped == 0 )); then - omarchy-update-stay-awake stop || true + omarchy-update-stay-awake stop || status=1 fi - /usr/bin/sudo -k || true - exit "$status" + omarchy_security_exit_with_revoked_sudo "$status" } if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then @@ -223,6 +40,7 @@ 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 cleanup_update EXIT +omarchy_security_install_signal_exit_traps omarchy-update-requires-free-space @@ -243,9 +61,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start - # A dev link explicitly authorizes its checkout through root-owned system - # configuration (including sudo's secure_path), so preserve the established - # pull-before-packages/migrations ordering for that trusted mode. + # Preserve the established development-checkout update ordering. omarchy-update-dev omarchy-update-keyring @@ -258,7 +74,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # 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. - /usr/bin/sudo -k + omarchy_security_revoke_sudo_timestamp omarchy-migrate omarchy-update-orphan-pkgs @@ -275,19 +91,18 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake stop update_stay_awake_stopped=1 - # AUR installation can refresh sudo after running package build code. No - # privileged update stage may follow it: user-controlled code can outlive - # its parent and wait for a later timestamp even if we invalidate in between. + # AUR package installation must also use the no-update wrapper. Finish + # update-owned system work before build code, hooks, or mise can run. omarchy-update-aur-pkgs - /usr/bin/sudo -k + 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="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update - /usr/bin/sudo -k - PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise" - /usr/bin/sudo -k + 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 "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only fi diff --git a/bin/omarchy-update-aur-pkgs b/bin/omarchy-update-aur-pkgs index 4f496b33..8cdfb617 100755 --- a/bin/omarchy-update-aur-pkgs +++ b/bin/omarchy-update-aur-pkgs @@ -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" --nosudoloop) +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" diff --git a/bin/omarchy-update-restart b/bin/omarchy-update-restart index 05f7ba05..393d95e0 100755 --- a/bin/omarchy-update-restart +++ b/bin/omarchy-update-restart @@ -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 diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index 5fd9eb15..826fee5e 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -81,46 +81,55 @@ stop() { } start() { - local inhibit_pid="" - local inhibit_start_time="" - local inhibit_runner=() local idle_owner="$$:$RANDOM:$RANDOM" stop mkdir -p "$state_dir" - 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 + # sudo authenticates in the foreground, then backgrounds the inhibitor. + # The held command drops back to this user before publishing its PID, so stop + # can release the inhibitor without another privileged operation or ticket. + local hold_command=( + /usr/bin/systemd-inhibit --what=sleep:idle --who=omarchy-update + --why="Omarchy update in progress" --mode=block + /usr/bin/setpriv --reuid "$UID" --regid "$(id -g)" --clear-groups + /usr/bin/bash -p -c ' + read -r process_stat <"/proc/$$/stat" + process_stat=${process_stat##*) } + read -r -a fields <<< "$process_stat" + printf "%s %s\n" "$$" "${fields[19]}" >"$1" + exec /usr/bin/sleep infinity + ' omarchy-update-inhibitor "$inhibit_pid_file" + ) - 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" - fi + if (( EUID == 0 )); then + ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec "${hold_command[@]}" ) & + elif [[ -t 0 ]]; then + sudo -N -b -- "${hold_command[@]}" + else + ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec pkexec "${hold_command[@]}" ) & fi + # For graphical authentication the launcher may wait for a password. Wait for + # either the user-owned held command to become ready or the launcher to fail. + local launcher_pid=${!:-} + local readiness_attempts=0 + while [[ ! -s $inhibit_pid_file ]]; do + if [[ -n $launcher_pid ]] && ! kill -0 "$launcher_pid" 2>/dev/null; then + wait "$launcher_pid" || return 1 + echo "The update sleep inhibitor did not start." >&2 + return 1 + fi + readiness_attempts=$((readiness_attempts + 1)) + if [[ -t 0 ]] && (( EUID != 0 && readiness_attempts >= 100 )); then + echo "The update sleep inhibitor did not become ready." >&2 + return 1 + fi + sleep 0.05 + done + if [[ ! -f $stay_awake_state ]]; then printf '%s\n' "$idle_owner" >"$idle_owner_file" mkdir -p "$(dirname "$stay_awake_state")" diff --git a/default/omarchy/sudo-no-update/sudo b/default/omarchy/sudo-no-update/sudo index ca94c1e6..de69347b 100755 --- a/default/omarchy/sudo-no-update/sudo +++ b/default/omarchy/sudo-no-update/sudo @@ -1,27 +1,19 @@ #!/bin/bash -p -# Internal update/migration sudo boundary. Authentication may authorize this -# command, but -N prevents it from publishing a timestamp that detached user -# code can silently reuse. - +# 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 +source "${BASH_SOURCE[0]%/*}/../../../bin/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 -require_privileged_bash_startup() { - [[ $- == *p* ]] || return 1 - /usr/bin/env -i /usr/bin/bash -p -c ' - [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 - 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 "$$" -} -if ! require_privileged_bash_startup; then - echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 - 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 -- "$@" +exec /usr/bin/sudo -N "$@" diff --git a/docs/update-process.md b/docs/update-process.md index bb73d271..9ec3b96c 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -26,7 +26,7 @@ The design goal is: | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | | `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. | -| `~/.local/state/omarchy/restart-*-required` | user | Optional allowlisted service/app restart markers checked by `omarchy-update-restart`. Marker names select fixed commands from the system-authorized Omarchy tree; they are not resolved through caller `PATH`. The shell needs no marker: it is restarted unconditionally after every update. | +| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. The shell needs no marker: it is restarted unconditionally after every update. | ## Migration layout @@ -56,12 +56,7 @@ 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. -The runner derives the migration source from root-owned `/etc/omarchy.conf`, -starts from a cold sudo timestamp, and routes migration sudo calls through -`sudo --no-update`. Historical migrations are strictly ordered and can mix -user-controlled tools or theme hooks with later privileged repairs; no-update -authentication lets those repairs run without publishing a credential a -detached earlier process could reuse. +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 @@ -149,9 +144,9 @@ omarchy-update Important behavior: -- `omarchy update` derives its source tree from root-owned `/etc/omarchy.conf`, or the root-owned `/usr/share/omarchy` default when that file is absent. It does not trust inherited `OMARCHY_PATH` or caller `PATH` for the privileged phase. +- `omarchy update` uses the session’s `OMARCHY_PATH` and a fixed command search path for its system phases. 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, the root-owned configuration is the explicit authorization for the user-writable checkout. `omarchy update` fast-forwards that authorized checkout from its configured upstream before changing system packages or running migrations. +- 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. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` — a promise not to ask anything. @@ -292,7 +287,8 @@ scripts. | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | | `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | -| `omarchy-update-system-pkgs` | Runs the ordinary guarded `pacman -Syu --noconfirm`. Package-vs-package conflicts may be retried interactively with `pacman -Su`; filesystem conflicts fail closed without moving, restoring, quarantining, or broadly overwriting live paths. The production Quattro transition performs the sole explicit settings-package takeover with `--overwrite='*'`; all later production and developer package transactions obey Pacman's ownership checks. | **Keep.** Small leaf command with no generic privileged conflict handler. | +| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | +| `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | | `omarchy-update-requires-free-space` | Aborts the update below a 10 GiB free-space threshold on `/`; silently skipped when free space cannot be determined; `OMARCHY_UPDATE_FORCE=1` bypasses. | **Keep internal/hidden.** | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | @@ -304,7 +300,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` | Restarts allowlisted 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-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. | diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 664e17c5..a799e6e0 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -105,7 +105,7 @@ assert_log_line() { } run_channel stable -assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel" +assert_log_line $'refresh\tstable\tdefer-hook' "stable refreshes the stable pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\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" @@ -115,13 +115,13 @@ fi pass "stable does not require reboot when already package-backed" run_channel rc -assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel" +assert_log_line $'refresh\trc\tdefer-hook' "rc refreshes the rc pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\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" OMARCHY_TEST_PATH="$ROOT" run_channel edge -assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel" +assert_log_line $'refresh\tedge\tdefer-hook' "edge refreshes the edge pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\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" @@ -137,7 +137,7 @@ if run_channel dev >"$test_tmp/occupied.out" 2>"$test_tmp/occupied.err"; then fi grep -q "already exists and is not a git checkout" "$test_tmp/occupied.err" || fail "dev explains occupied checkout paths" "$(cat "$test_tmp/occupied.err")" -if grep -Fx $'refresh\tedge' "$log_file" >/dev/null; then +if grep -Fx $'refresh\tedge\tdefer-hook' "$log_file" >/dev/null; then fail "dev validates checkout path before changing packages" "$(cat "$log_file")" fi pass "dev refuses occupied non-checkout paths before package changes" @@ -145,15 +145,17 @@ pass "dev refuses occupied non-checkout paths before package changes" rmdir "$checkout" run_channel dev assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" -assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" +assert_log_line $'refresh\tedge\tdefer-hook' "dev refreshes the edge pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" -[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file" | sed '/run-deferred/d') == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\tdefer-hook\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" pass "dev activates the checkout before changing or updating packages" +[[ $(tail -1 "$log_file") == $'refresh\tedge\trun-deferred' ]] || fail "channel refresh hook must run after the complete update" +pass "channel changes defer the refresh hook until all update work finishes" OMARCHY_TEST_PATH="$checkout" run_channel stable assert_log_line $'unlink\t--no-reboot' "switching from dev to stable unlinks without an early reboot prompt" diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh new file mode 100644 index 00000000..cabad278 --- /dev/null +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -0,0 +1,124 @@ +#!/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','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 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 + 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 == "omarchy-hook" || $step == "omarchy-update-mise" ]]; then + [[ ! -e $SUDO_TEST_CACHE ]] || exit 91 +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 + [[ $* == *"--nosudoloop"* ]] || 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 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" + 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 +} +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")" +} diff --git a/test/shell.d/update-hook-security-test.sh b/test/shell.d/update-hook-security-test.sh old mode 100644 new mode 100755 index 6245205f..4e0922c3 --- a/test/shell.d/update-hook-security-test.sh +++ b/test/shell.d/update-hook-security-test.sh @@ -2,1039 +2,116 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" +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 -# The setuid helper below models the strongest sudo timestamp mode: one token -# shared by every process for this uid. That makes a detached hook child a -# faithful regression for both global timestamps and the easier tty-sharing -# case. The helper is mounted over /usr/bin/sudo only in this private namespace -# so the scripts must use the fixed trusted invalidation path. -if [[ ${OMARCHY_UPDATE_HOOK_SECURITY_NS:-} != 1 ]]; then - outer_uid=$(id -u) - outer_gid=$(id -g) - subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) - subgid=$(awk -F: -v group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) - - if [[ -z $subuid || -z $subgid ]]; then - pass "no subordinate uid/gid range; skipping update-hook namespace proof" - exit 0 - fi - - exec unshare --user --mount \ - --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" \ - --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536" \ - env OMARCHY_UPDATE_HOOK_SECURITY_NS=1 bash "$0" -fi - -[[ $(id -u) == 0 ]] || fail "update-hook proof entered its root namespace" - -mount -t tmpfs -o mode=0755 tmpfs /run -run_bound=1 -test_tmp=$(mktemp -d -p /run omarchy-update-hook-security.XXXXXXXX) -mount -t tmpfs -o mode=0755 tmpfs "$test_tmp" -chmod 0755 "$test_tmp" -sudo_path_bound=0 -channel_paths_bound=0 -channel_wrapper_tree_bound=0 -aur_paths_bound=0 -font_paths_bound=0 -migration_pkg_paths_bound=0 -etc_bound=0 -persistent_pids=() -cleanup() { - local pid pid_file - for pid_file in "$test_home"/*.pid; do - if [[ -s $pid_file ]]; then - persistent_pids+=("$(<"$pid_file")") - fi - done - for pid in "${persistent_pids[@]}"; do - kill "$pid" 2>/dev/null || true - done - sleep 0.05 - for pid in "${persistent_pids[@]}"; do - kill -KILL "$pid" 2>/dev/null || true - done - if (( sudo_path_bound )); then - umount /usr/bin/sudo - fi - if (( channel_paths_bound )); then - umount /usr/bin/pacman - umount /usr/bin/omarchy-dev-unlink - umount /usr/bin/omarchy-state - umount /usr/bin/omarchy-refresh-pacman - umount /usr/bin/omarchy-update - fi - if (( channel_wrapper_tree_bound )); then - umount /usr/share/omarchy - fi - if (( aur_paths_bound )); then - umount /usr/bin/omarchy-pkg-aur-accessible - umount /usr/bin/yay - fi - if (( font_paths_bound )); then - umount /usr/bin/omarchy-launch-floating-terminal-with-presentation - umount /usr/bin/omarchy-pkg-add - umount /usr/bin/omarchy-font-set - fi - if (( migration_pkg_paths_bound )); then - umount /usr/bin/omarchy-pkg-missing - umount /usr/bin/omarchy-pkg-add - fi - if (( etc_bound )); then - umount /etc - fi - rm -rf "$test_tmp"/* - umount "$test_tmp" - rmdir "$test_tmp" - if (( run_bound )); then - umount /run - fi -} -trap cleanup EXIT - -stub_bin="$test_tmp/bin" -test_home="$test_tmp/home" -root_dir="$test_tmp/root" -token="$test_tmp/sudo-token" -event_log="$test_tmp/events" -hook_log="$test_home/hook-events" -mkdir -p "$stub_bin" "$test_home/.config/omarchy/hooks" "$root_dir" -mkdir -p "$test_tmp/default/omarchy/sudo-no-update" -cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$test_tmp/default/omarchy/sudo-no-update/sudo" -chmod 0755 "$test_tmp/default/omarchy/sudo-no-update/sudo" -mkdir -p "$test_tmp/default/pacman" -cp "$ROOT/default/pacman"/* "$test_tmp/default/pacman/" -chmod 0755 "$test_tmp/default" "$test_tmp/default/omarchy" \ - "$test_tmp/default/omarchy/sudo-no-update" "$test_tmp/default/pacman" -chmod 0644 "$test_tmp/default/pacman"/* -touch "$event_log" "$hook_log" -chown -R 1000:1000 "$test_home" -chown 1000:1000 "$event_log" -chmod 0700 "$test_home" -chmod 0600 "$event_log" "$hook_log" -chmod 0755 "$stub_bin" "$root_dir" - -cat >"$test_tmp/sudo.c" <<'C' -#include -#include -#include -#include -#include -#include -#include - -static const char *required_env(const char *name) { - const char *value = getenv(name); - if (!value || !*value) exit(125); - return value; +run_update() { + "$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$boundary_tmp/output" 2>&1 } -static void log_event(const char *event) { - int fd = open(required_env("TEST_SUDO_EVENT_LOG"), O_WRONLY | O_APPEND); - if (fd < 0) exit(125); - if (dprintf(fd, "%s\n", event) < 0) exit(125); - close(fd); -} - -static int authenticate(void) { - int fd = open(required_env("TEST_SUDO_TOKEN"), O_WRONLY | O_CREAT | O_TRUNC, 0600); - if (fd < 0) return 125; - close(fd); - return 0; -} - -static int token_valid(void) { - struct stat st; - return stat(required_env("TEST_SUDO_TOKEN"), &st) == 0 && st.st_uid == 0; -} - -static int is_pacman_command(int argc, char **argv) { - int index; - for (index = 1; index < argc; index++) { - const char *base = strrchr(argv[index], '/'); - base = base ? base + 1 : argv[index]; - if (strcmp(base, "pacman") == 0) return 1; - } - return 0; -} - -int main(int argc, char **argv) { - int no_update = 0; - if (argc == 2 && strcmp(argv[1], "-h") == 0) { - const char *disable = getenv("TEST_SUDO_NO_N"); - if (disable && strcmp(disable, "1") == 0) { - fputs("usage: sudo [-ABbEHknPS] command\n", stdout); - } else { - fputs("usage: sudo [-ABbEHkNnPS] command\n", stdout); - } - return 0; - } - if (argc > 1 && strcmp(argv[1], "-N") == 0) { - no_update = 1; - argc--; - argv++; - } - if (argc > 1 && strcmp(argv[1], "--") == 0) { - argc--; - argv++; - } - if (argc == 2 && strcmp(argv[1], "--authenticate-for-test") == 0) { - if (no_update) { - log_event("authenticate-no-update"); - return 0; - } else { - log_event("authenticate"); - return authenticate(); - } - } - if (argc == 2 && strcmp(argv[1], "-k") == 0) { - log_event("invalidate"); - if (unlink(required_env("TEST_SUDO_TOKEN")) < 0 && errno != ENOENT) return 125; - return 0; - } - if (!token_valid() && !no_update) { - if (is_pacman_command(argc, argv)) { - log_event("authenticate-command"); - if (authenticate() != 0) return 125; - } else { - log_event("deny"); - fputs("sudo: a password is required\n", stderr); - return 1; - } - } - log_event(no_update ? "grant-no-update" : "grant"); - if (argc < 2 || setuid(0) < 0) return 125; - execvp(argv[1], &argv[1]); - return 125; -} -C -gcc -O2 -Wall -Wextra -o "$stub_bin/sudo" "$test_tmp/sudo.c" -chown 0:0 "$stub_bin/sudo" -chmod 4755 "$stub_bin/sudo" -mount --bind "$stub_bin/sudo" /usr/bin/sudo -sudo_path_bound=1 -mount --bind "$ROOT/bin/omarchy-refresh-pacman" /usr/bin/omarchy-refresh-pacman -mount --bind "$ROOT/bin/omarchy-update" /usr/bin/omarchy-update -mount -t tmpfs -o mode=0755 tmpfs /usr/share/omarchy -mkdir -p /usr/share/omarchy/default/omarchy/sudo-no-update -cp "$ROOT/default/omarchy/sudo-no-update/sudo" /usr/share/omarchy/default/omarchy/sudo-no-update/sudo -chmod 0755 /usr/share/omarchy/default /usr/share/omarchy/default/omarchy \ - /usr/share/omarchy/default/omarchy/sudo-no-update -chmod 0755 /usr/share/omarchy/default/omarchy/sudo-no-update/sudo -channel_wrapper_tree_bound=1 - -# Authorize this private tree exactly as omarchy-dev-link authorizes a -# development checkout. Hiding the host /etc keeps the proof self-contained. -mount -t tmpfs -o mode=0755 tmpfs /etc -etc_bound=1 -mkdir -p /etc/pacman.d -touch /etc/pacman.conf /etc/pacman.d/mirrorlist -chmod 0644 /etc/pacman.conf /etc/pacman.d/mirrorlist -printf 'root:x:0:0:root:/root:/bin/bash\n' >/etc/passwd -printf 'root:x:0:\n' >/etc/group -chmod 0644 /etc/passwd /etc/group -write_authorized_source_root() { - local source_root="$1" - local quoted="$source_root" - - quoted=${quoted//\\/\\\\} - quoted=${quoted//\"/\\\"} - quoted=${quoted//\$/\\\$} - quoted=${quoted//\`/\\\`} - printf 'export OMARCHY_PATH="%s"\n' "$quoted" >/etc/omarchy.conf - chown 0:0 /etc/omarchy.conf - chmod 0644 /etc/omarchy.conf -} -write_authorized_source_root "$test_tmp" - -cat >"$test_home/launch-persistent-attack" <<'ATTACK' -#!/bin/bash -/usr/bin/setsid --fork /bin/bash -c ' - printf "%s\n" "$$" >"$3" - for (( attempt = 0; attempt < 200; attempt++ )); do - if /usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 0600 "$1" "$2" 2>/dev/null; then - exit 0 - fi - /usr/bin/sleep 0.01 - done -' omarchy-hook-child "$HOME/payload" "$1" "$2" -ATTACK -cat >"$test_home/payload" <<'PAYLOAD' -RUN+="/tmp/update-hook-payload" -PAYLOAD -chown 1000:1000 "$test_home/launch-persistent-attack" "$test_home/payload" -chmod 0700 "$test_home/launch-persistent-attack" -chmod 0600 "$test_home/payload" - -cat >"$stub_bin/omarchy-update-lock" <<'STUB' -#!/bin/bash -[[ ${1:-} == held ]] -STUB -cat >"$stub_bin/omarchy-update-system-pkgs" <<'STUB' -#!/bin/bash -[[ ${TEST_SKIP_UPDATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test -STUB -cat >"$stub_bin/omarchy-update-orphan-pkgs" <<'STUB' -#!/bin/bash -[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test -STUB -cat >"$stub_bin/omarchy-update-aur-pkgs" <<'STUB' -#!/bin/bash -[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test -STUB -cat >"$stub_bin/omarchy-update-mise" <<'STUB' -#!/bin/bash -sudo /usr/bin/true 2>/dev/null && exit 97 -"$HOME/launch-persistent-attack" "$TEST_MISE_VICTIM" "$TEST_MISE_PID" -STUB -cat >"$stub_bin/omarchy-update-restart" <<'STUB' -#!/bin/bash -printf 'restart:%s\n' "$1" >>"$TEST_HOOK_LOG" -if [[ $1 == --services-only && ${TEST_SKIP_LATE_AUTH:-0} != 1 ]]; then - sudo --authenticate-for-test -fi -STUB -cat >"$stub_bin/omarchy-update-confirm" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/omarchy-migrate" <<'STUB' -#!/bin/bash -if [[ ${TEST_REAL_MIGRATE:-0} == 1 ]]; then - exec "$TEST_ROOT/bin/omarchy-migrate" -fi -if [[ ${TEST_FAILING_STAGE:-} == signal ]]; then - kill -TERM "$PPID" - sleep 0.1 -fi -[[ ${TEST_FAILING_STAGE:-} != migration ]] -STUB -cat >"$stub_bin/omarchy-hook" <<'STUB' -#!/bin/bash -exec bash "$TEST_ROOT/bin/omarchy-hook" "$@" -STUB -cat >"$stub_bin/omarchy-dev-unlink" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/omarchy-state" <<'STUB' -#!/bin/bash -exit 0 -STUB -for command in \ - omarchy-update-requires-free-space omarchy-update-pkg-prune omarchy-snapshot \ - omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring \ - omarchy-update-analyze-logs omarchy-update-status; do - cat >"$stub_bin/$command" <<'STUB' -#!/bin/bash -exit 0 -STUB +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:omarchy-update-stay-awake stop','step:yay','step:omarchy-hook post-update','step:omarchy-update-mise','step:omarchy-update-restart --reboot-only']] +assert positions==sorted(positions), s +assert not any(line.startswith('sudo -N ') for line in s[positions[3]:]), s +PY + pass "update $args runs privileged phases before hooks and exits cold" done -cat >"$stub_bin/cp" <<'STUB' -#!/bin/bash -printf 'cp:%s\n' "$*" >>"$TEST_SUDO_EVENT_LOG" -exit 0 -STUB -cat >"$stub_bin/pacman" <<'STUB' -#!/bin/bash -[[ -z ${TEST_PACMAN_DELAY:-} ]] || /usr/bin/sleep "$TEST_PACMAN_DELAY" -exit "${TEST_PACMAN_STATUS:-0}" -STUB -chmod 0755 "$stub_bin"/* -chmod 4755 "$stub_bin/sudo" -mount --bind "$stub_bin/omarchy-dev-unlink" /usr/bin/omarchy-dev-unlink -mount --bind "$stub_bin/omarchy-state" /usr/bin/omarchy-state -mount --bind "$stub_bin/pacman" /usr/bin/pacman -channel_paths_bound=1 -evil_update_bin="$test_home/evil-update-bin" -evil_update_root="$test_home/evil-update-root" -evil_update_marker="$test_home/evil-update-ran" -mkdir -p "$evil_update_bin" "$evil_update_root/migrations" -for command in script omarchy-migrate omarchy-update-system-pkgs; do - cat >"$evil_update_bin/$command" <<'STUB' -#!/bin/bash -touch "$TEST_EVIL_UPDATE_MARKER" -exit 97 -STUB +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" + pass "update revokes credentials after $step fails" done -cat >"$evil_update_bin/omarchy-dev-unlink" <<'STUB' -#!/bin/bash -touch "$TEST_EVIL_CHANNEL_HELPER_MARKER" -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_CHANNEL_VICTIM" -STUB -cat >"$evil_update_root/migrations/9999999999.sh" <<'STUB' -#!/bin/bash -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_MIGRATION_VICTIM" -touch "$TEST_EVIL_UPDATE_MARKER" -STUB -chown -R 1000:1000 "$evil_update_bin" "$evil_update_root" -chmod 0755 "$evil_update_bin"/* "$evil_update_root/migrations/9999999999.sh" -write_attack_hook() { - local hook_name="$1" +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" - mkdir -p "$test_home/.config/omarchy/hooks/$hook_name.d" - cat >"$test_home/.config/omarchy/hooks/$hook_name" <<'HOOK' -#!/bin/bash -printf 'file:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" 2>/dev/null || true -"$HOME/launch-persistent-attack" "$TEST_PERSISTENT_VICTIM" "$TEST_PERSISTENT_PID" -HOOK - cat >"$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" <<'HOOK' -#!/bin/bash -printf 'directory:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_DIR_VICTIM" 2>/dev/null || true -HOOK - chown -R 1000:1000 "$test_home/.config/omarchy/hooks/$hook_name" \ - "$test_home/.config/omarchy/hooks/$hook_name.d" - chmod 0700 "$test_home/.config/omarchy/hooks/$hook_name" \ - "$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" -} +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_case() { - local pid_file - for pid_file in "$test_home"/*.pid; do - if [[ -s $pid_file ]]; then - persistent_pids+=("$(<"$pid_file")") - kill "$(<"$pid_file")" 2>/dev/null || true - fi - done - rm -f "$test_home"/*.pid "$token" "$root_dir"/* - : >"$event_log" - setpriv --reuid 1000 --regid 1000 --clear-groups /usr/bin/truncate -s 0 "$hook_log" -} +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" -run_as_user() { - setpriv --reuid 1000 --regid 1000 --clear-groups \ - env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$ROOT" \ - TEST_ROOT="$ROOT" TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ - TEST_HOOK_LOG="$hook_log" "$@" -} +for mode in normal defer-hook run-deferred; do + reset_boundary + "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable "$mode" >"$boundary_tmp/output" 2>&1 || fail "refresh $mode failed" "$(<"$boundary_tmp/output")" + assert_boundary_cold "refresh $mode" + python3 - "$SUDO_TEST_LOG" "$mode" <<'PY' +import sys +s=open(sys.argv[1]).read().splitlines();mode=sys.argv[2] +hooks=[i for i,l in enumerate(s) if l.startswith('step:omarchy-hook')] +priv=[i for i,l in enumerate(s) if l.startswith('sudo -N ')] +assert bool(hooks)==(mode!='defer-hook'), s +assert bool(priv)==(mode!='run-deferred'), s +if hooks: assert not any(i>hooks[0] for i in priv),s +PY + pass "refresh $mode preserves the final cold hook boundary" +done -authenticate_for_test() { - TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ - /usr/bin/sudo --authenticate-for-test -} +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 -wait_for_persistent_attempts() { - local pid_file="$1" - local pid="" - for (( attempt = 0; attempt < 100; attempt++ )); do - [[ -s $pid_file ]] && break - sleep 0.01 - done - [[ -s $pid_file ]] || fail "detached hook child did not start" - pid=$(<"$pid_file") - persistent_pids+=("$pid") - sleep 0.2 -} - -assert_hook_sandboxed() { - local direct_victim="$1" - local directory_victim="$2" - local persistent_victim="$3" - - [[ ! -e $direct_victim && ! -e $directory_victim && ! -e $persistent_victim ]] || - fail "hook code reused an Omarchy sudo credential" - grep -qxF 'file:1000' "$hook_log" || fail "the regular hook did not run as the desktop user" - grep -qxF 'directory:1000' "$hook_log" || fail "the hook-directory entry did not run as the desktop user" - [[ ! -e $token ]] || fail "the workflow left its modeled sudo credential live" -} - -write_attack_hook post-update -update_victim="$root_dir/80-update-hook.rules" -update_dir_victim="$root_dir/81-update-hook-dir.rules" -update_persistent_victim="$root_dir/82-update-hook-child.rules" -mise_victim="$root_dir/83-mise-child.rules" -reset_case -set +e -run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ - TEST_EVIL_UPDATE_MARKER="$evil_update_marker" \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/update.out" 2>"$test_tmp/update.err" -status=$? -set -e -(( status == 0 )) || fail "isolated unattended update failed" "$(<"$test_tmp/update.err")" -wait_for_persistent_attempts "$test_home/update.pid" -wait_for_persistent_attempts "$test_home/mise.pid" -assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" -[[ ! -e $mise_victim ]] || fail "detached mise code observed a later update authorization" -[[ ! -e $evil_update_marker ]] || fail "update trusted an inherited PATH or OMARCHY_PATH override" -grep -qxF 'restart:--services-only' "$hook_log" || fail "privileged restart phase did not run before user code" -grep -qxF 'restart:--reboot-only' "$hook_log" || fail "reboot-only phase did not run after user code" -pass "update leaves no later sudo authentication for mise or persistent hook children" - -# OM-SEC-14 ends at the final cold hook boundary. Later sections exercise -# separate migration, restart-marker, channel, and installer findings in their -# own PRs. -exit 0 - -# Exercise the real migration dispatcher separately from PATH spoofing. An old -# update inherited the evil root here and ran its migration with the live -# system-package credential; the fixed update exports the authorized root. -evil_migration_victim="$root_dir/90-evil-migration.rules" -reset_case -set +e -run_as_user env OMARCHY_PATH="$evil_update_root" TEST_REAL_MIGRATE=1 \ - TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_MIGRATION_STATE="$test_home/migration-state" OMARCHY_UPDATE_LOGGED=1 \ - "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-root-spoof.out" 2>"$test_tmp/update-root-spoof.err" -status=$? -set -e -(( status == 0 )) || fail "authorized-root update with real migration dispatcher failed" -[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || - fail "update dispatched a migration from inherited OMARCHY_PATH" -pass "update rejects inherited OMARCHY_PATH for real migration dispatch" - -# The login-notification workflow invokes omarchy-migrate directly, outside an -# update that already normalized OMARCHY_PATH. It must independently reject an -# inherited attacker tree rather than running the migration placed there. -reset_case -set +e -run_as_user env OMARCHY_PATH="$evil_update_root" \ - TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ - OMARCHY_MIGRATION_STATE="$test_home/direct-migration-state" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/direct-migrate.out" 2>"$test_tmp/direct-migrate.err" -status=$? -set -e -(( status == 0 )) || fail "direct authorized migration dispatch failed" -[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || - fail "direct migration dispatch trusted inherited OMARCHY_PATH" -pass "direct migration dispatch derives its source from root-owned configuration" - -# Reproduce the real historical ordering that exposed the remaining gap: the -# mise-wrapper migration can execute user tooling at 1784909971, while the -# 1784914435 migration invokes sudo later. A following migration also invokes -# the real absolute-path package helper, which cannot be protected by PATH -# alone. Every later authorization must inherit the exported no-update policy. -mkdir -p "$test_tmp/migrations" "$test_home/.local/bin" -chmod 0755 "$test_tmp/migrations" -cp "$ROOT/migrations/1784909971.sh" "$test_tmp/migrations/1784909971.sh" -cp "$ROOT/migrations/1784914435.sh" "$test_tmp/migrations/1784914435.sh" -cat >"$test_tmp/migrations/1784914436.sh" <<'STUB' -#!/bin/bash -/usr/bin/omarchy-pkg-add migration-security-fixture -STUB -chmod 0644 "$test_tmp/migrations/1784909971.sh" "$test_tmp/migrations/1784914435.sh" \ - "$test_tmp/migrations/1784914436.sh" -cat >"$test_home/.local/bin/legacy-mise-wrapper" <<'STUB' -#!/bin/bash -mise use -g "github:attacker/tool" -exec "attacker-tool" "$@" -STUB -cat >"$stub_bin/omarchy-mise-install" <<'STUB' -#!/bin/bash -"$HOME/launch-persistent-attack" "$TEST_MIGRATION_VICTIM" "$TEST_MIGRATION_PID" -STUB -cat >"$stub_bin/nmcli" <<'STUB' -#!/bin/bash -printf '%s\n' "$(id -u)" >"$TEST_PRIV_MIGRATION_MARKER" -STUB -cat >"$stub_bin/omarchy-notification-dismiss" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$stub_bin/omarchy-mise-install" "$stub_bin/nmcli" \ - "$stub_bin/omarchy-notification-dismiss" "$test_home/.local/bin/legacy-mise-wrapper" -chown -R 1000:1000 "$test_home/.local" -cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$stub_bin/omarchy-pkg-missing" -mount --bind "$ROOT/bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add -mount --bind "$stub_bin/omarchy-pkg-missing" /usr/bin/omarchy-pkg-missing -migration_pkg_paths_bound=1 -migration_victim="$root_dir/95-migration-child.rules" -migration_marker="$root_dir/96-privileged-migration-ran" -reset_case -set +e -run_as_user env OMARCHY_PATH="$evil_update_root" \ - TEST_MIGRATION_VICTIM="$migration_victim" TEST_MIGRATION_PID="$test_home/migration.pid" \ - TEST_PRIV_MIGRATION_MARKER="$migration_marker" TEST_PACMAN_DELAY=0.2 \ - OMARCHY_MIGRATION_STATE="$test_home/mixed-migration-state" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/mixed-migrate.out" 2>"$test_tmp/mixed-migrate.err" -status=$? -set -e -(( status == 0 )) || fail "real mixed-trust migration sequence failed" "$(<"$test_tmp/mixed-migrate.err")" -wait_for_persistent_attempts "$test_home/migration.pid" -[[ -f $migration_marker && $(<"$migration_marker") == 0 ]] || fail "later privileged migration did not run as root" -[[ ! -e $migration_victim && ! -e $token ]] || fail "mise migration child reused a later migration authorization" -(( $(grep -c '^grant-no-update$' "$event_log") >= 2 )) || - fail "direct package-helper migration did not inherit the no-update policy" -pass "real mise-before-sudo and absolute package-helper migrations publish no reusable timestamp" -umount /usr/bin/omarchy-pkg-missing -umount /usr/bin/omarchy-pkg-add -migration_pkg_paths_bound=0 - -# Bash can import both BASH_ENV startup code and exported functions before an -# ordinary script body. An exported sudo function used to bypass the PATH -# wrapper in a later helper, publishing a global token to a BASH_ENV child that -# had started before migration invalidation. Exercise both injection channels -# through a normal-shebang child of the privileged migration shell. -cat >"$test_tmp/migrations/9999999998.sh" <<'STUB' -#!/bin/bash -"$HOME/launch-persistent-attack" "$TEST_BASH_STARTUP_VICTIM" "$TEST_BASH_STARTUP_PID" -omarchy-exported-function-auth -STUB -cat >"$stub_bin/omarchy-exported-function-auth" <<'STUB' -#!/bin/bash -sudo --authenticate-for-test -STUB -cat >"$test_home/bash-env-attack" <<'STUB' -#!/bin/bash -if [[ ! -e $TEST_BASH_ENV_MARKER ]]; then - /usr/bin/touch "$TEST_BASH_ENV_MARKER" - "$HOME/launch-persistent-attack" "$TEST_BASH_ENV_VICTIM" "$TEST_BASH_ENV_PID" -fi -STUB -chmod 0755 "$test_tmp/migrations/9999999998.sh" "$stub_bin/omarchy-exported-function-auth" -chown 1000:1000 "$test_home/bash-env-attack" -chmod 0600 "$test_home/bash-env-attack" -bash_startup_victim="$root_dir/99-exported-function-child.rules" -bash_env_victim="$root_dir/100-bash-env-child.rules" -bash_env_marker="$test_home/bash-env-ran" -reset_case -set +e -run_as_user env \ - 'BASH_FUNC_sudo%%=() { /usr/bin/sudo "$@"; }' \ - TEST_MULTILINE_ENV=$'value\nBASH_FUNC_fake%%=not-an-environment-record' \ - BASH_ENV="$test_home/bash-env-attack" \ - TEST_BASH_ENV_MARKER="$bash_env_marker" \ - TEST_BASH_ENV_VICTIM="$bash_env_victim" TEST_BASH_ENV_PID="$test_home/bash-env.pid" \ - TEST_BASH_STARTUP_VICTIM="$bash_startup_victim" TEST_BASH_STARTUP_PID="$test_home/bash-startup.pid" \ - OMARCHY_MIGRATION_STATE="$test_home/bash-startup-migration-state" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/bash-startup.out" 2>"$test_tmp/bash-startup.err" -status=$? -set -e -(( status == 0 )) || fail "migration rejected a sanitized Bash startup environment" "$(<"$test_tmp/bash-startup.err")" -wait_for_persistent_attempts "$test_home/bash-startup.pid" -sleep 0.2 -[[ ! -e $bash_env_marker && ! -e $bash_env_victim ]] || - fail "BASH_ENV ran before or beneath the migration security boundary" -[[ ! -e $bash_startup_victim && ! -e $token ]] || - fail "an exported sudo function published a reusable migration credential" -grep -q '^grant-no-update$' "$event_log" || fail "sanitized helper did not use sudo --no-update" -grep -qxF 'exec /usr/bin/sudo -N -- "$@"' "$ROOT/default/omarchy/sudo-no-update/sudo" || - fail "no-update sudo wrapper omitted the option terminator" -pass "Bash startup injection cannot bypass the no-update sudo boundary" - -# Invoking a mixed-trust entrypoint with an ordinary explicit Bash bypasses its -# shebang. A BASH_ENV can erase its own environment record and retain a DEBUG -# trap, so environment-record cleanup alone is not a sufficient startup gate. -# The exact interpreter argv/privileged-mode check must reject this process -# before update authentication, leaving even its already-detached child cold. -cat >"$test_home/self-erasing-bash-env" <<'STUB' -#!/bin/bash -unset BASH_ENV ENV -trap ' - if [[ ! -e $TEST_DEBUG_TRAP_MARKER ]]; then - /usr/bin/touch "$TEST_DEBUG_TRAP_MARKER" - "$HOME/launch-persistent-attack" "$TEST_DEBUG_TRAP_VICTIM" "$TEST_DEBUG_TRAP_PID" - fi -' DEBUG -STUB -chown 1000:1000 "$test_home/self-erasing-bash-env" -chmod 0600 "$test_home/self-erasing-bash-env" -debug_trap_marker="$test_home/debug-trap-ran" -debug_trap_victim="$root_dir/101-debug-trap-child.rules" -reset_case -set +e -run_as_user env BASH_ENV="$test_home/self-erasing-bash-env" \ - TEST_DEBUG_TRAP_MARKER="$debug_trap_marker" TEST_DEBUG_TRAP_VICTIM="$debug_trap_victim" \ - TEST_DEBUG_TRAP_PID="$test_home/debug-trap.pid" OMARCHY_UPDATE_LOGGED=1 \ - /usr/bin/bash "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/unsafe-bash.out" 2>"$test_tmp/unsafe-bash.err" -status=$? -set -e -(( status == 126 )) || fail "update did not reject an unsafe explicit Bash interpreter" -[[ -e $debug_trap_marker ]] || fail "self-erasing BASH_ENV regression did not install its DEBUG trap" -wait_for_persistent_attempts "$test_home/debug-trap.pid" -[[ ! -e $debug_trap_victim && ! -e $token ]] || - fail "unsafe Bash startup reached update authentication" -! grep -qE '^(authenticate|authenticate-command|grant|grant-no-update)$' "$event_log" || - fail "unsafe Bash startup reached privileged update work" -grep -q 'unsafe Bash startup' "$test_tmp/unsafe-bash.err" || - fail "unsafe Bash startup rejection lacked a diagnostic" -pass "self-erasing BASH_ENV and DEBUG traps cannot cross the interpreter gate" - -# Model a hostile yay configuration that selects absolute /usr/bin/sudo and a -# refresh loop. The real AUR helper must override both on its command line, so -# even a migration child already polling the global token sees no credential. -cat >"$stub_bin/omarchy-pkg-aur-accessible" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/yay" <<'STUB' -#!/bin/bash -sudo_command=/usr/bin/sudo -sudoflags="" -sudoloop=true -while (($#)); do - case "$1" in - --sudo) - sudo_command="$2" - shift 2 - ;; - --sudoloop=false) - sudoloop=false - shift - ;; - --sudoflags=-N) - sudoflags=-N - shift - ;; - *) - shift - ;; +# 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 -printf 'sudo=%s sudoflags=%s sudoloop=%s\n' "$sudo_command" "$sudoflags" "$sudoloop" >"$TEST_YAY_LOG" -"$sudo_command" $sudoflags --authenticate-for-test -STUB -chmod 0755 "$stub_bin/omarchy-pkg-aur-accessible" "$stub_bin/yay" -mount --bind "$stub_bin/omarchy-pkg-aur-accessible" /usr/bin/omarchy-pkg-aur-accessible -mount --bind "$stub_bin/yay" /usr/bin/yay -aur_paths_bound=1 -yay_victim="$root_dir/97-yay-override-child.rules" -reset_case -run_as_user env TEST_YAY_LOG="$test_home/yay.log" \ - "$test_home/launch-persistent-attack" "$yay_victim" "$test_home/yay-child.pid" -wait_for_persistent_attempts "$test_home/yay-child.pid" -run_as_user env OMARCHY_PATH="$test_tmp" OMARCHY_SUDO_NO_UPDATE=1 TEST_YAY_LOG="$test_home/yay.log" \ - "$ROOT/bin/omarchy-update-aur-pkgs" >"$test_tmp/yay.out" 2>"$test_tmp/yay.err" -sleep 0.2 -grep -qxF "sudo=/usr/bin/sudo sudoflags=-N sudoloop=false" "$test_home/yay.log" || - fail "AUR update did not override hostile yay sudo settings" -[[ ! -e $yay_victim && ! -e $token ]] || fail "hostile yay sudo configuration published a reusable timestamp" -pass "AUR updates force no-update sudo and disable yay's credential loop" -# A pre-existing credential and skipped package paths exercise the interactive -# branch independently of authority acquired by update helpers. -reset_case -authenticate_for_test -set +e -run_as_user env TEST_SKIP_UPDATE_AUTH=1 TEST_SKIP_LATE_AUTH=1 \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" \ - >"$test_tmp/update-interactive.out" 2>"$test_tmp/update-interactive.err" -status=$? -set -e -(( status == 0 )) || fail "isolated interactive update failed" -wait_for_persistent_attempts "$test_home/update.pid" -assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" -pass "interactive update invalidates a pre-existing credential before user code" - -for failing_stage in migration signal; do - reset_case - set +e - run_as_user env TEST_FAILING_STAGE="$failing_stage" TEST_SKIP_LATE_AUTH=1 \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/update-$failing_stage.out" 2>"$test_tmp/update-$failing_stage.err" - status=$? - set -e - (( status != 0 )) || fail "update $failing_stage case unexpectedly succeeded" - [[ ! -e $token ]] || fail "update $failing_stage exit left its credential live" - [[ ! -s $hook_log ]] || fail "update $failing_stage case reached user-controlled stages" +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 -pass "failed and signaled updates invalidate credentials before user code" -reset_case -set +e -run_as_user env TEST_SUDO_NO_N=1 OMARCHY_UPDATE_LOGGED=1 \ - "$ROOT/bin/omarchy-update" -y >"$test_tmp/no-update-unsupported.out" 2>"$test_tmp/no-update-unsupported.err" -status=$? -set -e -(( status != 0 )) || fail "update accepted sudo without --no-update support" -! grep -qE '^(authenticate|grant)' "$event_log" || fail "unsupported sudo reached privileged update work" -grep -q 'does not support --no-update' "$test_tmp/no-update-unsupported.err" || - fail "unsupported sudo failure did not explain the missing security primitive" -pass "update fails closed before privileged work when sudo lacks --no-update" +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" -# The config parser is the authority for all three scoped commands. Exercise a -# different unsafe shape through each copy before restoring the valid config. -chmod 0666 /etc/omarchy.conf -if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/untrusted-update.out" 2>"$test_tmp/untrusted-update.err"; then - fail "update accepted a writable source-root authorization" -fi - -/usr/bin/mv /etc/omarchy.conf /etc/omarchy.real -/usr/bin/ln -s /etc/omarchy.real /etc/omarchy.conf -if run_as_user "$ROOT/bin/omarchy-refresh-pacman" stable \ - >"$test_tmp/untrusted-refresh.out" 2>"$test_tmp/untrusted-refresh.err"; then - fail "pacman refresh accepted a symlinked source-root authorization" -fi -/usr/bin/rm /etc/omarchy.conf -/usr/bin/mv /etc/omarchy.real /etc/omarchy.conf - -chown 1000:1000 /etc/omarchy.conf -if run_as_user "$ROOT/bin/omarchy-update-restart" --services-only \ - >"$test_tmp/untrusted-restart.out" 2>"$test_tmp/untrusted-restart.err"; then - fail "update restart accepted a non-root source-root authorization" -fi -write_authorized_source_root "$test_tmp" -pass "update commands reject writable, symlinked, and non-root source-root authorization" - -# A root-owned config may authorize a development checkout, but not a tree -# another local account (or every account) can rewrite. Exercise both unsafe -# directory-chain shapes before restoring the valid authorized fixture. -writable_source_root="$test_tmp/writable-source-root" -foreign_source_root="$test_tmp/foreign-source-root" -mkdir -p "$writable_source_root" "$foreign_source_root" -chown 1000:1000 "$writable_source_root" -chmod 0777 "$writable_source_root" -chown 1001:1001 "$foreign_source_root" -chmod 0755 "$foreign_source_root" - -write_authorized_source_root "$writable_source_root" -if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/writable-source.out" 2>"$test_tmp/writable-source.err"; then - fail "update accepted a group/world-writable authorized source tree" -fi -grep -q 'untrusted Omarchy source root' "$test_tmp/writable-source.err" || - fail "writable source-root rejection happened after the trust parser" - -write_authorized_source_root "$foreign_source_root" -if run_as_user "$ROOT/bin/omarchy-migrate" --pending \ - >"$test_tmp/foreign-source.out" 2>"$test_tmp/foreign-source.err"; then - fail "migration runner accepted a foreign-owned authorized source tree" -fi -grep -q 'untrusted Omarchy source root' "$test_tmp/foreign-source.err" || - fail "foreign source-root rejection happened after the trust parser" -write_authorized_source_root "$test_tmp" -pass "authorized source roots reject foreign-owned and group/world-writable path components" - -# The restart marker is attacker-writable, but its value is now an allowlisted -# selector into the configured Omarchy tree rather than a command resolved by -# PATH. A dev-linked tree is honored only through root-owned /etc/omarchy.conf. -reset_case -restart_home="$test_tmp/restart-home" -restart_log="$test_tmp/restart.log" -mkdir -p "$restart_home/.local/state/omarchy" -touch "$restart_log" -chown -R 1000:1000 "$restart_home" -chown 1000:1000 "$restart_log" -touch "$restart_home/.local/state/omarchy/restart-btop-required" \ - "$restart_home/.local/state/omarchy/restart-evil-required" -chown 1000:1000 "$restart_home/.local/state/omarchy"/* -cat >"$stub_bin/omarchy-restart-btop" <<'STUB' -#!/bin/bash -echo trusted-btop >>"$TEST_RESTART_LOG" -STUB -cat >"$stub_bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash -echo trusted-shell >>"$TEST_RESTART_LOG" -STUB -evil_bin="$test_home/evil-bin" -mkdir -p "$evil_bin" -cat >"$evil_bin/omarchy-restart-btop" <<'STUB' -#!/bin/bash -echo path-btop >>"$TEST_RESTART_LOG" -STUB -cat >"$evil_bin/omarchy-restart-evil" <<'STUB' -#!/bin/bash -echo path-evil >>"$TEST_RESTART_LOG" -sudo /usr/bin/true -STUB -chown -R 1000:1000 "$evil_bin" -chmod 0755 "$stub_bin/omarchy-restart-btop" "$stub_bin/omarchy-restart-shell" "$evil_bin"/* -run_as_user env HOME="$restart_home" PATH="$evil_bin:$stub_bin:/usr/bin:/bin" \ - OMARCHY_PATH="$test_home/evil-root" TEST_RESTART_LOG="$restart_log" \ - "$ROOT/bin/omarchy-update-restart" --services-only >"$test_tmp/restart.out" 2>"$test_tmp/restart.err" -grep -qxF trusted-btop "$restart_log" || fail "allowed marker did not use the configured Omarchy command" -grep -qxF trusted-shell "$restart_log" || fail "shell restart did not use the configured Omarchy command" -! grep -q '^path-' "$restart_log" || fail "restart marker resolved an attacker PATH command" -[[ ! -e $restart_home/.local/state/omarchy/restart-evil-required ]] || fail "unsupported restart marker was retained" -pass "restart markers use an allowlist and fixed configured command paths" - -special_root="$test_tmp/dev root\\checkout\$cash" -special_home="$test_tmp/special-home" -special_log="$test_tmp/special.log" -mkdir -p "$special_root/bin" "$special_home/.local/state/omarchy" -touch "$special_home/.local/state/omarchy/restart-btop-required" "$special_log" -chown -R 1000:1000 "$special_home" "$special_log" -cat >"$special_root/bin/omarchy-restart-btop" <<'STUB' -#!/bin/bash -echo special-btop >>"$TEST_RESTART_LOG" -STUB -cat >"$special_root/bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$special_root/bin"/* -chown -R 1000:1000 "$special_root" -write_authorized_source_root "$special_root" -run_as_user env HOME="$special_home" TEST_RESTART_LOG="$special_log" \ - "$ROOT/bin/omarchy-update-restart" --services-only \ - >"$test_tmp/special-root.out" 2>"$test_tmp/special-root.err" -grep -qxF special-btop "$special_log" || fail "authorized quoted dev root did not dispatch its restart helper" -write_authorized_source_root "$test_tmp" -pass "source-root authorization decodes spaces, backslashes, and dollar signs" - -# The legacy pre-refresh hook now runs only after pacman; a detached child can -# no longer wait for a later authentication in either tty or global mode. -write_attack_hook pre-refresh-pacman -refresh_victim="$root_dir/84-refresh-hook.rules" -refresh_dir_victim="$root_dir/85-refresh-hook-dir.rules" -refresh_persistent_victim="$root_dir/86-refresh-hook-child.rules" -reset_case -authenticate_for_test -cat >"$evil_bin/cp" <<'STUB' -#!/bin/bash -touch "$TEST_EVIL_REFRESH_MARKER" -exit 97 -STUB -chmod 0755 "$evil_bin/cp" -evil_refresh_marker="$test_home/evil-refresh-ran" -set +e -run_as_user env PATH="$evil_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ - TEST_EVIL_REFRESH_MARKER="$evil_refresh_marker" \ - TEST_ROOT_VICTIM="$refresh_victim" TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" \ - TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" TEST_PERSISTENT_PID="$test_home/refresh.pid" \ - "$ROOT/bin/omarchy-refresh-pacman" stable >"$test_tmp/refresh.out" 2>"$test_tmp/refresh.err" -status=$? -set -e -(( status == 0 )) || fail "isolated pacman refresh failed" "$(<"$test_tmp/refresh.err")" -wait_for_persistent_attempts "$test_home/refresh.pid" -assert_hook_sandboxed "$refresh_victim" "$refresh_dir_victim" "$refresh_persistent_victim" -[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh resolved cp through caller PATH" -/usr/bin/cmp -s "$test_tmp/default/pacman/pacman-stable.conf" /etc/pacman.conf || - fail "pacman refresh did not copy config from the authorized source root" -[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh copied from inherited OMARCHY_PATH or PATH" -pass "pacman refresh runs its legacy hook only after all privileged work" - -reset_case -authenticate_for_test -set +e -run_as_user env TEST_PACMAN_STATUS=1 TEST_ROOT_VICTIM="$refresh_victim" \ - TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" \ - TEST_PERSISTENT_PID="$test_home/refresh.pid" "$ROOT/bin/omarchy-refresh-pacman" stable \ - >"$test_tmp/refresh-fail.out" 2>"$test_tmp/refresh-fail.err" -status=$? -set -e -(( status != 0 )) || fail "failing pacman refresh unexpectedly succeeded" -[[ ! -e $token ]] || fail "failed pacman refresh left its credential live" -[[ ! -s $hook_log ]] || fail "failed pacman transaction reached the refresh hook" -pass "failed pacman refresh invalidates and does not run its hook" - -# Channel switching is a composite refresh caller: after refreshing it -# authenticates for the package swap and runs the full update. Exercise the -# real channel, refresh, update, and hook commands against the global-token -# model. A detached legacy refresh-hook child must not start until that entire -# chain has finished. -cat >"$test_home/.config/omarchy/hooks/post-update" <<'HOOK' -#!/bin/bash -printf 'post-update:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" -HOOK -chown 1000:1000 "$test_home/.config/omarchy/hooks/post-update" -chmod 0700 "$test_home/.config/omarchy/hooks/post-update" -channel_victim="$root_dir/91-channel-refresh-hook.rules" -channel_dir_victim="$root_dir/92-channel-refresh-dir.rules" -channel_persistent_victim="$root_dir/93-channel-refresh-child.rules" -channel_mise_victim="$root_dir/94-channel-mise-child.rules" -reset_case -set +e -evil_channel_helper_marker="$test_home/evil-channel-helper-ran" -evil_channel_helper_victim="$root_dir/98-channel-path-helper.rules" -run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" \ - TEST_EVIL_CHANNEL_HELPER_MARKER="$evil_channel_helper_marker" \ - TEST_EVIL_CHANNEL_VICTIM="$evil_channel_helper_victim" \ - TEST_ROOT_VICTIM="$channel_victim" TEST_ROOT_DIR_VICTIM="$channel_dir_victim" \ - TEST_PERSISTENT_VICTIM="$channel_persistent_victim" TEST_PERSISTENT_PID="$test_home/channel.pid" \ - TEST_MISE_VICTIM="$channel_mise_victim" TEST_MISE_PID="$test_home/channel-mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-channel-set" stable \ - >"$test_tmp/channel.out" 2>"$test_tmp/channel.err" -status=$? -set -e -(( status == 0 )) || fail "isolated channel switch failed" "$(<"$test_tmp/channel.err")" -wait_for_persistent_attempts "$test_home/channel.pid" -wait_for_persistent_attempts "$test_home/channel-mise.pid" -assert_hook_sandboxed "$channel_victim" "$channel_dir_victim" "$channel_persistent_victim" -[[ ! -e $channel_mise_victim ]] || fail "channel mise child reused a later refresh-hook credential" -[[ ! -e $evil_channel_helper_marker && ! -e $evil_channel_helper_victim ]] || - fail "channel switch resolved a post-pacman helper through caller PATH" -[[ $(grep -c '^file:1000$' "$hook_log") == 1 ]] || fail "channel switch did not run the deferred refresh hook exactly once" -pass "channel switching defers its refresh hook past every later authentication" - -cat >"$stub_bin/omarchy-launch-floating-terminal-with-presentation" <<'STUB' -#!/bin/bash -exec bash -c "$1" -STUB -cat >"$stub_bin/omarchy-pkg-add" <<'STUB' -#!/bin/bash -[[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]] || exit 98 -/usr/bin/sudo -N -- /usr/bin/true -exit "${TEST_PKG_STATUS:-0}" -STUB -cat >"$stub_bin/omarchy-font-set" <<'STUB' -#!/bin/bash -exec bash "$TEST_ROOT/bin/omarchy-font-set" "$@" -STUB -cat >"$stub_bin/fc-list" <<'STUB' -#!/bin/bash -echo 'Example Family' -STUB -cat >"$stub_bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/pgrep" <<'STUB' -#!/bin/bash -exit 1 -STUB -cat >"$stub_bin/sleep" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$stub_bin"/* -chmod 4755 "$stub_bin/sudo" -mount --bind "$stub_bin/omarchy-launch-floating-terminal-with-presentation" \ - /usr/bin/omarchy-launch-floating-terminal-with-presentation -mount --bind "$stub_bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add -mount --bind "$stub_bin/omarchy-font-set" /usr/bin/omarchy-font-set -font_paths_bound=1 - -write_attack_hook font-set -font_victim="$root_dir/87-font-hook.rules" -font_dir_victim="$root_dir/88-font-hook-dir.rules" -font_persistent_victim="$root_dir/89-font-hook-child.rules" -reset_case -set +e -run_as_user env TEST_ROOT_VICTIM="$font_victim" TEST_ROOT_DIR_VICTIM="$font_dir_victim" \ - TEST_PERSISTENT_VICTIM="$font_persistent_victim" TEST_PERSISTENT_PID="$test_home/font.pid" \ - "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ - >"$test_tmp/font.out" 2>"$test_tmp/font.err" -status=$? -set -e -(( status == 0 )) || fail "isolated font install failed" "$(<"$test_tmp/font.err")" -wait_for_persistent_attempts "$test_home/font.pid" -assert_hook_sandboxed "$font_victim" "$font_dir_victim" "$font_persistent_victim" -pass "font installation invalidates before file, directory, and persistent hooks" - -reset_case -set +e -run_as_user env TEST_PKG_STATUS=1 TEST_ROOT_VICTIM="$font_victim" \ - TEST_ROOT_DIR_VICTIM="$font_dir_victim" TEST_PERSISTENT_VICTIM="$font_persistent_victim" \ - TEST_PERSISTENT_PID="$test_home/font.pid" \ - "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ - >"$test_tmp/font-fail.out" 2>"$test_tmp/font-fail.err" -status=$? -set -e -(( status != 0 )) || fail "failing font package installation unexpectedly succeeded" -[[ ! -e $token ]] || fail "failed font package installation left its credential live" -[[ ! -s $hook_log ]] || fail "failed font package installation reached the hook" -pass "failed font installation invalidates without running its hook" +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" diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index 6c65428d..7348848c 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -4,16 +4,31 @@ 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" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +stub_bin="$SUDO_TEST_ROOT/bin" +test_home="$SUDO_TEST_HOME" runtime_dir="$test_tmp/runtime" -mkdir -p "$stub_bin" "$test_home" "$runtime_dir" +mkdir -p "$runtime_dir" +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 +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 +39,7 @@ write_stub() { local name="$1" local body="$2" + rm -f "$stub_bin/$name" cat >"$stub_bin/$name" <"$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 +85,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 +103,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 @@ -123,11 +141,10 @@ if (( EUID != 0 )); then 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 "$@"' +[[ $1 == "-N" && $2 == "-b" && $3 == "--" ]] || exit 90 +shift 3 +"$@" &' + write_stub pkexec '[[ -z ${PKEXEC_MARKER:-} ]] || touch "$PKEXEC_MARKER"; 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 @@ -137,7 +154,7 @@ exec "$@"' #!/bin/bash omarchy-update-stay-awake start for _ in {1..200}; do - grep -q '^systemd-inhibit ' "$SUDO_LOG" && break + grep -q -- '^-N -b -- ' "$SUDO_LOG" && break sleep 0.05 done SH @@ -146,10 +163,10 @@ SH 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 -- '^-N -b -- ' "$sudo_log" || fail "terminal inhibition authenticates its background command without a reusable timestamp" + grep -q -- '^-N -b -- ' "$sudo_log" || fail "terminal sleep inhibition runs through sudo" [[ ! -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" fi @@ -158,7 +175,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,20 +186,20 @@ 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" @@ -194,7 +211,7 @@ mkdir -p "$stay_awake_helper_state" "$(dirname "$stay_awake_state")" printf '%s\n' "old-update-owner" >"$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" @@ -206,7 +223,7 @@ 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" -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" diff --git a/test/shell.d/update-restart-phases-test.sh b/test/shell.d/update-restart-phases-test.sh new file mode 100755 index 00000000..baddde91 --- /dev/null +++ b/test/shell.d/update-restart-phases-test.sh @@ -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" diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh index edac1209..994dc493 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -2,60 +2,11 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" - -if [[ -z ${OMARCHY_UPDATE_SEQUENCE_NS:-} ]]; then - outer_uid=$(id -u) - outer_gid=$(id -g) - subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) - subgid=$(awk -F: -v group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) - if [[ -z $subuid || -z $subgid ]]; then - pass "no subordinate uid/gid range; skipping authorized update-sequence test" - exit 0 - fi - exec unshare --user --mount \ - --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" \ - --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536" \ - env OMARCHY_UPDATE_SEQUENCE_NS=setup bash "$0" -elif [[ $OMARCHY_UPDATE_SEQUENCE_NS == setup ]]; then - mount -t tmpfs -o mode=0755 tmpfs /run - namespace_tmp=$(mktemp -d -p /run omarchy-update-sequence.XXXXXXXX) - chmod 0755 "$namespace_tmp" - mkdir -p "$namespace_tmp/default/omarchy/sudo-no-update" - cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$namespace_tmp/default/omarchy/sudo-no-update/sudo" - chmod 0755 "$namespace_tmp/default/omarchy/sudo-no-update/sudo" - cat >"$namespace_tmp/fixed-sudo" <<'STUB' -#!/bin/bash -if [[ ${1:-} == "-h" ]]; then - echo 'usage: sudo [-ABbEHkNnPS] command' -fi -exit 0 -STUB - chmod 0755 "$namespace_tmp/fixed-sudo" - mount --bind "$namespace_tmp/fixed-sudo" /usr/bin/sudo - mount -t tmpfs -o mode=0755 tmpfs /etc - printf 'export OMARCHY_PATH="%s"\n' "$namespace_tmp" >/etc/omarchy.conf - chmod 0644 /etc/omarchy.conf - chown -R 1000:1000 "$namespace_tmp" - - set +e - setpriv --reuid 1000 --regid 1000 --clear-groups \ - env OMARCHY_UPDATE_SEQUENCE_NS=run OMARCHY_AUTHORIZED_TEST_ROOT="$namespace_tmp" bash "$0" - status=$? - set -e - - umount /usr/bin/sudo - umount /etc - rm -rf "$namespace_tmp" - umount /run - exit "$status" -fi - -test_tmp="$OMARCHY_AUTHORIZED_TEST_ROOT" -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. @@ -80,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" @@ -96,7 +48,7 @@ run_update() { FAILING_STEP="${FAILING_STEP:-}" \ OMARCHY_UPDATE_LOGGED=1 \ PATH="$stub_bin:$PATH" \ - "$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() {