diff --git a/AGENTS.md b/AGENTS.md index e4a0084eadf..6de19cb9a50 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-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 7e112388508..cf8e515f015 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -1,10 +1,23 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Set the Omarchy package channel. # omarchy:args= # omarchy:requires-sudo=true +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for channel switching." >&2 + exit 126 +fi +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -euo pipefail +omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" +user_path=$PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; } fail() { echo "Error: $*" >&2; exit 1; } @@ -33,9 +46,18 @@ validate_dev_checkout() { } link_dev_checkout() { - local checkout="$1" + local checkout="$1" required [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout" + # Check the destination before changing /etc/omarchy.conf or sudo's path. + # An existing checkout is not pulled automatically and may predate this policy. + for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do + if [[ ! -f $checkout/$required || ! -r $checkout/$required || + ( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then + fail "Update the checkout before switching to dev; missing required update support in $required." + fi + done + omarchy-dev-link "$checkout" --no-reboot } @@ -79,21 +101,27 @@ fi if [[ -n $dev_checkout ]]; then link_dev_checkout "$dev_checkout" export OMARCHY_PATH="$dev_checkout" - export PATH="$OMARCHY_PATH/bin:$PATH" + omarchy_security_enable_no_update_sudo omarchy-state set reboot-required fi -omarchy-refresh-pacman "$pacman_channel" +omarchy-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[@]}" if [[ -z $dev_checkout ]]; then omarchy-dev-unlink --no-reboot export OMARCHY_PATH=/usr/share/omarchy + omarchy_security_enable_no_update_sudo if (( leaving_dev )); then omarchy-state set reboot-required fi fi -omarchy-update -y +OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y + +# No channel-owned privileged work follows the historical refresh hook. +omarchy_security_revoke_sudo_timestamp +PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \ + "$OMARCHY_PATH/bin/omarchy-refresh-pacman" "$pacman_channel" run-deferred diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index e64aae32de6..a32b195be77 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -1,10 +1,29 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Run pending Omarchy migrations. # omarchy:args=[--pending] +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for Omarchy migrations." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 + +if ! omarchy_security_require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for Omarchy migrations." >&2 + exit 126 +fi + set -euo pipefail +omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" + +PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" +export PATH + mode="run" usage() { @@ -28,7 +47,6 @@ while (($#)); do esac done -OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" STATE_DIR="${OMARCHY_MIGRATION_STATE:-$HOME/.local/state/omarchy/migrations}" MIGRATIONS_DIR="$OMARCHY_PATH/migrations" @@ -80,22 +98,36 @@ wait_for_pacman_transaction() { exit 0 } +# Revoke before waiting or touching state. Every exit, including failed and +# interrupted migrations, must revoke again before returning to user code. +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo + wait_for_pacman_transaction mkdir -p "$STATE_DIR" [[ -d $MIGRATIONS_DIR ]] || exit 0 +# Migrations are strictly ordered and may mix user-controlled code (mise, +# themes, AUR builds) with later privileged repairs. Start cold and force every +# migration sudo through -N so authentication authorizes only that command and +# never creates a timestamp a detached earlier child can reuse. + while IFS=$'\t' read -r name file marker <&3; do [[ -n $name ]] || continue if [[ ! -f $marker ]]; then echo -e "\e[32m\nRunning migration (${name%.sh})\e[0m" - OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" 3<&- + OMARCHY_PATH="$OMARCHY_PATH" /usr/bin/bash -p -euo pipefail "$file" 3<&- + omarchy_security_revoke_sudo_timestamp || exit 1 mkdir -p "$(dirname "$marker")" touch "$marker" fi done 3< <(migration_entries) +omarchy_security_revoke_sudo_timestamp || exit 1 + # Clear a login-time notification the user left sitting there and then resolved # by running migrations some other way. The substring matches both the current # and legacy notification titles. diff --git a/bin/omarchy-migrate-sshd-key-only b/bin/omarchy-migrate-sshd-key-only new file mode 100755 index 00000000000..5bc69ece9a6 --- /dev/null +++ b/bin/omarchy-migrate-sshd-key-only @@ -0,0 +1,292 @@ +#!/bin/bash -p + +# omarchy:hidden=true +# omarchy:summary=Repair legacy Omarchy SSH hardening from a fixed root machine phase + +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for the SSH migration." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +set -euo pipefail +omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126 + +(( $# == 0 && EUID == 0 )) || { + echo "The SSH migration machine phase must run as root without arguments." >&2 + exit 2 +} + +PATH=/usr/bin:/usr/sbin:/bin:/sbin +export PATH +unset BASH_ENV ENV CDPATH GLOBIGNORE + +legacy_config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf +key_only_config=/etc/ssh/sshd_config.d/00-omarchy-key-only.conf +main_config=/etc/ssh/sshd_config +dropin_dir=/etc/ssh/sshd_config.d +passwd_file=/etc/passwd +login_defs=/etc/login.defs +machine_lock=/run/omarchy-sshd-key-only-migration.lock +eligible_names=() +keyed_names=() + +runtime_owner_mode=$(/usr/bin/stat -Lc '%u:%a:%F' -- /run) || exit 1 +[[ ! -L /run && $(/usr/bin/readlink -e -- /run) == /run && $runtime_owner_mode == "0:755:directory" ]] || { + echo "Refusing an unsafe runtime directory for the SSH migration lock." >&2 + exit 1 +} +umask 077 +if [[ -e $machine_lock || -L $machine_lock ]]; then + [[ -f $machine_lock && ! -L $machine_lock && $(/usr/bin/stat -Lc '%u:%a:%h' -- "$machine_lock") == "0:600:1" ]] || { + echo "Refusing an unsafe SSH migration lock." >&2 + exit 1 + } +fi +exec {machine_lock_fd}>"$machine_lock" +/usr/bin/flock -x "$machine_lock_fd" +[[ $(/usr/bin/stat -Lc '%u:%a:%h' -- "$machine_lock") == "0:600:1" ]] || exit 1 + +service_active_state() { + local state + state=$(/usr/bin/systemctl is-active sshd.service 2>/dev/null) || true + case "$state" in + active | activating | reloading | deactivating) return 0 ;; + inactive | failed) return 1 ;; + *) return 2 ;; + esac +} + +service_enabled_state() { + local state + state=$(/usr/bin/systemctl is-enabled sshd.service 2>/dev/null) || true + case "$state" in + enabled | enabled-runtime | linked | linked-runtime | alias | static | indirect | generated | transient) return 0 ;; + disabled | masked | masked-runtime) return 1 ;; + *) return 2 ;; + esac +} + +disable_affected() { + local active_status enabled_status + service_active_state && active_status=0 || active_status=$? + service_enabled_state && enabled_status=0 || enabled_status=$? + if (( active_status == 2 || enabled_status == 2 )); then + echo "Could not determine whether sshd.service is active or enabled; refusing to complete the SSH migration." >&2 + return 1 + fi + (( active_status == 0 || enabled_status == 0 )) || return 0 + /usr/bin/systemctl disable --now sshd.service || { + echo "Could not disable SSH after key-only policy verification failed; rerun omarchy-migrate with administrator privileges." >&2 + exit 1 + } + echo "Disabled sshd because a machine account with usable key-only access could not be proven. Run omarchy-setup-security-sshd to repair it." +} + +legacy_is_omarchy_managed() { + [[ -f $legacy_config && ! -L $legacy_config ]] || return 1 + [[ $(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$legacy_config") == $'PasswordAuthentication no\nKbdInteractiveAuthentication no' ]] +} + +valid_login_shell() { + local shell=$1 + [[ $shell == /* && -x $shell ]] || return 1 + case "$shell" in + */nologin | */false) return 1 ;; + esac +} + +account_has_usable_key() { + local name=$1 uid=$2 home=$3 + local authorized_keys="$home/.ssh/authorized_keys" canonical home_owner home_mode ssh_owner ssh_mode key_owner key_mode line + + [[ -d $home && ! -L $home ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$home" 2>/dev/null) || return 1 + [[ $canonical == "$home" ]] || return 1 + [[ -d $home/.ssh && ! -L $home/.ssh && -f $authorized_keys && ! -L $authorized_keys ]] || return 1 + [[ $(/usr/bin/realpath -e -- "$home/.ssh") == "$home/.ssh" && + $(/usr/bin/realpath -e -- "$authorized_keys") == "$authorized_keys" ]] || return 1 + read -r home_owner home_mode < <(/usr/bin/stat -Lc '%u %a' -- "$home") || return 1 + read -r ssh_owner ssh_mode < <(/usr/bin/stat -Lc '%u %a' -- "$home/.ssh") || return 1 + read -r key_owner key_mode < <(/usr/bin/stat -Lc '%u %a' -- "$authorized_keys") || return 1 + [[ $home_owner == 0 || $home_owner == "$uid" ]] || return 1 + [[ $ssh_owner == 0 || $ssh_owner == "$uid" ]] || return 1 + [[ $key_owner == 0 || $key_owner == "$uid" ]] || return 1 + [[ $home_mode =~ ^[0-7]+$ && $ssh_mode =~ ^[0-7]+$ && $key_mode =~ ^[0-7]+$ ]] || return 1 + ! ((8#$home_mode & 022)) && ! ((8#$ssh_mode & 022)) && ! ((8#$key_mode & 022)) || return 1 + while IFS= read -r line || [[ -n $line ]]; do + [[ $line =~ ^[[:space:]]*(#|$) ]] && continue + /usr/bin/ssh-keygen -lf /dev/stdin <<<"$line" >/dev/null 2>&1 && return 0 + done <"$authorized_keys" + return 1 +} + +valid_account_name() { + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,30}[$]?$ ]] +} + +account_is_admitted() { + local name=$1 dump=$2 directive token status_line status account_groups group admitted + local -a values groups + + status_line=$(LC_ALL=C /usr/bin/passwd -S -- "$name" 2>/dev/null) || return 1 + read -r token status _ <<<"$status_line" + [[ $token == "$name" && ( $status == "P" || $status == "NP" ) ]] || return 1 + + account_groups=$(/usr/bin/id -Gn -- "$name" 2>/dev/null) || return 1 + read -r -a groups <<<"$account_groups" + ((${#groups[@]} > 0)) || return 1 + for group in "${groups[@]}"; do valid_account_name "$group" || return 1; done + + for directive in allowusers denyusers allowgroups denygroups; do + mapfile -t values < <(/usr/bin/awk -v wanted="$directive" 'tolower($1) == wanted { for (i=2; i<=NF; i++) print $i }' <<<"$dump") + case "$directive" in + allowusers) + ((${#values[@]} == 0)) || { + admitted=0 + for token in "${values[@]}"; do valid_account_name "$token" || return 1; [[ $token != "$name" ]] || admitted=1; done + ((admitted)) || return 1 + } + ;; + denyusers) + for token in "${values[@]}"; do valid_account_name "$token" || return 1; [[ $token != "$name" ]] || return 1; done + ;; + allowgroups) + ((${#values[@]} == 0)) || { + admitted=0 + for token in "${values[@]}"; do + valid_account_name "$token" || return 1 + for group in "${groups[@]}"; do [[ $token != "$group" ]] || admitted=1; done + done + ((admitted)) || return 1 + } + ;; + denygroups) + for token in "${values[@]}"; do + valid_account_name "$token" || return 1 + for group in "${groups[@]}"; do [[ $token != "$group" ]] || return 1; done + done + ;; + esac + done +} + +enumerate_login_accounts() { + local uid_min=1000 line name password uid gid gecos home shell extra + local configured_uid_min + configured_uid_min=$(/usr/bin/awk '$1 == "UID_MIN" && $2 ~ /^[0-9]+$/ { print $2; exit }' "$login_defs" 2>/dev/null || true) + [[ -z $configured_uid_min ]] || uid_min=$configured_uid_min + + while IFS= read -r line || [[ -n $line ]]; do + IFS=: read -r name password uid gid gecos home shell extra <<<"$line" + [[ -z ${extra:-} && $uid =~ ^[0-9]+$ && + $home == /* ]] || return 1 + valid_account_name "$name" || return 1 + (( uid == 0 || (uid >= uid_min && uid != 65534) )) || continue + valid_login_shell "$shell" || continue + eligible_names+=("$name") + if account_has_usable_key "$name" "$uid" "$home"; then + keyed_names+=("$name") + fi + done <"$passwd_file" + ((${#eligible_names[@]} > 0)) +} + +precedence_is_provable() { + /usr/bin/awk ' + { line=$0; sub(/[[:space:]]*#.*/, "", line); sub(/^[[:space:]]+/, "", line) } + line !~ /^[[:space:]]*$/ { + split(line,f,/[[:space:]]+/); key=tolower(f[1]) + if (!included) { + if (key=="include" && line ~ /^[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]]+\/etc\/ssh\/sshd_config\.d\/\*\.conf[[:space:]]*$/) included=1 + else if (key=="include" || key=="match" || key=="passwordauthentication" || key=="kbdinteractiveauthentication" || key=="authenticationmethods" || key=="pubkeyauthentication" || key=="authorizedkeysfile") exit 1 + } + } + END { if (!included) exit 1 } + ' "$main_config" || return 1 + local entry + while IFS= read -r entry; do + [[ $entry == "${key_only_config##*/}" ]] && continue + [[ $entry > "${key_only_config##*/}" ]] || return 1 + done < <(/usr/bin/find "$dropin_dir" -mindepth 1 -maxdepth 1 -name '*.conf' -printf '%f\n' | LC_ALL=C /usr/bin/sort) +} + +dump_is_key_only() { + local dump=$1 + /usr/bin/grep -qixF 'passwordauthentication no' <<<"$dump" && + /usr/bin/grep -qixF 'kbdinteractiveauthentication no' <<<"$dump" && + /usr/bin/grep -qixF 'authenticationmethods publickey' <<<"$dump" && + /usr/bin/grep -qixF 'pubkeyauthentication yes' <<<"$dump" && + /usr/bin/grep -qixF 'authorizedkeysfile .ssh/authorized_keys' <<<"$dump" +} + +effective_is_key_only() { + local dump name keyed admitted_key=0 + dump=$(/usr/bin/sshd -T) && dump_is_key_only "$dump" || return 1 + for name in "${eligible_names[@]}"; do + dump=$(/usr/bin/sshd -T -C "user=$name,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=22") || return 1 + dump_is_key_only "$dump" || return 1 + for keyed in "${keyed_names[@]}"; do + if [[ $keyed == "$name" ]] && account_is_admitted "$name" "$dump"; then admitted_key=1; fi + done + done + ((admitted_key)) +} + +# Only repair the exact two-directive file emitted by old Omarchy. Preserve an +# administrator-authored file at either name byte-for-byte. +if [[ -e $legacy_config || -L $legacy_config ]]; then + legacy_is_omarchy_managed || exit 0 +elif [[ ! -e $key_only_config && ! -L $key_only_config ]]; then + exit 0 +fi +if [[ -e $key_only_config || -L $key_only_config ]] && [[ ! -f $key_only_config || -L $key_only_config ]]; then + disable_affected + exit 0 +fi + +if ! enumerate_login_accounts || ((${#keyed_names[@]} == 0)) || ! precedence_is_provable; then + disable_affected + exit 0 +fi + +created=0 +if [[ ! -e $key_only_config ]]; then + /usr/bin/install -DT -o root -g root -m 0644 /dev/stdin "$key_only_config" <<'CONF' || exit 1 +# Written by Omarchy once an SSH key was already authorized. +PasswordAuthentication no +KbdInteractiveAuthentication no +AuthenticationMethods publickey +PubkeyAuthentication yes +AuthorizedKeysFile .ssh/authorized_keys +Match all + PasswordAuthentication no + KbdInteractiveAuthentication no + AuthenticationMethods publickey + PubkeyAuthentication yes + AuthorizedKeysFile .ssh/authorized_keys +CONF + created=1 +fi +/usr/bin/ssh-keygen -A || { ((created)) && /usr/bin/rm -f "$key_only_config"; exit 1; } +if ! /usr/bin/sshd -t || ! effective_is_key_only; then + ((created)) && /usr/bin/rm -f "$key_only_config" + disable_affected + exit 0 +fi + +service_active_state && active_status=0 || active_status=$? +if (( active_status == 2 )); then + ((created)) && /usr/bin/rm -f "$key_only_config" + echo "Could not determine whether sshd.service is active; refusing to complete the SSH migration." >&2 + exit 1 +elif (( active_status == 0 )) && ! /usr/bin/systemctl reload sshd.service; then + disable_affected + exit 0 +fi + +if legacy_is_omarchy_managed; then + /usr/bin/rm -f -- "$legacy_config" +fi diff --git a/bin/omarchy-pkg-add b/bin/omarchy-pkg-add index 977d7efdbc9..28c0aadf50e 100755 --- a/bin/omarchy-pkg-add +++ b/bin/omarchy-pkg-add @@ -1,21 +1,53 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Install Arch packages if they are missing # omarchy:args= # omarchy:examples=omarchy pkg add jq ripgrep # omarchy:requires-sudo=true -if omarchy-pkg-missing "$@"; then +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for package installation." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 + +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for package installation." >&2 + exit 126 +} + +set -euo pipefail +omarchy_security_sanitize_bash_environment "$0" "$@" +PATH=/usr/bin:/usr/sbin:/bin:/sbin +export PATH + +case "${OMARCHY_SUDO_NO_UPDATE:-0}" in + 0|"") sudo_args=() ;; + 1) sudo_args=(-N) ;; + *) + echo "Invalid OMARCHY_SUDO_NO_UPDATE value." >&2 + exit 2 + ;; +esac + +(($# > 0)) || { + echo "Usage: omarchy-pkg-add " >&2 + exit 2 +} + +if /usr/bin/omarchy-pkg-missing "$@"; then if (( EUID == 0 )); then - pacman -S --noconfirm --needed "$@" || exit 1 + /usr/bin/pacman -S --noconfirm --needed -- "$@" || exit 1 else - sudo pacman -S --noconfirm --needed "$@" || exit 1 + /usr/bin/sudo "${sudo_args[@]}" -- /usr/bin/pacman -S --noconfirm --needed -- "$@" || exit 1 fi fi for pkg in "$@"; do # Secondary check to handle states where pacman doesn't actually register an error - if ! pacman -Q "$pkg" &>/dev/null; then + if ! /usr/bin/pacman -Q -- "$pkg" &>/dev/null; then echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2 exit 1 fi diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 299d6c20d20..d70ffd9ca42 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -1,26 +1,48 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages. # omarchy:requires-sudo=true -sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak -sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi -channel="${1:-stable}" +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" +user_path=$PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo +channel="${1:-stable}" +hook_mode="${2:-normal}" if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then - echo "Error: Invalid channel '$channel'. Must be one of: stable, rc, edge" - exit 1 + echo "Invalid channel: $channel" >&2 + exit 2 +fi +if [[ $hook_mode != "normal" && $hook_mode != "defer-hook" && $hook_mode != "run-deferred" ]]; then + echo "Invalid refresh hook mode: $hook_mode" >&2 + exit 2 fi -echo "Setting channel to $channel" -echo - -sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf -sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist - -# Allow user customization of /etc/pacman.conf before the upgrade runs -omarchy-hook pre-refresh-pacman +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 -# Reset all package DBs and then update -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm +# 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-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index da8af34cdc5..4821120469d 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -120,7 +120,7 @@ fi # never owned, a yes takes that with it, and saying so is the prompt's job. # Without a terminal to ask in, keeping everything is the answer. data_removed=false -if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cmd-present gum; then +if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then # du answers non-zero when either directory is missing, and pipefail would # turn that into an aborted removal; the size is worth no such thing. size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true) diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw index e610cbbe02c..a5b15a23edb 100755 --- a/bin/omarchy-remove-ai-openclaw +++ b/bin/omarchy-remove-ai-openclaw @@ -61,7 +61,7 @@ gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true # front of the user with the size rather than left silent. Without a terminal # to ask in, keeping it is the answer. state_removed=false -if [[ -d $HOME/.openclaw && -t 0 ]] && omarchy-cmd-present gum; then +if [[ -d $HOME/.openclaw && -t 0 ]]; then size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1) if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then rm -rf "$HOME/.openclaw" diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index dfc21724620..9891e7e8a7c 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -61,6 +61,19 @@ relock_session() { return 1 } +notifications_ready() { + [[ $(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus \ + org.freedesktop.DBus NameHasOwner s org.freedesktop.Notifications 2>/dev/null) == "b true" ]] +} + +# Core IPC can answer before the notification plugin has registered its bus +# name. Restore an existing notification service before update hooks or setup +# invitations send their one-time toasts; a disabled service need not appear. +notifications_were_running=0 +if notifications_ready; then + notifications_were_running=1 +fi + # Each kill stops the oldest matching instance and only returns once it has # fully exited, so the no-duplicate launch below can't race a dying shell. while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done @@ -70,7 +83,8 @@ while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; hyprctl dispatch 'hl.dsp.exec_cmd("omarchy-launch-shell")' >/dev/null for (( attempt = 0; attempt < 20; attempt++ )); do - if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1; then + if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1 && + { (( notifications_were_running == 0 )) || notifications_ready; }; then # The session stays compositor-locked after the old lock client died, so # re-acquire the lock and let the user authenticate out of it. if (( relock )) && ! relock_session; then diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000000..890d4430d80 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,137 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Provide internal helpers for command-scoped sudo authentication + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-security-functions is an internal function library." >&2 + exit 64 +fi + +omarchy_security_require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1 + executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1 + [[ $executable == "/usr/bin/bash" && + ( ${argv[0]:-} == "/bin/bash" || ${argv[0]:-} == "/usr/bin/bash" ) && + ${argv[1]:-} == "-p" ]] + ' omarchy-bash-startup "$$" +} + +omarchy_security_sanitize_bash_environment() { + local script=$1 + shift + local entry name environment_fd environment_pid + local -a unsets=() + + # Read the raw environment: privileged Bash ignores exported functions, but + # leaves their records for ordinary child interpreters to import later. + exec {environment_fd}< <(/usr/bin/env -0) + environment_pid=$! + while IFS= read -r -d '' entry <&"$environment_fd"; do + name=${entry%%=*} + case "$name" in + BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%) + unsets+=(-u "$name") + ;; + esac + done + exec {environment_fd}<&- + wait "$environment_pid" || return 1 + if (( ${#unsets[@]} > 0 )); then + exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@" + fi +} + +omarchy_security_require_source_root() { + local command_source command_name=${1##*/} + command_source=$(/usr/bin/readlink -e -- "$1") || return 1 + + # A runtime root selects the code used by this invocation. Accept the + # canonical checkout containing the entrypoint or the package's bin links. + if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] || + ! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] || + [[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then + echo "OMARCHY_PATH does not match this Omarchy command." >&2 + return 1 + fi +} + +omarchy_security_sudo_supports_no_update() { + local help + help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1 + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help" +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k +} + +omarchy_security_exit_with_revoked_sudo() { + local status=$1 + local message=${2:-Could not invalidate cached sudo authorization.} + + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + (( status != 0 )) || status=1 + fi + exit "$status" +} + +omarchy_security_install_signal_exit_traps() { + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +omarchy_security_install_sudo_cleanup_traps() { + OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} + trap omarchy_security_run_sudo_cleanup_trap EXIT + omarchy_security_install_signal_exit_traps +} + +omarchy_security_enable_no_update_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + if ! omarchy_security_sudo_supports_no_update; then + echo "This sudo does not support --no-update; refusing mixed-trust work." >&2 + return 1 + fi + if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then + echo "The command-scoped sudo wrapper is missing." >&2 + return 1 + fi + PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" + OMARCHY_SUDO_NO_UPDATE=1 + export PATH OMARCHY_SUDO_NO_UPDATE +} + +omarchy_security_run_sudo_cleanup_trap() { + local status=$? + + omarchy_security_exit_with_revoked_sudo "$status" \ + "${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}" +} + +omarchy_security_assert_root_directory() { + local path=$1 expected_mode=$2 canonical owner actual_mode + + [[ $path == /* && -d $path && ! -L $path ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$path") || return 1 + [[ $canonical == "$path" ]] || return 1 + read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1 + [[ $owner == "0" && $actual_mode == "$expected_mode" ]] +} + +omarchy_security_prepare_private_root_directory() { + local path=$1 parent=$2 + + omarchy_security_assert_root_directory "$parent" 755 || return 1 + if [[ -e $path || -L $path ]]; then + omarchy_security_assert_root_directory "$path" 700 + else + /usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1 + omarchy_security_assert_root_directory "$path" 700 + fi +} diff --git a/bin/omarchy-setup-security-sshd b/bin/omarchy-setup-security-sshd index 7b93936ff63..746106fb30f 100755 --- a/bin/omarchy-setup-security-sshd +++ b/bin/omarchy-setup-security-sshd @@ -1,23 +1,50 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Set up the OpenSSH server, open the firewall, and authorize an SSH key # omarchy:args=[--key=] [--gh-keys ] # omarchy:examples=omarchy-setup-security-sshd | omarchy-setup-security-sshd --gh-keys dhh | omarchy-setup-security-sshd --key="ssh-ed25519 AAAA... user@host" # omarchy:requires-sudo=true -set -e +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for SSH setup." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for SSH setup." >&2 + exit 126 +} +set -euo pipefail +omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126 +omarchy_security_require_source_root "$0" || exit 126 +PATH=/usr/bin:/usr/sbin:/bin:/sbin +export PATH +unset BASH_ENV ENV CDPATH GLOBIGNORE -AUTHORIZED_KEYS="$HOME/.ssh/authorized_keys" KEY="" GITHUB_USER="" +CURRENT_UID=$(/usr/bin/id -u) +PASSWD_ENTRY=$(/usr/bin/getent passwd "$CURRENT_UID") || { + echo "Could not resolve the current account from the system user database." >&2 + exit 1 +} +IFS=: read -r ACCOUNT_NAME _ PASSWD_UID _ _ ACCOUNT_HOME _ <<<"$PASSWD_ENTRY" +if [[ $PASSWD_UID != "$CURRENT_UID" || ! $ACCOUNT_NAME =~ ^[a-z_][a-z0-9_-]{0,30}[$]?$ || $ACCOUNT_HOME != /* ]]; then + echo "The current account has an invalid system user-database entry." >&2 + exit 1 +fi +AUTHORIZED_KEYS="$ACCOUNT_HOME/.ssh/authorized_keys" + # Checked while parsing, before anything is installed or opened: an empty or # option-shaped username otherwise falls through to the interactive menu having # already changed the machine, and `--gh-keys --help` would take --help as the # username and set the server up on its way to failing. require_github_user() { - if [[ -z $1 || $1 == -* ]]; then - echo "omarchy-setup-security-sshd: --gh-keys needs a GitHub username." >&2 + if [[ ! $1 =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}$ ]]; then + echo "omarchy-setup-security-sshd: --gh-keys needs a valid GitHub username." >&2 exit 2 fi } @@ -57,28 +84,202 @@ if [[ -n $KEY && -n $GITHUB_USER ]]; then exit 2 fi -setup_sshd() { - echo "Installing and starting the OpenSSH server..." - omarchy-pkg-add openssh - sudo systemctl enable --now sshd.service +SERVICE_WAS_ACTIVE=false +SERVICE_WAS_ENABLED=false +SERVICE_STARTED=false +SERVICE_ENABLED=false +FIREWALL_RULE_WAS_PRESENT=false +FIREWALL_RULE_ADDED=false +HARDENING_CONFIG=/etc/ssh/sshd_config.d/00-omarchy-key-only.conf +HARDENING_BACKUP="" +HARDENING_WAS_PRESENT=false +HARDENING_TOUCHED=false +AUTHORIZED_KEYS_TEMP="" +AUTHORIZED_KEYS_BACKUP="" +AUTHORIZED_KEYS_WAS_PRESENT=false +AUTHORIZED_KEYS_TOUCHED=false +SETUP_COMPLETE=false + +setup_sshd_package() { + echo "Installing the OpenSSH server prerequisites..." + /usr/bin/omarchy-pkg-add openssh +} + +omarchy_firewall_rule_present() { + /usr/bin/sudo ufw show added 2>/dev/null | + /usr/bin/grep -Eq "^ufw limit 22/tcp comment ['\"]?omarchy-sshd['\"]?$" +} + +record_existing_state() { + local active_status enabled_status + sshd_active_state && active_status=0 || active_status=$? + sshd_enabled_state && enabled_status=0 || enabled_status=$? + if (( active_status == 2 || enabled_status == 2 )); then + echo "Could not determine the existing sshd.service state; refusing SSH setup." >&2 + return 1 + fi + ((active_status != 0)) || SERVICE_WAS_ACTIVE=true + ((enabled_status != 0)) || SERVICE_WAS_ENABLED=true + if ! /usr/bin/omarchy-cmd-missing ufw && omarchy_firewall_rule_present; then + FIREWALL_RULE_WAS_PRESENT=true + fi +} + +sshd_active_state() { + local state + state=$(/usr/bin/sudo systemctl is-active sshd.service 2>/dev/null) || true + case "$state" in + active | activating | reloading | deactivating) return 0 ;; + inactive | failed) return 1 ;; + *) return 2 ;; + esac +} + +sshd_enabled_state() { + local state + state=$(/usr/bin/sudo systemctl is-enabled sshd.service 2>/dev/null) || true + case "$state" in + enabled | enabled-runtime | linked | linked-runtime | alias | static | indirect | generated | transient) return 0 ;; + disabled | masked | masked-runtime) return 1 ;; + *) return 2 ;; + esac +} + +rollback_setup() { + local exit_status=$? + local rollback_failed=false config_restored=false + trap - EXIT + + [[ -z $AUTHORIZED_KEYS_TEMP ]] || /usr/bin/rm -f -- "$AUTHORIZED_KEYS_TEMP" + + if [[ $SETUP_COMPLETE != "true" ]]; then + if [[ $FIREWALL_RULE_ADDED == "true" ]]; then + /usr/bin/sudo ufw --force delete limit 22/tcp comment "omarchy-sshd" >/dev/null 2>&1 || rollback_failed=true + /usr/bin/sudo ufw reload >/dev/null 2>&1 || rollback_failed=true + fi + if [[ $SERVICE_ENABLED == "true" && $SERVICE_WAS_ENABLED != "true" ]]; then + /usr/bin/sudo systemctl disable sshd.service >/dev/null 2>&1 || rollback_failed=true + fi + if [[ $SERVICE_STARTED == "true" && $SERVICE_WAS_ACTIVE != "true" ]]; then + /usr/bin/sudo systemctl stop sshd.service >/dev/null 2>&1 || rollback_failed=true + fi + if [[ $HARDENING_TOUCHED == "true" ]]; then + if [[ -n $HARDENING_BACKUP ]]; then + # The backup is in the same root-owned directory, so rename restores + # the administrator's exact owner/group/mode atomically. + if /usr/bin/sudo mv -fT -- "$HARDENING_BACKUP" "$HARDENING_CONFIG" >/dev/null 2>&1; then + HARDENING_BACKUP="" + config_restored=true + else + rollback_failed=true + fi + elif [[ $HARDENING_WAS_PRESENT != "true" ]]; then + if /usr/bin/sudo rm -f "$HARDENING_CONFIG" >/dev/null 2>&1; then + config_restored=true + else + rollback_failed=true + fi + fi + # If an existing daemon consumed our temporary config, apply the + # restored, validated administrator config. If it is no longer valid, + # leave the already-running key-only policy in memory (fail secure). + if [[ $SERVICE_WAS_ACTIVE == "true" && $config_restored == "true" ]]; then + if /usr/bin/sudo sshd -t >/dev/null 2>&1; then + /usr/bin/sudo systemctl reload sshd.service >/dev/null 2>&1 || rollback_failed=true + else + rollback_failed=true + fi + fi + fi + if [[ $AUTHORIZED_KEYS_TOUCHED == "true" ]]; then + if [[ -n $AUTHORIZED_KEYS_BACKUP ]]; then + if /usr/bin/mv -fT -- "$AUTHORIZED_KEYS_BACKUP" "$AUTHORIZED_KEYS"; then + AUTHORIZED_KEYS_BACKUP="" + else + rollback_failed=true + fi + elif [[ $AUTHORIZED_KEYS_WAS_PRESENT != "true" ]]; then + /usr/bin/rm -f -- "$AUTHORIZED_KEYS" || rollback_failed=true + fi + elif [[ -n $AUTHORIZED_KEYS_BACKUP ]]; then + /usr/bin/rm -f -- "$AUTHORIZED_KEYS_BACKUP" || rollback_failed=true + AUTHORIZED_KEYS_BACKUP="" + fi + elif [[ -n $HARDENING_BACKUP ]]; then + /usr/bin/sudo rm -f "$HARDENING_BACKUP" >/dev/null 2>&1 || { + echo "WARNING: SSH setup succeeded but could not remove $HARDENING_BACKUP" >&2 + } + fi + if [[ $SETUP_COMPLETE == "true" && -n $AUTHORIZED_KEYS_BACKUP ]]; then + /usr/bin/rm -f -- "$AUTHORIZED_KEYS_BACKUP" || + echo "WARNING: SSH setup succeeded but could not remove $AUTHORIZED_KEYS_BACKUP" >&2 + fi + + if [[ $rollback_failed == "true" ]]; then + echo "CRITICAL: SSH setup rollback was incomplete; inspect sshd.service, UFW, and $HARDENING_CONFIG immediately." >&2 + ((exit_status != 0)) || exit_status=1 + fi + + if ! /usr/bin/sudo -k >/dev/null 2>&1; then + echo "CRITICAL: SSH setup could not invalidate cached sudo authorization." >&2 + ((exit_status != 0)) || exit_status=1 + fi + + exit "$exit_status" } +trap rollback_setup EXIT +omarchy_security_install_signal_exit_traps open_firewall() { - if omarchy-cmd-missing ufw; then + if /usr/bin/omarchy-cmd-missing ufw; then echo "UFW is not installed; skipping firewall rule." return fi echo "Opening the SSH port in the firewall (rate limited against brute force)..." - sudo ufw limit 22/tcp comment "omarchy-sshd" >/dev/null - sudo ufw reload >/dev/null + if [[ $FIREWALL_RULE_WAS_PRESENT != "true" ]]; then + if ! /usr/bin/sudo ufw limit 22/tcp comment "omarchy-sshd" >/dev/null; then + omarchy_firewall_rule_present && FIREWALL_RULE_ADDED=true + return 1 + fi + # The rule may exist even if a later status/parse check fails. Record our + # successful mutation first so every subsequent failure removes it. + FIREWALL_RULE_ADDED=true + omarchy_firewall_rule_present || { + echo "UFW did not publish the expected Omarchy SSH rule." >&2 + return 1 + } + fi + /usr/bin/sudo ufw reload >/dev/null } valid_key() { - ssh-keygen -lf /dev/stdin <<<"$1" >/dev/null 2>&1 + [[ $1 != *$'\n'* ]] && /usr/bin/ssh-keygen -lf /dev/stdin <<<"$1" >/dev/null 2>&1 } -authorize_key() { +account_home_accepts_authorized_keys() { + local canonical owner mode + + [[ -d $ACCOUNT_HOME && ! -L $ACCOUNT_HOME ]] || { + echo "$ACCOUNT_HOME must be a real directory before SSH can be made key-only." >&2 + return 1 + } + canonical=$(/usr/bin/realpath -e -- "$ACCOUNT_HOME" 2>/dev/null) || return 1 + [[ $canonical == "$ACCOUNT_HOME" ]] || { + echo "$ACCOUNT_HOME must not resolve through a symbolic link." >&2 + return 1 + } + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$ACCOUNT_HOME") || return 1 + [[ $owner == 0 || $owner == "$CURRENT_UID" ]] && [[ $mode =~ ^[0-7]+$ ]] && + ! ((8#$mode & 0022)) || { + echo "$ACCOUNT_HOME must be owned by the account (or root) and not group/world writable before SSH can be made key-only." >&2 + return 1 + } +} + +COLLECTED_KEYS=() + +collect_key() { local key="$1" if ! valid_key "$key"; then @@ -86,31 +287,21 @@ authorize_key() { return 1 fi - mkdir -p "$HOME/.ssh" - chmod 700 "$HOME/.ssh" - touch "$AUTHORIZED_KEYS" - chmod 600 "$AUTHORIZED_KEYS" - - if grep -qxF "$key" "$AUTHORIZED_KEYS"; then - echo "Key already authorized: $(ssh-keygen -lf /dev/stdin <<<"$key")" - else - echo "$key" >>"$AUTHORIZED_KEYS" - echo "Authorized key: $(ssh-keygen -lf /dev/stdin <<<"$key")" - fi + COLLECTED_KEYS+=("$key") } authorize_keys_from_github() { local username="$1" keys added=0 echo "Fetching keys from https://github.com/$username.keys..." - if ! keys=$(curl -fsSL "https://github.com/$username.keys") || [[ -z $keys ]]; then + if ! keys=$(/usr/bin/curl -fsSL "https://github.com/$username.keys") || [[ -z $keys ]]; then echo -e "\e[31mCould not fetch any SSH keys for GitHub user '$username'.\e[0m" >&2 exit 1 fi while IFS= read -r key; do [[ -z $key ]] && continue - authorize_key "$key" && added=$((added + 1)) + collect_key "$key" && added=$((added + 1)) done <<<"$keys" if (( added == 0 )); then @@ -122,32 +313,174 @@ authorize_keys_from_github() { prompt_for_github_user() { local username - username=$(gum input --prompt "GitHub username> " --placeholder "dhh") || exit 1 + username=$(/usr/bin/gum input --prompt "GitHub username> " --placeholder "dhh") || exit 1 if [[ -z $username ]]; then echo -e "\e[31mNo GitHub username given.\e[0m" >&2 exit 1 fi + require_github_user "$username" + authorize_keys_from_github "$username" } authorize_pasted_key() { local key - key=$(gum input --prompt "Public key> " --placeholder "ssh-ed25519 AAAA... user@host") || exit 1 + key=$(/usr/bin/gum input --prompt "Public key> " --placeholder "ssh-ed25519 AAAA... user@host") || exit 1 if [[ -z $key ]]; then echo -e "\e[31mNo SSH key given.\e[0m" >&2 exit 1 fi - authorize_key "$key" || exit 1 + collect_key "$key" || exit 1 +} + +authorize_collected_keys() { + local ssh_dir owner key + ((${#COLLECTED_KEYS[@]} > 0)) || { + echo "No valid SSH public key was collected." >&2 + return 1 + } + ssh_dir=$(/usr/bin/dirname "$AUTHORIZED_KEYS") + /usr/bin/mkdir -p "$ssh_dir" + [[ -d $ssh_dir && ! -L $ssh_dir ]] || { + echo "$ssh_dir must be a real directory for atomic key authorization." >&2 + return 1 + } + /usr/bin/chmod 0700 "$ssh_dir" + owner=$(/usr/bin/stat -Lc '%u' -- "$ssh_dir" 2>/dev/null || true) + [[ $owner == "$(/usr/bin/id -u)" ]] || return 1 + if [[ -e $AUTHORIZED_KEYS || -L $AUTHORIZED_KEYS ]]; then + [[ -f $AUTHORIZED_KEYS && ! -L $AUTHORIZED_KEYS ]] || { + echo "$AUTHORIZED_KEYS is not a regular file; refusing to replace it." >&2 + return 1 + } + AUTHORIZED_KEYS_WAS_PRESENT=true + AUTHORIZED_KEYS_BACKUP=$(/usr/bin/mktemp "$ssh_dir/.authorized_keys.backup.XXXXXX") || return 1 + /usr/bin/cp -a -- "$AUTHORIZED_KEYS" "$AUTHORIZED_KEYS_BACKUP" || return 1 + fi + + AUTHORIZED_KEYS_TEMP=$(/usr/bin/mktemp "$ssh_dir/.authorized_keys.XXXXXX") || return 1 + /usr/bin/chmod 0600 "$AUTHORIZED_KEYS_TEMP" + if [[ -f $AUTHORIZED_KEYS ]]; then + /usr/bin/cat "$AUTHORIZED_KEYS" >"$AUTHORIZED_KEYS_TEMP" || return 1 + fi + for key in "${COLLECTED_KEYS[@]}"; do + if /usr/bin/grep -qxF "$key" "$AUTHORIZED_KEYS_TEMP"; then + echo "Key already authorized: $(/usr/bin/ssh-keygen -lf /dev/stdin <<<"$key")" + else + printf '%s\n' "$key" >>"$AUTHORIZED_KEYS_TEMP" || return 1 + echo "Authorized key: $(/usr/bin/ssh-keygen -lf /dev/stdin <<<"$key")" + fi + done + /usr/bin/chmod 0600 "$AUTHORIZED_KEYS_TEMP" + AUTHORIZED_KEYS_TOUCHED=true + /usr/bin/mv -fT -- "$AUTHORIZED_KEYS_TEMP" "$AUTHORIZED_KEYS" + AUTHORIZED_KEYS_TEMP="" +} + +validate_hardening_precedence() { + local first_dropin + # Fail if sshd can establish authentication or Match state before the stock + # drop-in include. Our first-expanded file then supplies both global and + # Match-context defaults before any later administrator Match can run. + /usr/bin/sudo /usr/bin/awk ' + function clean(line) { sub(/[[:space:]]*#.*/, "", line); sub(/^[[:space:]]+/, "", line); return line } + { + line = clean($0) + if (line ~ /^[[:space:]]*$/) next + split(line, field, /[[:space:]]+/) + key = tolower(field[1]) + if (!included) { + if (key == "include" && line ~ /^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]]+\/etc\/ssh\/sshd_config\.d\/\*\.conf[[:space:]]*$/) { + included = 1 + next + } + if (key == "include" || key == "match" || key == "passwordauthentication" || + key == "kbdinteractiveauthentication" || key == "authenticationmethods") exit 1 + } + } + END { if (!included) exit 1 } + ' /etc/ssh/sshd_config || { + echo "sshd_config must include /etc/ssh/sshd_config.d/*.conf before authentication or Match directives." >&2 + return 1 + } + + first_dropin=$(/usr/bin/sudo /usr/bin/find /etc/ssh/sshd_config.d -mindepth 1 -maxdepth 1 -name '*.conf' -printf '%f\n' | + LC_ALL=C /usr/bin/sort | /usr/bin/head -n1) + [[ $first_dropin == "${HARDENING_CONFIG##*/}" ]] || { + echo "$HARDENING_CONFIG is not the first expanded sshd drop-in; refusing ambiguous authentication precedence." >&2 + return 1 + } +} + +effective_policy_is_key_only() { + local dump="$1" + grep -qixF "passwordauthentication no" <<<"$dump" && + grep -qixF "kbdinteractiveauthentication no" <<<"$dump" && + grep -qixF "authenticationmethods publickey" <<<"$dump" && + grep -qixF "pubkeyauthentication yes" <<<"$dump" && + grep -qixF "authorizedkeysfile .ssh/authorized_keys" <<<"$dump" +} + +valid_system_name() { + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,30}[$]?$ ]] +} + +account_is_admitted() { + local name=$1 dump=$2 directive token status_line status account_groups group admitted + local -a values groups + + status_line=$(LC_ALL=C /usr/bin/passwd -S -- "$name" 2>/dev/null) || return 1 + read -r token status _ <<<"$status_line" + [[ $token == "$name" && ( $status == "P" || $status == "NP" ) ]] || return 1 + account_groups=$(/usr/bin/id -Gn -- "$name" 2>/dev/null) || return 1 + read -r -a groups <<<"$account_groups" + ((${#groups[@]} > 0)) || return 1 + for group in "${groups[@]}"; do valid_system_name "$group" || return 1; done + + for directive in allowusers denyusers allowgroups denygroups; do + mapfile -t values < <(/usr/bin/awk -v wanted="$directive" 'tolower($1) == wanted { for (i=2; i<=NF; i++) print $i }' <<<"$dump") + case "$directive" in + allowusers) + ((${#values[@]} == 0)) || { + admitted=0 + for token in "${values[@]}"; do valid_system_name "$token" || return 1; [[ $token != "$name" ]] || admitted=1; done + ((admitted)) || return 1 + } + ;; + denyusers) + for token in "${values[@]}"; do valid_system_name "$token" || return 1; [[ $token != "$name" ]] || return 1; done + ;; + allowgroups) + ((${#values[@]} == 0)) || { + admitted=0 + for token in "${values[@]}"; do + valid_system_name "$token" || return 1 + for group in "${groups[@]}"; do [[ $token != "$group" ]] || admitted=1; done + done + ((admitted)) || return 1 + } + ;; + denygroups) + for token in "${values[@]}"; do + valid_system_name "$token" || return 1 + for group in "${groups[@]}"; do [[ $token != "$group" ]] || return 1; done + done + ;; + esac + done +} + +current_account_name() { + printf '%s' "$ACCOUNT_NAME" } # Only called after a key is authorized. Disabling password authentication # before then could lock the owner out of the machine. disable_password_auth() { - local config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf - local effective_config + local effective_config matched_config account_name if [[ ! -s $AUTHORIZED_KEYS ]]; then echo -e "\e[31mCannot disable SSH password authentication without an authorized key.\e[0m" >&2 @@ -155,18 +488,50 @@ disable_password_auth() { fi echo "Disabling SSH password authentication, now that a key is authorized..." - sudo install -Dm644 /dev/stdin "$config" <<'CONF' + if /usr/bin/sudo test -e "$HARDENING_CONFIG" || /usr/bin/sudo test -L "$HARDENING_CONFIG"; then + if ! /usr/bin/sudo test -f "$HARDENING_CONFIG" || /usr/bin/sudo test -L "$HARDENING_CONFIG"; then + echo "$HARDENING_CONFIG is not a regular file; preserving it and refusing setup." >&2 + return 1 + fi + HARDENING_WAS_PRESENT=true + if ! HARDENING_BACKUP=$(/usr/bin/sudo mktemp /etc/ssh/sshd_config.d/.00-omarchy-key-only.backup.XXXXXX) || + ! /usr/bin/sudo cp -a "$HARDENING_CONFIG" "$HARDENING_BACKUP"; then + [[ -z $HARDENING_BACKUP ]] || /usr/bin/sudo rm -f "$HARDENING_BACKUP" >/dev/null 2>&1 || true + HARDENING_BACKUP="" + return 1 + fi + fi + HARDENING_TOUCHED=true + # Root consumes an inherited descriptor, never a caller-owned temporary + # pathname that another process can swap after validation. + if ! /usr/bin/sudo install -DT -o root -g root -m 0644 /dev/stdin "$HARDENING_CONFIG" <<'CONF'; then # Written by omarchy-setup-security-sshd once an SSH key was authorized. # Delete this file and reload sshd to allow password logins again. PasswordAuthentication no KbdInteractiveAuthentication no +AuthenticationMethods publickey +PubkeyAuthentication yes +AuthorizedKeysFile .ssh/authorized_keys +Match all + PasswordAuthentication no + KbdInteractiveAuthentication no + AuthenticationMethods publickey + PubkeyAuthentication yes + AuthorizedKeysFile .ssh/authorized_keys CONF + return 1 + fi + + validate_hardening_precedence + + # Arch creates host keys on first daemon start. Generate them explicitly so + # the complete configuration can be validated without starting a listener. + /usr/bin/sudo ssh-keygen -A # Validate before reloading: a config sshd rejects would otherwise take the # service down on its next restart, potentially stranding a remote owner. - if ! sudo sshd -t; then + if ! /usr/bin/sudo sshd -t; then echo -e "\e[31msshd rejected the hardening config; removing it and leaving passwords on.\e[0m" >&2 - sudo rm -f "$config" return 1 fi @@ -174,39 +539,66 @@ CONF # these settings. An earlier administrator rule could leave passwords enabled. # Match keywords case-insensitively: OpenSSH 9.x dumps them lowercase, 10.x # in CamelCase. - if ! effective_config=$(sudo sshd -T) || - ! grep -qixF "passwordauthentication no" <<<"$effective_config" || - ! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then + account_name=$(current_account_name) || return 1 + if ! effective_config=$(/usr/bin/sudo sshd -T) || ! effective_policy_is_key_only "$effective_config" || + ! matched_config=$(/usr/bin/sudo sshd -T -C "user=$account_name,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=22") || + ! effective_policy_is_key_only "$matched_config" || ! account_is_admitted "$account_name" "$matched_config"; then echo -e "\e[31msshd did not apply the password-authentication restrictions; removing the ineffective config.\e[0m" >&2 - sudo rm -f "$config" return 1 fi +} + +publish_sshd() { + if [[ $SERVICE_WAS_ACTIVE == "true" ]]; then + # Preserve established administrator sessions while applying the validated + # key-only policy to the already-running daemon. + /usr/bin/sudo systemctl reload sshd.service + else + SERVICE_STARTED=true + /usr/bin/sudo systemctl start sshd.service || return 1 + fi - # Reload rather than restart so an administrator already connected keeps - # their session. - sudo systemctl reload sshd.service + if [[ $SERVICE_WAS_ENABLED != "true" ]]; then + SERVICE_ENABLED=true + /usr/bin/sudo systemctl enable sshd.service || return 1 + fi } echo -e "\e[32mSetting up SSH server access with key-based authentication.\n\e[0m" -setup_sshd -open_firewall +/usr/bin/sudo -k >/dev/null 2>&1 || { + echo "Could not invalidate cached sudo authorization; refusing SSH setup." >&2 + exit 1 +} echo if [[ -n $KEY ]]; then - authorize_key "$KEY" || exit 1 + collect_key "$KEY" || exit 1 elif [[ -n $GITHUB_USER ]]; then authorize_keys_from_github "$GITHUB_USER" else - case $(gum choose "Grab key from GitHub" "Paste key manually" --header "How would you like to add your SSH key?") in + case $(/usr/bin/gum choose "Grab key from GitHub" "Paste key manually" --header "How would you like to add your SSH key?") in "Grab key from GitHub") prompt_for_github_user ;; "Paste key manually") authorize_pasted_key ;; *) exit 1 ;; esac fi +account_home_accepts_authorized_keys +authorize_collected_keys + +# No caller-resolved executable runs after this point. The key and all remote +# input have already been validated, so the reusable credential needed for the +# multi-command rollback-capable system transaction cannot be handed to a +# downloaded tool, prompt helper, or detached child from this workflow. +record_existing_state +setup_sshd_package disable_password_auth +publish_sshd +open_firewall + +SETUP_COMPLETE=true echo -e "\e[32m\nPerfect! The SSH server is running and your key is authorized.\e[0m" echo "Password logins are off; this machine now accepts authorized keys only." -echo "You can now connect with: ssh $USER@$(hostname)" +echo "You can now connect with: ssh $ACCOUNT_NAME@$(hostname)" diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 719d881bfe7..92d7ae168a7 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,70 +1,572 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Toggle passwordless sudo for the current user. # omarchy:args=[MINUTES] # omarchy:requires-sudo=true -NOPASSWD_FILE="/etc/sudoers.d/99-omarchy-nopasswd-${USER}" -TIMER_NAME="omarchy-nopasswd-expire-${USER}" +if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then + echo "Refusing an unsafe Bash startup for passwordless sudo." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for passwordless sudo." >&2 + exit 126 + } + omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126 +fi -MINUTES=${1:-15} -if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then +set -euo pipefail + +readonly DEFAULT_MINUTES=15 +readonly MAX_MINUTES=1440 +readonly STATE_DIR=/var/lib/omarchy/sudo-passwordless +readonly RUNTIME_DIR=/run/omarchy/sudo-passwordless +readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock +readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook +readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing +readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless +readonly STATUS_INACTIVE=3 + +usage() { echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 + echo "MINUTES must be between 1 and $MAX_MINUTES." >&2 exit 1 -fi +} -arm_expiry() { - if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm -f -- "$NOPASSWD_FILE"; then - return 0 +valid_minutes() { + [[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES)) +} + +valid_uid() { + [[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294)) +} + +valid_account_name() { + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] && (( ${#1} <= 32 )) +} + +resolve_account() { + local uid="$1" entry + valid_uid "$uid" || return 1 + entry=$(/usr/bin/getent passwd "$((10#$uid))") || return 1 + IFS=: read -r ACCOUNT_NAME _ ACCOUNT_UID _ _ _ _ <<<"$entry" + [[ $ACCOUNT_UID == "$((10#$uid))" ]] || return 1 + # Sudoers names and the legacy filename both have metacharacters. Omarchy + # accounts use this portable subset; refusing anything else is safer than + # attempting to quote privileged policy syntax. + valid_account_name "$ACCOUNT_NAME" || return 1 + ACCOUNT_UID=$((10#$uid)) +} + +verify_sudo_caller() { + local requested_uid="$1" + ((EUID == 0)) || return 1 + valid_uid "$requested_uid" || return 1 + [[ ${SUDO_UID:-} =~ ^[0-9]+$ ]] || return 1 + ((10#$SUDO_UID == 10#$requested_uid)) || return 1 + resolve_account "$requested_uid" +} + +prepare_root_state() { + omarchy_security_assert_root_directory /var 755 || return 1 + [[ -d /var/lib && ! -L /var/lib ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /var/lib) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /var/lib) & 022)) || return 1 + + if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /var/lib/omarchy || return 1 + fi + omarchy_security_assert_root_directory /var/lib/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$STATE_DIR" /var/lib/omarchy || return 1 + + omarchy_security_assert_root_directory /run 755 || return 1 + if [[ ! -e /run/omarchy && ! -L /run/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /run/omarchy || return 1 + fi + omarchy_security_assert_root_directory /run/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$RUNTIME_DIR" /run/omarchy +} + +with_root_lock() { + local fd rc=0 + # The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy: + # those are exactly the kinds of partial-install state it must fail closed + # through. /run/lock is established by the OS before sysinit services run. + omarchy_security_assert_root_directory /run 755 || return 1 + [[ -d /run/lock && ! -L /run/lock ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /run/lock) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /run/lock) & 022)) || return 1 + exec {fd}>"$LOCK_FILE" || return 1 + /usr/bin/chown root:root "$LOCK_FILE" || return 1 + /usr/bin/chmod 0600 "$LOCK_FILE" || return 1 + /usr/bin/flock -x "$fd" || return 1 + "$@" || rc=$? + /usr/bin/flock -u "$fd" || rc=1 + exec {fd}>&- + return "$rc" +} + +rule_file() { + printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" +} + +state_file() { + printf '%s/%s.state' "$STATE_DIR" "$1" +} + +read_state_record() { + local uid="$1" file state_uid name expires timer canonical_uid + local -a lines=() + valid_uid "$uid" || return 1 + canonical_uid=$((10#$uid)) + file=$(state_file "$uid") + [[ -f $file && ! -L $file ]] || return 1 + mapfile -t lines <"$file" || return 1 + (( ${#lines[@]} == 4 )) || return 1 + [[ ${lines[0]} == UID=* && ${lines[1]} == USER=* && + ${lines[2]} == EXPIRES=* && ${lines[3]} == TIMER=* ]] || return 1 + state_uid=${lines[0]#UID=} + name=${lines[1]#USER=} + expires=${lines[2]#EXPIRES=} + timer=${lines[3]#TIMER=} + [[ $state_uid == "$canonical_uid" ]] || return 1 + valid_account_name "$name" || return 1 + [[ $expires =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + [[ $timer =~ ^omarchy-nopasswd-expire-${canonical_uid}-[0-9a-f]{32}$ ]] || return 1 + printf '%s\t%s\t%s' "$name" "$expires" "$timer" +} + +read_state_timer() { + local record + record=$(read_state_record "$1") || return 1 + printf '%s' "${record##*$'\t'}" +} + +current_epoch() { + local now + now=$(/usr/bin/date +%s) || return 1 + [[ $now =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + printf '%s' "$now" +} + +valid_expiry() { + [[ $1 =~ ^[1-9][0-9]{0,10}$ ]] +} + +valid_timer_for_uid() { + local uid="$1" timer="$2" + valid_uid "$uid" || return 1 + uid=$((10#$uid)) + [[ $timer =~ ^omarchy-nopasswd-expire-${uid}-[0-9a-f]{32}$ ]] +} + +stop_timer() { + local timer="$1" + [[ $timer =~ ^omarchy-nopasswd-expire-[0-9]+-[0-9a-f]{32}$ ]] || return 0 + /usr/bin/systemctl stop "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true + /usr/bin/systemctl reset-failed "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true +} + +classify_generated_rule() { + local file=$1 suffix contents name + + GENERATED_RULE_LEGACY_TIMER="" + [[ -f $file && ! -L $file ]] || return 1 + contents=$(/usr/bin/cat -- "$file") || return 2 + suffix=${file##*/99-omarchy-nopasswd-} + + if [[ $suffix =~ ^[0-9]+$ ]]; then + name=${contents%' ALL=(ALL) NOPASSWD: ALL'} + if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then + return 0 + fi + name=${contents%%' ALL=(ALL) NOTAFTER='*} + valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]] + elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then + GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}" + else + return 1 + fi +} + +remove_known_legacy_rules() { + local file classification failed=0 + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + # A crash after publishing the numeric rule but before its state rename + # must not survive the next boot. Do not require the account to still + # exist: a deleted account could otherwise make the rule immortal and a + # later username reuse could activate it again. + if /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]; then + [[ -z $GENERATED_RULE_LEGACY_TIMER ]] || + /usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \ + "${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true + else + failed=1 + fi + else + classification=$? + # An unreadable candidate cannot be proven inert. A symlink, non-file, + # or administrator-authored body is unrelated and remains untouched. + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +cleanup_uid_locked() { + local uid="$1" timer="" + valid_uid "$uid" || return 1 + timer=$(read_state_timer "$uid" 2>/dev/null || true) + # Remove policy first. A failed timer stop can only leave an inert cleanup + # job behind, never extend passwordless access. + /usr/bin/rm -f -- "$(rule_file "$uid")" || return 1 + [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1 + /usr/bin/rm -f -- "$(state_file "$uid")" || return 1 + [[ -z $timer ]] || stop_timer "$timer" +} + +cleanup_all_locked() { + local state uid failed=0 file classification + shopt -s nullglob + for state in "$STATE_DIR"/*.state; do + uid=${state##*/} + uid=${uid%.state} + if valid_uid "$uid" && ! cleanup_uid_locked "$uid"; then failed=1; fi + done + shopt -u nullglob + remove_known_legacy_rules || failed=1 + + # Never report a successful boot cleanup while an exact rule emitted by any + # Omarchy implementation is still active. Administrator-extended files do + # not match these complete bodies and remain untouched. + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + failed=1 + else + classification=$? + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +verify_root_policy_file() { + local file=$1 owner mode canonical current + [[ -f $file && ! -L $file ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$file") || return 1 + [[ $canonical == "$file" ]] || return 1 + owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1 + mode=$(/usr/bin/stat -Lc '%a' -- "$file") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + + current=${file%/*} + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + [[ $current == / ]] && break + current=${current%/*} + [[ -n $current ]] || current=/ + done +} + +verify_boot_cleanup() { + local active_rules hook + [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 + verify_root_policy_file "$BOOT_CLEANUP_FILE" || return 1 + active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1 + [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1 + verify_root_policy_file "$PACKAGE_HOOK" || return 1 + hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1 + [[ $hook == '[Trigger] +Operation = Upgrade +Operation = Remove +Type = Package +Target = omarchy-settings +Target = omarchy-settings-dev + +[Action] +Description = Revoking temporary Omarchy sudo grants before settings changes... +When = PreTransaction +Exec = /usr/bin/omarchy-sudo-passwordless __package-removing +AbortOnFail' ]] +} + +package_removing_locked() { + # ALPM must abort before removing the helper or boot cleanup if revocation + # fails. The marker also blocks publication after this lock is released. + (umask 077; : >"$REMOVAL_BLOCKER") || return 1 + /usr/bin/rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-* || return 1 + cleanup_all_locked +} + +prepare_state_file() { + local uid="$1" name="$2" expires="$3" timer="$4" tmp + tmp=$(/usr/bin/mktemp "$STATE_DIR/.state.XXXXXX") || return 1 + if ! /usr/bin/printf 'UID=%s\nUSER=%s\nEXPIRES=%s\nTIMER=%s\n' \ + "$uid" "$name" "$expires" "$timer" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0600 "$tmp"; then + /usr/bin/rm -f -- "$tmp" + return 1 fi + printf '%s' "$tmp" +} + +start_expiry_timer() { + local uid="$1" expires="$2" timer="$3" + valid_uid "$uid" && valid_expiry "$expires" && valid_timer_for_uid "$uid" "$timer" || return 1 + # Calendar timers use CLOCK_REALTIME and catch up immediately after resume; + # a monotonic OnActiveSec timer pauses while the machine is suspended. + /usr/bin/systemd-run --quiet --collect --on-calendar="@${expires}" \ + --timer-property=AccuracySec=1s --unit="$timer" \ + -- "$INSTALLED_SELF" __expire "$uid" "$timer" || return 1 + /usr/bin/systemctl is-active --quiet "${timer}.timer" +} - echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2 - if ! sudo rm -f -- "$NOPASSWD_FILE"; then - echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2 +publish_rule() { + local uid="$1" name="$2" expires="$3" destination tmp deadline + valid_expiry "$expires" || return 1 + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + [[ $deadline =~ ^[0-9]{14}Z$ ]] || return 1 + destination=$(rule_file "$uid") + tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1 + if ! /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$name" "$deadline" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" || + ! /usr/sbin/visudo -cf "$tmp" >/dev/null || + ! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then + /usr/bin/rm -f -- "$tmp" + return 1 fi + /usr/bin/rm -f -- "$tmp" +} + +abort_enable_locked() { + local uid=$1 timer=$2 old_timer=$3 pending_state=$4 + # Publication can install policy and then fail while cleaning its temporary + # file. Never disarm either expiry job until policy revocation is confirmed. + if cleanup_uid_locked "$uid"; then + stop_timer "$timer" + [[ -z $old_timer ]] || stop_timer "$old_timer" + else + echo "Could not revoke passwordless sudo after a failed grant; expiry jobs remain armed. Administrator cleanup is required." >&2 + fi + /usr/bin/rm -f -- "$pending_state" || true return 1 } -echo "Toggle passwordless sudo..." +enable_locked() { + local uid="$1" minutes="$2" old_timer="" timer token expires pending_state now + resolve_account "$uid" || return 1 + valid_minutes "$minutes" || return 1 + prepare_root_state || return 1 + verify_boot_cleanup || { + echo "omarchy-sudo-passwordless: package-owned boot cleanup or transaction hook is missing or unsafe" >&2 + return 1 + } -# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up -if sudo test -f "$NOPASSWD_FILE" && ! systemctl is-active "${TIMER_NAME}.timer" &>/dev/null; then - sudo rm "$NOPASSWD_FILE" -fi + old_timer=$(read_state_timer "$uid" 2>/dev/null || true) + token=$(/usr/bin/tr -d '-' /dev/null - arm_expiry || exit 1 - echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes." + # State and a verified timer exist before the policy becomes reachable. If + # publication fails, cleanup removes both. During an update the old timer is + # deliberately kept until the replacement is active, so failure shortens the + # grant rather than extending it. + pending_state=$(prepare_state_file "$uid" "$ACCOUNT_NAME" "$expires" "$timer") || return 1 + if ! start_expiry_timer "$uid" "$expires" "$timer"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + if ! /usr/bin/mv -fT -- "$pending_state" "$(state_file "$uid")"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME" "$expires"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + now=$(current_epoch) || { + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + } + if ((10#$now >= 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer" || ! verify_boot_cleanup; then + # The timer may have expired or failed between its initial verification and + # rule publication. Revoke synchronously so a suspended or heavily loaded + # machine cannot turn a short grant into a reboot-long one. + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + [[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer" +} + +status_locked() { + local uid="$1" record state_name expires timer now remainder + resolve_account "$uid" || return 2 + if [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]]; then + return "$STATUS_INACTIVE" + fi + record=$(read_state_record "$uid") || { + revoke_inactive_grant "$uid" + return $? + } + state_name=${record%%$'\t'*} + remainder=${record#*$'\t'} + expires=${remainder%%$'\t'*} + timer=${record##*$'\t'} + [[ $state_name == "$ACCOUNT_NAME" ]] || { + revoke_inactive_grant "$uid" + return $? + } + now=$(current_epoch) || { + revoke_inactive_grant "$uid" + return $? + } + ((10#$now < 10#$expires)) || { + revoke_inactive_grant "$uid" + return $? + } + /usr/bin/systemctl is-active --quiet "${timer}.timer" || { + revoke_inactive_grant "$uid" + return $? + } +} + +revoke_inactive_grant() { + if cleanup_uid_locked "$1"; then + return "$STATUS_INACTIVE" + else + echo "Could not revoke invalid or expired passwordless sudo. Administrator cleanup is required." >&2 + return 2 + fi +} + +expire_locked() { + local uid=$1 timer=${2:-} current_timer status + if [[ -n $timer ]]; then + current_timer=$(read_state_timer "$uid" 2>/dev/null || true) + # A delayed predecessor must not revoke a newer, independently timed grant. + [[ -z $current_timer || $current_timer == "$timer" ]] || return 0 + cleanup_uid_locked "$uid" + elif status_locked "$uid"; then + # Compatibility with already scheduled UID-only jobs: enforce the current + # grant's expiry instead of letting an old timer shorten its replacement. + return 0 else - sudo rm "$NOPASSWD_FILE" - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null + status=$? + (( status == STATUS_INACTIVE )) + fi +} + +root_dispatch() { + local action="$1" + shift + case "$action" in + __status) + (($# == 1)) && verify_sudo_caller "$1" || return 2 + with_root_lock status_locked "$1" + ;; + __enable) + (($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1 + with_root_lock enable_locked "$1" "$2" + ;; + __disable) + (($# == 1)) && verify_sudo_caller "$1" || return 1 + with_root_lock cleanup_uid_locked "$1" + ;; + __expire) + (($# == 1 || $# == 2)) && ((EUID == 0)) && valid_uid "$1" || return 1 + [[ -z ${2:-} ]] || valid_timer_for_uid "$1" "$2" || return 1 + with_root_lock expire_locked "$@" + ;; + __cleanup-all) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock cleanup_all_locked + ;; + __package-removing) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock package_removing_locked + ;; + *) return 1 ;; + esac +} + +case "${1:-}" in + __status|__enable|__disable|__expire|__cleanup-all|__package-removing) + action=$1 + shift + root_dispatch "$action" "$@" + exit + ;; +esac + +(($# <= 1)) || usage +minutes=${1:-$DEFAULT_MINUTES} +valid_minutes "$minutes" || usage +uid=$(/usr/bin/id -u) +valid_uid "$uid" || { + echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2 + exit 1 +} + +omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2 + exit 1 +} + +omarchy_security_install_sudo_cleanup_traps +/usr/bin/sudo -k >/dev/null 2>&1 || { + echo "Could not start from a cold sudo credential state." >&2 + exit 1 +} + +echo "Toggle passwordless sudo..." +if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then + if (($# == 0)); then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid" echo "Passwordless sudo has been DISABLED. Sudo will require a password again." + else + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + echo "Passwordless sudo timer updated. It will automatically disable in ${minutes} minutes." fi else + status=$? + if (( status != STATUS_INACTIVE )); then + echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2 + exit 1 + fi echo "" echo "⚠️ WARNING: This will allow ANY process running as your user to" - echo "execute ANY command as root WITHOUT a password for ${MINUTES} minutes." + echo "execute ANY command as root WITHOUT a password for ${minutes} minutes." echo "" echo "This is useful for AI agents that need to run sudo commands," echo "but it significantly weakens the security of your system." echo "Anyone or anything with access to your user account gets full root." echo "" - echo "Passwordless sudo will automatically disable after ${MINUTES} minutes." + echo "Passwordless sudo will automatically disable after ${minutes} minutes," + echo "including if the machine reboots before the timer fires." echo "Run this command again to disable it early." echo "" - if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then - echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null - sudo chmod 440 "$NOPASSWD_FILE" - arm_expiry || exit 1 - + if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" echo "" - echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes." - echo "A restart removes the passwordless sudo rule as well." + echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." else echo "Aborted. No changes made." fi diff --git a/bin/omarchy-update b/bin/omarchy-update index e71e808664e..3873a6bc401 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -1,28 +1,60 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Update Omarchy and system packages # omarchy:args=[-y] # omarchy:examples=omarchy update | omarchy update -y # omarchy:requires-sudo=true +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" +# Logging and lock acquisition re-exec this command with a sanitized PATH. +# Preserve the caller's path only for the later unprivileged hook/mise phases. +user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH} +unset OMARCHY_UPDATE_USER_PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo + +update_stay_awake_stopped=0 +cleanup_update() { + local status=$? + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "Could not invalidate sudo before update cleanup." >&2 + omarchy_security_exit_with_revoked_sudo 1 + fi + if (( update_stay_awake_stopped == 0 )); then + omarchy-update-stay-awake stop || status=1 + fi + omarchy_security_exit_with_revoked_sudo "$status" +} if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then script_command=$(printf '%q ' "$0" "$@") - exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log" + exec env OMARCHY_UPDATE_LOGGED=1 OMARCHY_UPDATE_USER_PATH="$user_path" script -qefc "$script_command" "/tmp/omarchy-update.log" fi if ! omarchy-update-lock held; then - exec omarchy-update-lock run "$0" "$@" + exec env OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-update-lock run "$0" "$@" fi trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR -trap 'omarchy-update-stay-awake stop' EXIT +trap cleanup_update EXIT +omarchy_security_install_signal_exit_traps omarchy-update-requires-free-space -# -y is a promise not to ask anything. Steps that would need an answer report -# and move on instead of waiting on a prompt nobody is here to give. +# -y suppresses Omarchy confirmation prompts; sudo authorization is still +# required. Interactive review steps report and move on instead of waiting. [[ ${1:-} != "-y" ]] || export OMARCHY_UPDATE_UNATTENDED=1 if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then @@ -38,6 +70,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start + # Preserve the established development-checkout update ordering. omarchy-update-dev omarchy-update-keyring @@ -45,20 +78,41 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # them, so everything below waits on this finishing. An upgrade that stopped # takes the update with it rather than migrating against what is still on disk. omarchy-update-system-pkgs + + # Historical migrations are strictly ordered and mix user hooks/downloaded + # tooling with privileged repairs. The no-update sudo wrapper has covered the + # whole update, so neither the package transaction nor a later repair can + # publish a timestamp to a detached migration child. + omarchy_security_revoke_sudo_timestamp omarchy-migrate - omarchy-hook post-update - omarchy-update-aur-pkgs - omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status + # Service restart helpers can need sudo. Run them before any user-controlled + # update tooling; the reboot-only phase below performs no privileged work. + omarchy-update-restart --services-only + # Release update-owned inhibitors before offering a reboot. A confirmed # reboot can terminate this process before its EXIT trap gets a chance to # remove the persistent Stay Awake marker. + omarchy_security_revoke_sudo_timestamp omarchy-update-stay-awake stop - trap - EXIT + update_stay_awake_stopped=1 + + # 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 + omarchy_security_revoke_sudo_timestamp + + # Hooks and mise execute user-controlled code. Give each a cold credential + # boundary and run mise last so it cannot wait for a legitimate hook sudo. + # Only the unprivileged reboot prompt follows them. + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update + omarchy_security_revoke_sudo_timestamp + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise" + omarchy_security_revoke_sudo_timestamp - omarchy-update-restart + "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only fi diff --git a/bin/omarchy-update-aur-pkgs b/bin/omarchy-update-aur-pkgs index 4f496b331fc..a860a97d308 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" --sudoloop=false) +fi + if pacman -Qem >/dev/null; then if omarchy-pkg-aur-accessible; then echo -e "\e[32m\nUpdate AUR packages\e[0m" - yay -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs + yay "${sudo_options[@]}" -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs || exit 1 echo else echo -e "\e[31m\nAUR is unavailable (so skipping updates)\e[0m" diff --git a/bin/omarchy-update-restart b/bin/omarchy-update-restart index 05f7ba056ec..393d95e0de7 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] -echo +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 + 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?" fi -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?" + 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?" -fi +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 -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 + # 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 diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index 5fd9eb151b5..3559fd642a5 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -1,15 +1,218 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Manage sleep and idle inhibition during an update # omarchy:args= # omarchy:hidden=true +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo -state_dir="${XDG_RUNTIME_DIR:-/tmp/omarchy-$UID}/omarchy-update-stay-awake" -idle_owner_file="$state_dir/idle-owner" -inhibit_pid_file="$state_dir/inhibit-pid" stay_awake_state="$HOME/.local/state/omarchy/indicators/stay-awake" +caller_uid="" +state_base="" +state_dir="" +idle_owner_file="" +inhibit_pid_file="" +launch_control_file="" +launch_pending=0 +launch_token="" + +fail_state_boundary() { + echo "Refusing to use an unsafe Omarchy update inhibitor state path." >&2 + return 1 +} + +directory_is_private() { + local directory="$1" + local expected_owner="$2" + local canonical="" + local owner="" + local mode="" + + [[ -d $directory && ! -L $directory ]] || return 1 + canonical=$(readlink -e -- "$directory") || return 1 + [[ $canonical == "$directory" ]] || return 1 + read -r owner mode < <(stat -Lc '%u %a' -- "$directory") || return 1 + [[ $owner == "$expected_owner" && $mode == "700" ]] +} + +root_owned_parent_chain() { + local directory="$1" + local parent owner mode type canonical + + parent=$(/usr/bin/dirname -- "$directory") || return 1 + while :; do + [[ -d $parent && ! -L $parent ]] || return 1 + canonical=$(/usr/bin/readlink -e -- "$parent") || return 1 + [[ $canonical == "$parent" ]] || return 1 + read -r owner mode type < <(/usr/bin/stat -Lc '%u %a %F' -- "$parent") || return 1 + [[ $owner == 0 && $type == "directory" ]] || return 1 + ! ((8#$mode & 022)) || return 1 + [[ $parent == / ]] && break + parent=$(/usr/bin/dirname -- "$parent") || return 1 + done +} + +runtime_directory_is_private() { + local directory="$1" expected_owner="$2" + directory_is_private "$directory" "$expected_owner" && + root_owned_parent_chain "$directory" +} + +ensure_private_directory() { + local directory="$1" + + if [[ ! -e $directory && ! -L $directory ]]; then + mkdir -m 700 -- "$directory" 2>/dev/null || true + fi + directory_is_private "$directory" "$caller_uid" +} + +initialize_state_boundary() { + local canonical_tmp="" + local tmp_owner="" + local tmp_mode="" + + caller_uid="$EUID" + [[ $caller_uid =~ ^[0-9]+$ ]] || return 1 + + if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + runtime_directory_is_private "$XDG_RUNTIME_DIR" "$caller_uid" || fail_state_boundary + state_base="$XDG_RUNTIME_DIR" + else + [[ -d /tmp && ! -L /tmp ]] || fail_state_boundary + canonical_tmp=$(readlink -e -- /tmp) || fail_state_boundary + read -r tmp_owner tmp_mode < <(stat -Lc '%u %a' -- /tmp) || fail_state_boundary + [[ $canonical_tmp == "/tmp" && $tmp_owner == "0" && $tmp_mode == "1777" ]] || fail_state_boundary + + state_base="/tmp/omarchy-$caller_uid" + ensure_private_directory "$state_base" || fail_state_boundary + fi + + state_dir="$state_base/omarchy-update-stay-awake" + idle_owner_file="$state_dir/idle-owner" + inhibit_pid_file="$state_dir/inhibit-pid" + launch_control_file="$state_dir/launch-control" +} + +state_file_is_private() { + local state_file="$1" + local owner="" + local mode="" + local links="" + + [[ -f $state_file && ! -L $state_file ]] || return 1 + read -r owner mode links < <(stat -Lc '%u %a %h' -- "$state_file") || return 1 + [[ $owner == "$caller_uid" && $mode == "600" && $links == "1" ]] +} + +read_state_record() { + local state_file="$1" + local records=() + local file_size="" + local LC_ALL=C + + state_file_is_private "$state_file" || return 1 + mapfile -t records <"$state_file" || return 1 + (( ${#records[@]} == 1 )) || return 1 + file_size=$(stat -Lc '%s' -- "$state_file") || return 1 + (( file_size == ${#records[0]} + 1 )) || return 1 + state_file_is_private "$state_file" || return 1 + printf '%s\n' "${records[0]}" +} + +atomic_write_state() { + local state_file="$1" + local record="$2" + local temporary="" + + [[ $record != *$'\n'* ]] || return 1 + temporary=$(mktemp "$state_dir/.${state_file##*/}.XXXXXXXX") || return 1 + chmod 600 "$temporary" || { + rm -f -- "$temporary" + return 1 + } + if ! printf '%s\n' "$record" >"$temporary" || ! state_file_is_private "$temporary"; then + rm -f -- "$temporary" + return 1 + fi + if [[ -e $state_file || -L $state_file ]]; then + state_file_is_private "$state_file" || { + rm -f -- "$temporary" + return 1 + } + fi + mv -fT -- "$temporary" "$state_file" || { + rm -f -- "$temporary" + return 1 + } + state_file_is_private "$state_file" +} + +rollback_pending_launch() { + local control_fd="" + local inhibit_record="" + local inhibit_pid="" + local recorded_start_time="" + local recorded_owner="" + local token="" + + (( launch_pending == 1 )) || return 0 + + if [[ -e $launch_control_file || -L $launch_control_file ]]; then + state_file_is_private "$launch_control_file" || return 1 + exec {control_fd}<>"$launch_control_file" || return 1 + /usr/bin/flock -x "$control_fd" || { + exec {control_fd}>&- + return 1 + } + : >"/proc/self/fd/$control_fd" + printf 'cancelled %s\n' "$launch_token" >&"$control_fd" + rm -f -- "$launch_control_file" + /usr/bin/flock -u "$control_fd" + exec {control_fd}>&- + fi + + inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true) + if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($launch_token)$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + recorded_start_time="${BASH_REMATCH[2]}" + recorded_owner="${BASH_REMATCH[3]}" + token="${BASH_REMATCH[4]}" + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + discard_launched_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" + fi + [[ $(read_state_record "$inhibit_pid_file" 2>/dev/null || true) != "$inhibit_record" ]] || + rm -f -- "$inhibit_pid_file" + fi + + rmdir "$state_dir" 2>/dev/null || true + launch_pending=0 +} + +cleanup_pending_launch() { + local status=$? + + trap - EXIT HUP INT TERM + if ! rollback_pending_launch; then + echo "Failed to roll back the pending Omarchy update sleep inhibitor." >&2 + (( status != 0 )) || status=1 + fi + omarchy_security_exit_with_revoked_sudo "$status" \ + "Failed to invalidate sudo credentials after the update sleep inhibitor." +} process_start_time() { local process_pid="$1" @@ -21,9 +224,85 @@ process_start_time() { process_stat="${process_stat##*) }" read -r -a stat_fields <<<"$process_stat" (( ${#stat_fields[@]} > 19 )) || return 1 + [[ ${stat_fields[19]} =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "${stat_fields[19]}" } +process_owner() { + local process_pid="$1" + local owner="" + + owner=$(stat -Lc '%u' -- "/proc/$process_pid") || return 1 + [[ $owner =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$owner" +} + +process_has_token() { + local process_pid="$1" + local token="$2" + local argument="" + local expected="--why=Omarchy update in progress [$token]" + + [[ -r /proc/$process_pid/cmdline ]] || return 1 + while IFS= read -r -d '' argument; do + [[ $argument == "$expected" ]] && return 0 + done <"/proc/$process_pid/cmdline" + return 1 +} + +process_identity() { + local process_pid="$1" + local token="$2" + local start_before="" + local start_after="" + local owner_before="" + local owner_after="" + + start_before=$(process_start_time "$process_pid") || return 1 + owner_before=$(process_owner "$process_pid") || return 1 + process_has_token "$process_pid" "$token" || return 1 + start_after=$(process_start_time "$process_pid") || return 1 + owner_after=$(process_owner "$process_pid") || return 1 + [[ $start_before == "$start_after" && $owner_before == "$owner_after" ]] || return 1 + printf '%s %s\n' "$start_before" "$owner_before" +} + +process_matches() { + local process_pid="$1" + local expected_start="$2" + local expected_owner="$3" + local token="$4" + local identity="" + + identity=$(process_identity "$process_pid" "$token" 2>/dev/null) || return 1 + [[ $identity == "$expected_start $expected_owner" ]] +} + +process_base_identity() { + local process_pid="$1" + local start_before="" + local start_after="" + local owner_before="" + local owner_after="" + + start_before=$(process_start_time "$process_pid") || return 1 + owner_before=$(process_owner "$process_pid") || return 1 + start_after=$(process_start_time "$process_pid") || return 1 + owner_after=$(process_owner "$process_pid") || return 1 + [[ $start_before == "$start_after" && $owner_before == "$owner_after" ]] || return 1 + printf '%s %s\n' "$start_before" "$owner_before" +} + +process_base_matches() { + local process_pid="$1" + local expected_start="$2" + local expected_owner="$3" + local identity="" + + identity=$(process_base_identity "$process_pid" 2>/dev/null) || return 1 + [[ $identity == "$expected_start $expected_owner" ]] +} + process_state() { local process_pid="$1" local process_stat="" @@ -34,113 +313,322 @@ process_state() { printf '%s\n' "${process_stat%% *}" } -stop() { +discard_launched_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" + + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true + for (( attempt = 0; attempt < 25; attempt++ )); do + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || break + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && break + sleep 0.02 + done + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" && + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" KILL || true + fi + + if [[ ! -e /proc/$inhibit_pid ]] || [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]]; then + wait "$inhibit_pid" 2>/dev/null || true + fi +} + +signal_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" + local signal="$5" + local verified_kill='' + + read -r -d '' verified_kill <<'SH' || true +set -e +process_pid="$1" +expected_start="$2" +expected_owner="$3" +expected_argument="--why=Omarchy update in progress [$4]" +expected_signal="$5" +[[ $expected_signal == "TERM" || $expected_signal == "KILL" ]] + +process_matches() { + local process_stat="" + local stat_fields=() + local start_before="" + local start_after="" + local owner_before="" + local owner_after="" + local argument="" + local found=0 + + [[ -r /proc/$process_pid/stat && -r /proc/$process_pid/cmdline ]] || return 1 + process_stat=$( 19 )) || return 1 + start_before="${stat_fields[19]}" + owner_before=$(/usr/bin/stat -Lc '%u' -- "/proc/$process_pid") || return 1 + while IFS= read -r -d '' argument; do + if [[ $argument == "$expected_argument" ]]; then + found=1 + break + fi + done <"/proc/$process_pid/cmdline" + (( found == 1 )) || return 1 + + process_stat=$( 19 )) || return 1 + start_after="${stat_fields[19]}" + owner_after=$(/usr/bin/stat -Lc '%u' -- "/proc/$process_pid") || return 1 + [[ $start_before == "$expected_start" && $start_after == "$expected_start" ]] + [[ $owner_before == "$expected_owner" && $owner_after == "$expected_owner" ]] +} + +process_matches +builtin kill -s "$expected_signal" -- "$process_pid" +SH + + [[ $recorded_owner == "$caller_uid" ]] || return 1 + + /usr/bin/env -i /usr/bin/bash --noprofile --norc -c "$verified_kill" omarchy-inhibitor-kill \ + "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" "$signal" >/dev/null 2>&1 +} + +terminate_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" + + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true + + for (( attempt = 0; attempt < 50; attempt++ )); do + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0 + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || return 0 + sleep 0.02 + done + + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0 + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && return 0 + return 1 +} + +stop_locked() { + local inhibit_record="" local inhibit_pid="" local recorded_start_time="" - local current_start_time="" + local recorded_owner="" + local token="" local idle_owner="" local current_idle_owner="" + local failed=0 + local inhibit_record_version="" + local remove_inhibit_state=1 - if [[ -s $idle_owner_file ]]; then - idle_owner=$(<"$idle_owner_file") - if [[ -f $stay_awake_state ]]; then - current_idle_owner=$(<"$stay_awake_state") - fi - if [[ -n $idle_owner && $current_idle_owner == "$idle_owner" ]]; then - omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true - fi - rm -f "$idle_owner_file" + if [[ ! -e $state_dir && ! -L $state_dir ]]; then + return 0 fi + directory_is_private "$state_dir" "$caller_uid" || fail_state_boundary - if [[ -s $inhibit_pid_file ]]; then - read -r inhibit_pid recorded_start_time <"$inhibit_pid_file" || true - if [[ $inhibit_pid =~ ^[0-9]+$ ]]; then - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) + if [[ -e $idle_owner_file || -L $idle_owner_file ]]; then + idle_owner=$(read_state_record "$idle_owner_file" 2>/dev/null || true) + if [[ $idle_owner =~ ^[0-9]+:[0-9]+:[0-9]+$ ]]; then + if [[ -f $stay_awake_state ]]; then + current_idle_owner=$(<"$stay_awake_state") + fi + if [[ $current_idle_owner == "$idle_owner" ]]; then + omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true + fi + else + echo "Ignoring unsafe Omarchy update idle ownership state." >&2 + failed=1 fi - if [[ -n $recorded_start_time && $current_start_time == "$recorded_start_time" ]]; then - kill "$inhibit_pid" >/dev/null 2>&1 || true - - for (( attempt = 0; attempt < 50; attempt++ )); do - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) - [[ $current_start_time == "$recorded_start_time" ]] || break - [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || break - sleep 0.02 - done - - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) - if [[ $current_start_time == "$recorded_start_time" ]] && - [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then - echo "Failed to stop the Omarchy update sleep inhibitor." >&2 - return 1 + rm -f -- "$idle_owner_file" + fi + + if [[ -e $inhibit_pid_file || -L $inhibit_pid_file ]]; then + inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true) + if [[ $inhibit_record =~ ^([12])\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ([0-9a-f]{32})$ ]]; then + inhibit_record_version="${BASH_REMATCH[1]}" + inhibit_pid="${BASH_REMATCH[2]}" + recorded_start_time="${BASH_REMATCH[3]}" + recorded_owner="${BASH_REMATCH[4]}" + token="${BASH_REMATCH[5]}" + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + if ! terminate_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + echo "Failed to stop the Omarchy update sleep inhibitor." >&2 + failed=1 + remove_inhibit_state=0 + fi + elif [[ $inhibit_record_version == "2" ]] && + process_base_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner"; then + echo "The Omarchy update sleep inhibitor has not reached a verifiable identity yet." >&2 + failed=1 + remove_inhibit_state=0 fi + else + echo "Ignoring unsafe Omarchy update sleep inhibitor state." >&2 + failed=1 fi - rm -f "$inhibit_pid_file" + (( remove_inhibit_state == 0 )) || rm -f -- "$inhibit_pid_file" fi rmdir "$state_dir" 2>/dev/null || true + (( failed == 0 )) } -start() { +start_locked() { + local idle_owner="$$:$RANDOM:$RANDOM" + local token="" + local launcher_pid="" + local inhibit_record="" local inhibit_pid="" local inhibit_start_time="" - local inhibit_runner=() - local idle_owner="$$:$RANDOM:$RANDOM" + local inhibit_owner="" + local readiness_attempts=0 - stop - mkdir -p "$state_dir" + stop_locked || return 1 + ensure_private_directory "$state_dir" || fail_state_boundary - if omarchy-cmd-present systemd-inhibit; then - if (( EUID != 0 )); then - if [[ -t 0 ]]; then - sudo -v - inhibit_runner=(sudo) - else - inhibit_runner=(pkexec) - fi + token=$(LC_ALL=C /usr/bin/od -An -N16 -tx1 /dev/urandom | /usr/bin/tr -d ' \n') + [[ $token =~ ^[0-9a-f]{32}$ ]] || return 1 + launch_token="$token" + launch_pending=1 + trap cleanup_pending_launch EXIT + atomic_write_state "$launch_control_file" "active $token" || return 1 + + # The unprivileged held process writes its own private identity record. exec -a + # preserves the random token in its final argv without retaining an extra shell + # or a terminal descriptor merely to make that identity observable. + local hold_command=( + /usr/bin/systemd-inhibit --what=sleep:idle --who=omarchy-update + --why="Omarchy update in progress [$token]" --mode=block + /usr/bin/setpriv --reuid "$caller_uid" --regid "$(/usr/bin/id -g)" --clear-groups + /usr/bin/bash -p -c ' + set -e + umask 077 + state_dir=$1 + token=$2 + owner=$3 + control=$4 + expected="--why=Omarchy update in progress [$token]" + temporary="" + cleanup() { [[ -z $temporary ]] || /usr/bin/rm -f -- "$temporary"; } + trap cleanup EXIT + read -r process_stat <"/proc/$$/stat" + process_stat=${process_stat##*) } + read -r -a fields <<<"$process_stat" + temporary=$(/usr/bin/mktemp "$state_dir/.inhibit-pid.XXXXXXXX") + /usr/bin/chmod 600 "$temporary" + printf "1 %s %s %s %s\n" "$$" "${fields[19]}" "$owner" "$token" >"$temporary" + exec {control_fd}<"$control" + /usr/bin/flock -x "$control_fd" + IFS= read -r control_record <&"$control_fd" + [[ $control_record == "active $token" ]] + /usr/bin/mv -fT -- "$temporary" "$state_dir/inhibit-pid" + temporary="" + /usr/bin/flock -u "$control_fd" + exec {control_fd}>&- + trap - EXIT + exec -a "$expected" /usr/bin/sleep infinity + ' omarchy-update-inhibitor "$state_dir" "$token" "$caller_uid" "$launch_control_file" + ) + + if (( EUID == 0 )); then + ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec "${hold_command[@]}" + ) & + launcher_pid=$! + elif [[ -t 0 ]]; then + if ! ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec /usr/bin/sudo -N -b -- "${hold_command[@]}" + ); then + return 1 fi + else + ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec /usr/bin/pkexec "${hold_command[@]}" + ) & + launcher_pid=$! + fi - 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 & + while :; do + inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true) + if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($token)$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + inhibit_start_time="${BASH_REMATCH[2]}" + inhibit_owner="${BASH_REMATCH[3]}" + process_matches "$inhibit_pid" "$inhibit_start_time" "$inhibit_owner" "$token" && break fi - 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" + if [[ -n $launcher_pid ]] && ! kill -0 "$launcher_pid" 2>/dev/null; then + wait "$launcher_pid" || return 1 + echo "The update sleep inhibitor did not start." >&2 + return 1 fi + (( readiness_attempts += 1 )) + if [[ -t 0 ]] && (( EUID != 0 && readiness_attempts >= 100 )); then + echo "The update sleep inhibitor did not become ready." >&2 + return 1 + fi + /usr/bin/sleep 0.05 + done + + if [[ -e $launch_control_file || -L $launch_control_file ]]; then + state_file_is_private "$launch_control_file" || return 1 + rm -f -- "$launch_control_file" fi + launch_pending=0 + omarchy_security_install_sudo_cleanup_traps if [[ ! -f $stay_awake_state ]]; then - printf '%s\n' "$idle_owner" >"$idle_owner_file" - mkdir -p "$(dirname "$stay_awake_state")" + if ! atomic_write_state "$idle_owner_file" "$idle_owner"; then + stop_locked || true + return 1 + fi + if ! mkdir -p "$(dirname "$stay_awake_state")"; then + stop_locked || true + return 1 + fi if omarchy-toggle-idle stay-awake >/dev/null 2>&1; then - printf '%s\n' "$idle_owner" >"$stay_awake_state" + if ! printf '%s\n' "$idle_owner" >"$stay_awake_state"; then + omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true + rm -f -- "$idle_owner_file" + stop_locked || true + return 1 + fi else - rm -f "$idle_owner_file" + rm -f -- "$idle_owner_file" fi fi } case "${1:-}" in - start) - start - ;; - stop) - stop - ;; + start | stop) ;; *) echo "Usage: omarchy-update-stay-awake " >&2 exit 2 ;; esac + +initialize_state_boundary +exec {state_lock_fd}<"$state_base" || fail_state_boundary +flock -x "$state_lock_fd" || fail_state_boundary +directory_is_private "$state_base" "$caller_uid" || fail_state_boundary + +case "$1" in + start) + start_locked + ;; + stop) + stop_locked + ;; +esac diff --git a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample index cebec4fc8db..0c8ba7c437b 100644 --- a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample +++ b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample @@ -1,17 +1,20 @@ #!/bin/bash -# This hook is called by `omarchy refresh pacman` AFTER the channel template -# is copied to /etc/pacman.conf and BEFORE `pacman -Syyuu` runs. Use it to -# layer customizations onto the freshly-written pacman.conf so they're -# respected by the upgrade — common cases are adding a custom repository +# This legacy-named hook is called by `omarchy refresh pacman` AFTER the +# channel template is copied and the privileged package transaction finishes. +# During `omarchy channel set`, it is deferred until the package switch and +# complete update have also finished. +# Use it to layer customizations onto the freshly-written pacman.conf for +# subsequent package operations — common cases are adding a custom repository # (e.g. CachyOS, Chaotic-AUR, an internal company repo) or extra IgnorePkg # lines. # -# The hook runs as the invoking user with a warm sudo cache. +# The hook runs as the invoking user after Omarchy invalidates its sudo cache. +# A sudo command here therefore requires its own explicit authorization. # # To put it into use, remove .sample from this file name. -# Example: add an Include line above [core] for a custom repo. +# Example: add an Include line above [core] for future package operations. # Maintain the repo entries in /etc/pacman.d/custom-repos.conf yourself. CONF=/etc/pacman.conf diff --git a/default/agents/skills/omarchy/hooks.md b/default/agents/skills/omarchy/hooks.md index 4f8236ac0b7..4878c7e1984 100644 --- a/default/agents/skills/omarchy/hooks.md +++ b/default/agents/skills/omarchy/hooks.md @@ -14,8 +14,8 @@ file first, if one exists. ├── battery-low.d/ # Low battery (percentage in $1) ├── font-set.d/ # After font change (font name in $1) ├── post-boot.d/ # After the desktop starts -├── post-update.d/ # During `omarchy update`, after system packages and migrations -├── pre-refresh-pacman.d/ # Before `omarchy refresh pacman` re-syncs packages +├── post-update.d/ # At the end of `omarchy update`, after privileged work +├── pre-refresh-pacman.d/ # After `omarchy refresh pacman` finishes (legacy name) └── theme-set.d/ # After theme change (theme slug in $1) ``` @@ -26,3 +26,5 @@ THEME_NAME=$1 echo "Theme changed to: $THEME_NAME" # Add custom actions here ``` + +Update-related hooks run only after the workflow's privileged work and after Omarchy invalidates its sudo timestamp. A hook that invokes `sudo` must therefore request its own explicit authorization. The legacy-named `pre-refresh-pacman` hook runs after the refresh transaction; during `omarchy channel set`, it is deferred further until the package switch and complete update finish. Executable user code cannot safely run before a later sudo authentication because a detached child could wait for the new timestamp. diff --git a/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook new file mode 100644 index 00000000000..f0bce15d324 --- /dev/null +++ b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook @@ -0,0 +1,12 @@ +[Trigger] +Operation = Upgrade +Operation = Remove +Type = Package +Target = omarchy-settings +Target = omarchy-settings-dev + +[Action] +Description = Revoking temporary Omarchy sudo grants before settings changes... +When = PreTransaction +Exec = /usr/bin/omarchy-sudo-passwordless __package-removing +AbortOnFail diff --git a/default/omarchy/sudo-no-update/sudo b/default/omarchy/sudo-no-update/sudo new file mode 100755 index 00000000000..7019a4dc0e2 --- /dev/null +++ b/default/omarchy/sudo-no-update/sudo @@ -0,0 +1,20 @@ +#!/bin/bash -p + +# Preserve sudo options while preventing authentication from refreshing the +# credential cache. Timestamp maintenance and informational modes stand alone. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 + exit 126 +fi +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/../../../bin/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 + +if (( $# == 1 )); then + case "$1" in + -k|--reset-timestamp|-K|--remove-timestamp|-h|--help|-V|--version) + exec /usr/bin/sudo "$@" + ;; + esac +fi +exec /usr/bin/sudo -N "$@" diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md new file mode 100644 index 00000000000..0e805e6a2ee --- /dev/null +++ b/docs/passwordless-sudo.md @@ -0,0 +1,25 @@ +# Temporary passwordless sudo + +`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`. + +## Grant lifecycle + +Root state records the resolved account name, absolute expiry epoch and unique timer name. A calendar timer is armed and verified before the generated policy becomes active. The sudoers rule also embeds the same UTC deadline with `NOTAFTER`, so sudo independently rejects it after expiry even if timer cleanup is delayed. Publication rechecks the package-owned boot cleanup before and after installing policy. Policy revocation must succeed before expiry jobs are stopped; a deletion error leaves those jobs armed and reports that administrator cleanup is required. + +An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists. + +Each new expiry callback carries its timer identity. A delayed predecessor cannot revoke a newer grant. Already scheduled UID-only callbacks remain compatible by checking the current grant's expiry. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; it does not run during routine non-boot tmpfiles maintenance. + +## Package ownership + +The packaging companion must put the publication/expiry command, `omarchy-security-functions` `omarchy-nopasswd-sudo.conf` and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files. + +Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant. + +The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch. + +## Validation + +`test/shell.d/nopasswd-sudo-expiry-test.sh` covers the public interface, cold authentication, timer setup, boot cleanup, package transitions and lock contention. `test/shell.d/passwordless-grant-lifecycle-test.sh` covers publication/cleanup failures, error status, supported account syntax, predecessor callbacks and the shared package-removal lock. Supply `OMARCHY_PKGS_PATH` as either a repository root or its `pkgbuilds` directory. + +These tests use private filesystem fixtures and mapped privileged commands. Package archive ownership, actual install/upgrade/removal, real calendar expiry, suspend/resume and boot cleanup must also be validated in a disposable VM before claiming release readiness. Changes to the common library require integration checks on the downstream update, migration, installer, package-picker and diagnostic PRs. diff --git a/docs/update-process.md b/docs/update-process.md index bec3350d1d4..3ec46bd5b01 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -22,6 +22,7 @@ The design goal is: | Path | Owner | Purpose | | --- | --- | --- | | `${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.lock` | user | Prevent overlapping update runs. Owned by `omarchy-update-lock`; compatibility wrappers inherit/respect it. | +| `${XDG_RUNTIME_DIR}/omarchy-update-stay-awake/` | user | Private mode-0700 inhibitor coordination state. If no runtime directory is available, the helper uses the validated mode-0700 `/tmp/omarchy-$UID/` fallback. | | `/tmp/omarchy-update.log` | user | Transcript of `omarchy update`, used by `omarchy-update-analyze-logs`. | | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | @@ -56,6 +57,10 @@ 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. +Both the update and standalone migration runner start with a cold credential state and use the no-update sudo wrapper. The runner revokes again on success, failure, and catchable termination signals. Historical migrations remain strictly ordered, and failed migrations remain pending. + +The standalone migration runner and SSH setup command also require their source root to match the running entrypoint before selecting the wrapper or migration files. The migration directory remains `$OMARCHY_PATH/migrations` after that validation; no fallback source tree is inferred from the user's home or configuration. + For watchers and diagnostics, `omarchy-migrate --pending` prints pending migration names and exits `0` when any are pending. When no migrations are pending, it prints nothing and exits non-zero. @@ -125,21 +130,32 @@ omarchy-update │ installed but unconfigured fails the snapshot loudly, pointing at │ install/config/snapper.sh, and the update continues without one) ├─ omarchy-update-stay-awake start - ├─ run package updates, migrations, hooks, and log analysis + ├─ run system-package updates + ├─ invalidate sudo, then run migrations and all later privileged work with + │ no-update authentication + ├─ run orphan review and log analysis ├─ omarchy-update-status │ └─ refresh or clear the shell update indicator + ├─ restart marked services and the shell ├─ omarchy-update-stay-awake stop │ └─ release the sleep inhibitor and restore shell idle state, if changed - └─ omarchy-update-restart + ├─ update AUR packages + ├─ invalidate sudo credentials + ├─ run the post-update hook, invalidate again, then update mise tools + └─ offer the unprivileged reboot prompt ``` Important behavior: -- In dev-link mode, `omarchy update` fast-forwards the active checkout from its - configured upstream before changing system packages or running migrations. -- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` — a promise not to ask anything. - Steps that would prompt (orphan removal, conflict handoff) report and skip - instead of blocking. +- Protected update entrypoints require the session's canonical `OMARCHY_PATH` to match their own checkout or the packaged `/usr/bin` entrypoint before selecting commands or the sudo wrapper. This preserves intentionally trusted development checkouts while rejecting a command paired with a different source root. System phases use a fixed command search path; user PATH is restored behind the sudo wrapper for hooks and mise. +- Mixed-trust update entrypoints start Bash in privileged mode, discard `BASH_ENV`, `ENV`, and exported-function records before launching helpers, and reject an ordinary `bash path/to/command` invocation. Run them as executables (normally through the `omarchy` CLI); `/usr/bin/bash -p path/to/command` is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary. +- In dev-link mode, `omarchy update` fast-forwards the active checkout from its configured upstream before changing system packages or running migrations. +- Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. +- User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. +- This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. +- Sleep inhibition authenticates before detaching, drops the held command back to the caller, and closes both update lock descriptors before the persistent process starts. Cleanup accepts only caller-owned, mode-0600, single-link state and revalidates the recorded PID, process start time, owner, and random token immediately before every signal. +- Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and runs the deferred refresh hook only after the full update succeeds and authorization is revoked again. Failed and interrupted channel switches revoke on exit. +- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before confirmation when it is not met. If free space cannot be determined, the check is silently skipped. Set `OMARCHY_UPDATE_FORCE=1` to bypass the check. @@ -251,6 +267,9 @@ which pacman repo the mirrorlist points at (and swap between the `omarchy` and `omarchy-dev` packages through a guard-allowed pacman run), while `dev` links the runtime to a git checkout via the dev-link mechanism, after which `omarchy update` fast-forwards that checkout instead of upgrading a package. +Channel switching defers the legacy `pre-refresh-pacman` hook across the package +swap and the complete update. The hook runs exactly once at the final cold +credential boundary; it is skipped if the composite operation fails earlier. There is no version file at runtime. `omarchy-version` derives the version from `pacman -Q` on whichever package is installed, or reports `dev ()` for a @@ -285,7 +304,7 @@ scripts. | `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | | `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | -| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates, restarts components with `restart-*-required` markers, and always restarts the shell. | **Keep.** Important final step; may eventually include service-restart checks. | +| `omarchy-update-restart` | Restarts components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | diff --git a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf index 2c644ff1fc9..be81137ad05 100644 --- a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +++ b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -1,5 +1,5 @@ -# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd- and -# arms a transient systemd-run timer to remove it again. Transient units do not -# survive a reboot, so remove any remaining grant during early boot. Boot-only -# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short. +# omarchy-sudo-passwordless creates grants in this owned filename namespace. +# Transient expiry timers do not survive reboot, so early boot removes every +# remaining grant. The boot-only modifier prevents later tmpfiles runs from +# shortening a live, explicitly requested window. r! /etc/sudoers.d/99-omarchy-nopasswd-* diff --git a/manual/48-security.md b/manual/48-security.md index 45750398e30..6db0dff5f17 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,9 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well. +Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough. + +Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive. Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk. diff --git a/migrations/1785944594.sh b/migrations/1785944594.sh index 823fe952425..4f03440e3b2 100644 --- a/migrations/1785944594.sh +++ b/migrations/1785944594.sh @@ -1,27 +1,51 @@ echo "Update T2 Mac suspend, Touch Bar, and fan defaults" -if ! lspci -nn | grep "106b:180[12]" >/dev/null; then - exit 0 -fi +limine_conf=/etc/limine-entry-tool.d/t2-mac.conf +fan_conf=/etc/t2fand.conf +repair_marker=/var/lib/omarchy/migrations/1785944594 -limine_conf="${OMARCHY_T2_LIMINE_CONF:-/etc/limine-entry-tool.d/t2-mac.conf}" -fan_conf="${OMARCHY_T2_FAN_CONF:-/etc/t2fand.conf}" -running_cmdline="${OMARCHY_T2_RUNNING_CMDLINE:-/proc/cmdline}" -repair_marker="${OMARCHY_T2_REPAIR_MARKER:-/var/lib/omarchy/migrations/1785944594}" -needs_limine_rebuild=0 +is_t2_mac() { + local devices + devices=$(/usr/bin/lspci -nn) || return 2 + [[ $devices =~ 106b:180[12] ]] +} -if [[ -f $limine_conf ]] && grep -q 'pcie_ports=compat' "$limine_conf"; then - sudo sed -i \ - 's/pcie_ports=compat/pm_async=off mem_sleep_default=deep/' \ - "$limine_conf" - needs_limine_rebuild=1 -fi +tiny_dfr_installed() { + local packages + packages=$(/usr/bin/pacman -Qq) || return 2 + [[ $'\n'$packages$'\n' == *$'\ntiny-dfr\n'* ]] +} -# t2fanrd reads one section per detected fan and fails when a section is -# missing. Extra sections are ignored, so this also remains safe on one-fan -# models. -if [[ -f $fan_conf ]] && ! grep -Eq '^[[:space:]]*\[Fan2\][[:space:]]*$' "$fan_conf"; then - sudo tee -a "$fan_conf" >/dev/null <<'EOF' +needs_machine_repair() { + local status + if is_t2_mac; then + : + else + status=$? + (( status == 1 )) && return 1 + return 2 + fi + [[ ! -e $repair_marker ]] || return 1 + return 0 +} + +repair_machine() { + local rebuild=0 + local status + if needs_machine_repair; then + : + else + status=$? + (( status == 1 )) && return 0 + echo "Could not inspect T2 hardware or packages; leaving the repair pending." >&2 + return 1 + fi + if [[ -f $limine_conf ]] && /usr/bin/grep -q 'pcie_ports=compat' "$limine_conf"; then + /usr/bin/sed -i 's/pcie_ports=compat/pm_async=off mem_sleep_default=deep/' "$limine_conf" || return 1 + rebuild=1 + fi + if [[ -f $fan_conf ]] && ! /usr/bin/grep -Eq '^[[:space:]]*\[Fan2\][[:space:]]*$' "$fan_conf"; then + /usr/bin/tee -a "$fan_conf" >/dev/null <<'EOF' || return 1 [Fan2] low_temp=55 @@ -29,29 +53,38 @@ high_temp=75 speed_curve=linear always_full_speed=false EOF -fi - -# The kernel's built-in Boot Camp-style Touch Bar works without tiny-dfr. The -# optional daemon holds stale device descriptors across suspend with t2bce. -if omarchy-pkg-present tiny-dfr; then - sudo systemctl disable --now tiny-dfr.service || true - omarchy-pkg-drop tiny-dfr -fi - -# The current kernel keeps its old command line until reboot. Record a -# successful machine-wide rebuild so another user's migration does not repeat -# it before then, while a missing marker still retries an interrupted rebuild. -if [[ -f $limine_conf ]] && - [[ ! -e $repair_marker ]] && - grep -q 'pm_async=off' "$limine_conf" && - grep -q 'mem_sleep_default=deep' "$limine_conf" && - { [[ ! -r $running_cmdline ]] || - ! grep -Eq '(^| )pm_async=off( |$)' "$running_cmdline" || - ! grep -Eq '(^| )mem_sleep_default=deep( |$)' "$running_cmdline"; }; then - needs_limine_rebuild=1 -fi + fi + if tiny_dfr_installed; then + /usr/bin/systemctl disable --now tiny-dfr.service || true + /usr/bin/env OMARCHY_UPDATE_PACMAN=1 /usr/bin/pacman -Rns --noconfirm -- tiny-dfr || return 1 + else + status=$? + if (( status != 1 )); then + echo "Could not inspect installed packages; leaving the T2 repair pending." >&2 + return 1 + fi + fi + if [[ -f $limine_conf ]] && /usr/bin/grep -q 'pm_async=off' "$limine_conf" && + /usr/bin/grep -q 'mem_sleep_default=deep' "$limine_conf"; then rebuild=1; fi + if (( rebuild )); then /usr/bin/limine-mkinitcpio || return 1; fi + /usr/bin/install -Dm644 /dev/null "$repair_marker" || return 1 +} -if (( needs_limine_rebuild )); then - sudo limine-mkinitcpio - sudo install -Dm644 /dev/null "$repair_marker" +if (( $# == 0 )); then + if needs_machine_repair; then + : + else + status=$? + (( status == 1 )) && exit 0 + echo "Could not inspect T2 hardware or packages; leaving the repair pending." >&2 + exit 1 + fi + /usr/bin/sudo -N -- /usr/bin/flock --exclusive --no-fork /run/omarchy-t2-hardware-migration.lock \ + /usr/bin/env -i PATH=/usr/bin:/bin \ + /usr/bin/bash -p -euo pipefail /usr/share/omarchy/migrations/1785944594.sh --machine +elif (( $# == 1 && EUID == 0 )) && [[ $1 == "--machine" ]]; then + repair_machine +else + echo "This migration accepts no arguments; its machine phase requires root." >&2 + exit 1 fi diff --git a/migrations/1786380259.sh b/migrations/1786380259.sh index ebe9af2c31c..e76faad2672 100644 --- a/migrations/1786380259.sh +++ b/migrations/1786380259.sh @@ -1,36 +1,44 @@ echo "Remember Bluetooth on and off through the rfkill soft block" -marker="${OMARCHY_BLUETOOTH_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1786380259}" -main_conf="${OMARCHY_BLUETOOTH_MAIN_CONF:-/etc/bluetooth/main.conf}" +marker=/var/lib/omarchy/migrations/1786380259 +main_conf=/etc/bluetooth/main.conf -# Machine-wide work, but migration completion is recorded per user, so a second -# account would run it again and undo whatever an administrator changed in -# between. Written last, so an interrupted run is retried rather than skipped. -if [[ -e $marker ]]; then - exit 0 -fi +repair_machine() { + local controllers controller details powered=0 + [[ ! -e $marker ]] || return 0 -# Read the machine as it stands before anything below changes it. Powered is the -# only record of what the user chose, and with AutoEnable=false holding the -# adapter down at every boot, no daemon to ask means off is what they have been -# living with. -# -# sudo because this runs machine-wide and /dev/rfkill is only writable without it -# from an active graphical seat — an update over SSH would otherwise abort here, -# before the marker, and abort again on every retry. -if omarchy-bluetooth-power is-on; then - sudo omarchy-bluetooth-power on -else - sudo omarchy-bluetooth-power off -fi + controllers=$(/usr/bin/timeout 2s /usr/bin/bluetoothctl list) || { + echo "Could not read Bluetooth power state; leaving the migration pending." >&2 + return 1 + } + while read -r _ controller _; do + [[ -n ${controller:-} ]] || continue + details=$(/usr/bin/timeout 2s /usr/bin/bluetoothctl show "$controller") || { + echo "Could not read Bluetooth controller $controller; leaving the migration pending." >&2 + return 1 + } + [[ $details == *"Powered: yes"* ]] && powered=1 + done <<<"$controllers" -# Omarchy set AutoEnable=false believing bluetoothd would then restore the last -# power state. It has no such behaviour, so all the flag ever did was keep -# Bluetooth off at every boot. Left in place it would also stop bluetoothd from -# powering the adapter up when the block above is lifted. Only the exact line -# Omarchy wrote is reverted, so a hand-edited opt-out survives. -if [[ -f $main_conf ]]; then - sudo sed -i 's/^AutoEnable=false$/#AutoEnable=true/' "$main_conf" -fi + if (( powered )); then + /usr/bin/omarchy-bluetooth-power on || return 1 + else + /usr/bin/omarchy-bluetooth-power off || return 1 + fi + if [[ -f $main_conf ]]; then + /usr/bin/sed -i 's/^AutoEnable=false$/#AutoEnable=true/' "$main_conf" || return 1 + fi + /usr/bin/install -Dm644 /dev/null "$marker" || return 1 +} -sudo install -Dm644 /dev/null "$marker" +if (( $# == 0 )); then + [[ ! -e $marker ]] || exit 0 + /usr/bin/sudo -N -- /usr/bin/flock --exclusive --no-fork /run/omarchy-bluetooth-state-migration.lock \ + /usr/bin/env -i PATH=/usr/bin:/bin \ + /usr/bin/bash -p -euo pipefail /usr/share/omarchy/migrations/1786380259.sh --machine +elif (( $# == 1 && EUID == 0 )) && [[ $1 == "--machine" ]]; then + repair_machine +else + echo "This migration accepts no arguments; its machine phase requires root." >&2 + exit 1 +fi diff --git a/migrations/1787494718.sh b/migrations/1787494718.sh index 0f11ada6e39..5f3f0a74fb5 100644 --- a/migrations/1787494718.sh +++ b/migrations/1787494718.sh @@ -1,116 +1,75 @@ echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root" -authfile="/etc/fido2/fido2" +authfile=/etc/fido2/fido2 -# omarchy-migrate records this migration as complete whenever it exits zero, so -# a line printed here scrolls past once in the update terminal and is never -# shown again. The states below cannot be repaired without deciding what to do -# with a file we do not own, and they are exactly the ones where the authfile -# may already be under someone else's control, so say so where it outlives the -# scrollback as well. report_unrepairable() { echo " $1" echo " $2" omarchy-notification-send -u critical -g  "FIDO2 authfile needs attention" "$1 $2" || true } -# Nothing to repair on any machine that never set FIDO2 up, which is almost all -# of them. Checked before any sudo so those machines never see a password -# prompt. -L as well as -e: a dangling symlink is invisible to -e. -if [[ ! -L $authfile && ! -e $authfile ]]; then - # Absence and "cannot look" are the same answer to the tests above. The old - # setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the - # caller's umask and sudoers' 0022, so anyone registering under `umask 077` - # left it mode 0700 with the user-owned authfile still inside. Escalate for - # that case alone -- a machine that never set FIDO2 up has no directory here - # and still reaches exit 0 without a password prompt. Not through a symlink: - # chmod would act on whatever it points at. - authdir=${authfile%/*} - - if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then - exit 0 - fi - - # Ask root whether a registration is behind it before touching the directory - # itself. An aborted setup that left an empty 0700 directory, or one an - # administrator deliberately keeps private, must not have its mode widened - # and its group and special bits discarded for a repair it does not need. - if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then - exit 0 +needs_machine_repair() { + local owner group mode authdir=${authfile%/*} + if [[ ! -L $authfile && ! -e $authfile ]]; then + [[ ! -L $authdir && -d $authdir && ! -x $authdir ]] && return 0 + return 1 fi - - sudo chmod 755 "$authdir" -fi - -# The old privileged move could install a symlink here if its fixed staging path -# was redirected. Reported, not repaired: chown follows symlinks and would take -# ownership of the target instead, and removing it would strip sudo and polkit -# from anyone whose only credential is the token. -if [[ -L $authfile ]]; then - report_unrepairable "$authfile is a symlink, not a regular file." \ - "Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2." - exit 0 -fi - -# A directory or a device here is no more ours to rewrite than a symlink is, -# and changing a directory's mode would alter an object we do not own. -if [[ ! -f $authfile ]]; then - report_unrepairable "$authfile is not a regular file." \ - "Leaving it alone. Remove it and re-run Setup > Security > Fido2." - exit 0 -fi - -# Migration state is per-user, so every account re-runs this. The file's own -# ownership is the state check: the second account finds the repair already -# done and exits without escalating. -owner=$(stat -c %U "$authfile" 2>/dev/null) || owner="" -group=$(stat -c %G "$authfile" 2>/dev/null) || group="" -mode=$(stat -c %a "$authfile" 2>/dev/null) || mode="" -if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then - exit 0 -fi - -# Setup used to `mv` this in from /tmp, which carried the invoking user's -# ownership into /etc. Root ownership stops that user from rewriting their own -# PAM credential without root. Mode 644 keeps the public credential mapping -# readable when pam_u2f opens an absolute authfile as the authenticating user. -# -# Rename a fresh copy over the path rather than chowning in place. A descriptor -# opened while the file was still the user's own stays writable on that inode -# through any later chmod or chown, since permission is checked at open(2), and -# pam_u2f resolving the path would keep landing on it. Replacing the inode -# leaves that descriptor writing to a file nothing reads. -stage="" - -safe_stage_path() { - local candidate=$1 - local prefix="$authfile.new." - local suffix - - [[ $candidate == "$prefix"* ]] || return 1 - suffix=${candidate#"$prefix"} - [[ $suffix =~ ^[[:alnum:]]{6}$ ]] + [[ -L $authfile ]] && return 0 + [[ -f $authfile ]] || return 0 + owner=$(/usr/bin/stat -c %U "$authfile" 2>/dev/null) || return 0 + group=$(/usr/bin/stat -c %G "$authfile" 2>/dev/null) || return 0 + mode=$(/usr/bin/stat -c %a "$authfile" 2>/dev/null) || return 0 + [[ $owner != "root" || $group != "root" || $mode != "644" ]] } -cleanup_stage() { - local status=$? - - if safe_stage_path "$stage"; then - sudo rm -f -- "$stage" || true +repair_machine() ( + local owner group mode authdir=${authfile%/*} authdir_mode stage="" + if [[ ! -L $authfile && ! -e $authfile ]]; then + [[ ! -L $authdir && -d $authdir ]] || return 0 + if [[ ! -e $authfile && ! -L $authfile ]]; then return 0; fi fi - - return "$status" -} - -trap cleanup_stage EXIT -stage=$(sudo mktemp "$authfile.new.XXXXXX") - -if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then - echo " Could not create a safe staging file beside $authfile." + if [[ ! -L $authdir && -d $authdir && ( -e $authfile || -L $authfile ) ]]; then + authdir_mode=$(/usr/bin/stat -c %a "$authdir") + if (( (10#${authdir_mode: -1} & 1) == 0 )); then + /usr/bin/chmod 755 "$authdir" + fi + fi + [[ ! -L $authfile ]] || return 20 + [[ -f $authfile ]] || return 21 + owner=$(/usr/bin/stat -c %U "$authfile") + group=$(/usr/bin/stat -c %G "$authfile") + mode=$(/usr/bin/stat -c %a "$authfile") + [[ $owner != "root" || $group != "root" || $mode != "644" ]] || return 0 + + stage=$(/usr/bin/mktemp "$authfile.new.XXXXXX") + trap '[[ -z $stage ]] || /usr/bin/rm -f -- "$stage"' EXIT + [[ $stage == "$authfile.new."* && ${stage#"$authfile.new."} =~ ^[[:alnum:]]{6}$ && -f $stage && ! -L $stage ]] + /usr/bin/install -T -m 644 -o root -g root "$authfile" "$stage" + /usr/bin/mv -Tf "$stage" "$authfile" + stage="" +) + +if (( $# == 0 )); then + needs_machine_repair || exit 0 + status=0 + /usr/bin/sudo -N -- /usr/bin/flock --exclusive --no-fork /run/omarchy-fido2-authfile-migration.lock \ + /usr/bin/env -i PATH=/usr/bin:/bin \ + /usr/bin/bash -p -euo pipefail /usr/share/omarchy/migrations/1787494718.sh --machine || status=$? + case $status in + 0) ;; + 20) + report_unrepairable "$authfile is a symlink, not a regular file." \ + "Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2." + ;; + 21) + report_unrepairable "$authfile is not a regular file." \ + "Leaving it alone. Remove it and re-run Setup > Security > Fido2." + ;; + *) exit "$status" ;; + esac +elif (( $# == 1 && EUID == 0 )) && [[ $1 == "--machine" ]]; then + repair_machine +else + echo "This migration accepts no arguments; its machine phase requires root." >&2 exit 1 fi - -sudo install -T -m 644 -o root -g root "$authfile" "$stage" -sudo mv -Tf "$stage" "$authfile" -stage="" -trap - EXIT diff --git a/migrations/1787815267.sh b/migrations/1787815267.sh index b3f9282a966..e304fcada70 100644 --- a/migrations/1787815267.sh +++ b/migrations/1787815267.sh @@ -1,57 +1,108 @@ echo "Separate printer discovery from root and print-filter access" -machine_marker="${OMARCHY_CUPS_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1787815267}" - -[[ ! -e $machine_marker ]] || exit 0 - -# Existing releases allowed a desktop user or shared group named cups-browsed, -# which systemd-sysusers would silently reuse for passwordless CUPS access. -if omarchy-pkg-present cups; then - cups_browsed_account=$(getent passwd cups-browsed || true) - cups_browsed_group=$(getent group cups-browsed || true) - - if [[ -n $cups_browsed_account || -n $cups_browsed_group ]]; then - IFS=: read -r _ _ cups_browsed_uid cups_browsed_gid cups_browsed_description cups_browsed_home cups_browsed_shell <<<"$cups_browsed_account" - IFS=: read -r _ _ cups_browsed_group_gid cups_browsed_group_members <<<"$cups_browsed_group" - other_primary_user=$(getent passwd | awk -F: -v gid="$cups_browsed_gid" '$1 != "cups-browsed" && $4 == gid { print $1; exit }') - - if [[ ! $cups_browsed_uid =~ ^[0-9]+$ || ! $cups_browsed_group_gid =~ ^[0-9]+$ ]] || - ((cups_browsed_uid <= 0 || cups_browsed_uid >= 1000)) || - [[ $cups_browsed_gid != $cups_browsed_group_gid ]] || - [[ $cups_browsed_description != "CUPS printer discovery" || $cups_browsed_home != "/" || $cups_browsed_shell != "/usr/bin/nologin" ]] || - [[ -n $cups_browsed_group_members || -n $other_primary_user ]]; then - echo "Cannot harden printer discovery: the existing cups-browsed user or group is not a dedicated system account." >&2 - false - fi +machine_marker=/var/lib/omarchy/migrations/1787815267 +installed_packages="" + +load_installed_packages() { + installed_packages=$(/usr/bin/pacman -Qq) || { + echo "Could not inspect installed packages; leaving CUPS hardening pending." >&2 + return 1 + } +} + +package_installed() { [[ $'\n'$installed_packages$'\n' == *$'\n'"$1"$'\n'* ]]; } + +nss_record() { + local database=$1 name=$2 output status + output=$(/usr/bin/getent "$database" "$name") && status=0 || status=$? + if (( status == 0 )); then + printf '%s' "$output" + elif (( status == 2 )); then + return 1 + else + echo "Could not inspect the $name $database record; leaving CUPS hardening pending." >&2 + return 2 fi -fi +} -# CUPS-PDF accepts a job-controlled post-processing command in a backend that -# CUPS launches as root. Native application print-to-file support replaces it. -omarchy-pkg-drop cups-pdf +unit_active() { + local status + /usr/bin/systemctl is-active --quiet "$1" 2>/dev/null && return 0 + status=$? + (( status == 3 )) && return 1 + echo "Could not inspect whether $1 is active; leaving CUPS hardening pending." >&2 + return 2 +} -# system-config-printer uses this helper to request printer administration -# through Polkit now that the desktop user's wheel group is no longer @SYSTEM. -if omarchy-pkg-present cups; then - omarchy-pkg-add cups-pk-helper -fi +unit_enabled() { + local state status + state=$(/usr/bin/systemctl is-enabled "$1" 2>/dev/null) && status=0 || status=$? + if (( status == 0 )); then return 0; fi + case $state in disabled|masked|masked-runtime|static|indirect|generated|transient|alias|linked|linked-runtime) return 1 ;; esac + echo "Could not inspect whether $1 is enabled; leaving CUPS hardening pending." >&2 + return 2 +} -# Stop the root-running daemon before changing the authorization it relies on. -if systemctl is-active --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl stop cups-browsed.service -fi +repair_machine() { + local account="" group="" uid="" gid="" description="" home="" shell="" group_gid="" members="" other_primary_user="" passwd_records="" + local status + [[ ! -e $machine_marker ]] || return 0 + load_installed_packages || return 1 -if omarchy-pkg-present cups; then - sudo systemctl daemon-reload - sudo systemctl try-reload-or-restart cups.service -fi + if package_installed cups; then + account=$(nss_record passwd cups-browsed) || { status=$?; (( status == 1 )) || return 1; account=""; } + group=$(nss_record group cups-browsed) || { status=$?; (( status == 1 )) || return 1; group=""; } + if [[ -n $account || -n $group ]]; then + IFS=: read -r _ _ uid gid description home shell <<<"$account" + IFS=: read -r _ _ group_gid members <<<"$group" + if ! passwd_records=$(/usr/bin/getent passwd); then + echo "Could not enumerate passwd records; leaving CUPS hardening pending." >&2 + return 1 + fi + other_primary_user=$(/usr/bin/awk -F: -v gid="$gid" '$1 != "cups-browsed" && $4 == gid { print $1; exit }' <<<"$passwd_records") || return 1 + if [[ ! $uid =~ ^[0-9]+$ || ! $group_gid =~ ^[0-9]+$ ]] || + (( uid <= 0 || uid >= 1000 )) || [[ $gid != "$group_gid" ]] || + [[ $description != "CUPS printer discovery" || $home != "/" || $shell != "/usr/bin/nologin" ]] || + [[ -n $members || -n $other_primary_user ]]; then + echo "Cannot harden printer discovery: the existing cups-browsed user or group is not a dedicated system account." >&2 + return 1 + fi + fi + fi -# Resume on whether the unit is enabled, not on whether it was running when this -# run started: an interrupted earlier run leaves it stopped, and a retry that -# recomputed that would skip the restart and still write the marker below. A -# masked or disabled unit reports not-enabled and is left alone. -if systemctl is-enabled --quiet cups-browsed.service 2>/dev/null; then - sudo systemctl restart cups-browsed.service -fi + if package_installed cups-pdf; then + /usr/bin/env OMARCHY_UPDATE_PACMAN=1 /usr/bin/pacman -Rns --noconfirm -- cups-pdf || return 1 + fi + if package_installed cups && ! package_installed cups-pk-helper; then + /usr/bin/env OMARCHY_UPDATE_PACMAN=1 /usr/bin/pacman -S --needed --noconfirm -- cups-pk-helper || return 1 + fi + if unit_active cups-browsed.service; then + /usr/bin/systemctl stop cups-browsed.service || return 1 + else + status=$? + (( status == 1 )) || return 1 + fi + if package_installed cups; then + /usr/bin/systemctl daemon-reload || return 1 + /usr/bin/systemctl try-reload-or-restart cups.service || return 1 + fi + if unit_enabled cups-browsed.service; then + /usr/bin/systemctl restart cups-browsed.service || return 1 + else + status=$? + (( status == 1 )) || return 1 + fi + /usr/bin/install -Dm644 /dev/null "$machine_marker" || return 1 +} -sudo install -Dm644 /dev/null "$machine_marker" +if (( $# == 0 )); then + [[ ! -e $machine_marker ]] || exit 0 + /usr/bin/sudo -N -- /usr/bin/flock --exclusive --no-fork /run/omarchy-cups-hardening-migration.lock \ + /usr/bin/env -i PATH=/usr/bin:/bin \ + /usr/bin/bash -p -euo pipefail /usr/share/omarchy/migrations/1787815267.sh --machine +elif (( $# == 1 && EUID == 0 )) && [[ $1 == "--machine" ]]; then + repair_machine +else + echo "This migration accepts no arguments; its machine phase requires root." >&2 + exit 1 +fi diff --git a/migrations/1788163635.sh b/migrations/1788163635.sh new file mode 100644 index 00000000000..e2fe22725d2 --- /dev/null +++ b/migrations/1788163635.sh @@ -0,0 +1,6 @@ +echo "Remove legacy temporary passwordless sudo grants" + +# This removes current numeric grants, exact legacy username grants, corrupt or +# orphaned state, and their known timers. Administrator-authored sudoers files +# whose contents do not exactly match Omarchy's generated grammar are preserved. +sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all diff --git a/migrations/1788163637.sh b/migrations/1788163637.sh new file mode 100644 index 00000000000..02c0fff2403 --- /dev/null +++ b/migrations/1788163637.sh @@ -0,0 +1,20 @@ +echo "Upgrade Omarchy-managed SSH hardening to a machine-validated key-only policy" + +if ((EUID == 0)); then + /usr/bin/omarchy-migrate-sshd-key-only +else + /usr/bin/sudo -k + cleanup_ssh_migration_sudo() { + local status=$? + trap - EXIT HUP INT TERM + /usr/bin/sudo -k >/dev/null 2>&1 || status=1 + exit "$status" + } + trap cleanup_ssh_migration_sudo EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + /usr/bin/sudo -N -- /usr/bin/omarchy-migrate-sshd-key-only + /usr/bin/sudo -k + trap - EXIT HUP INT TERM +fi diff --git a/test/shell.d/channel-sudo-boundary-test.sh b/test/shell.d/channel-sudo-boundary-test.sh new file mode 100755 index 00000000000..b7fabf0257e --- /dev/null +++ b/test/shell.d/channel-sudo-boundary-test.sh @@ -0,0 +1,143 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-channel-set +copy_boundary_file bin/omarchy-refresh-pacman +copy_boundary_file bin/omarchy-update +export OMARCHY_UPDATE_LOGGED=1 + +# Relocate the package root into the fixture, including the explicit handoff +# from the development checkout. All privileged operations remain stand-ins. +python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$SUDO_TEST_ROOT" <<'PY' +import sys +from pathlib import Path +p = Path(sys.argv[1]) +p.write_text(p.read_text().replace('/usr/share/omarchy', sys.argv[2])) +PY + +for command in omarchy-dev-link omarchy-dev-unlink omarchy-state gum git; do + cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB' +#!/bin/bash +set -euo pipefail +step=${0##*/} +printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG" +case "$step" in + omarchy-dev-link|omarchy-dev-unlink) sudo /usr/bin/true ;; + git) + [[ $1 == "clone" ]] || exit 90 + /usr/bin/cp -a "$SUDO_TEST_ROOT" "${@: -1}" + mkdir -p "${@: -1}/.git" "${@: -1}/shell" + ;; +esac +STUB + chmod +x "$SUDO_TEST_ROOT/bin/$command" +done + +assert_scoped_channel() { + local label=$1 + assert_boundary_cold "$label" + python3 - "$SUDO_TEST_LOG" <<'PY' +import sys +events = open(sys.argv[1]).read().splitlines() +assert events[0] == 'sudo -k', events +sudo = [event for event in events if event.startswith('sudo ')] +assert all(event in ('sudo -h', 'sudo -k') or event.startswith('sudo -N ') for event in sudo), events +hooks = [i for i, event in enumerate(events) if event.startswith('step:omarchy-hook ')] +assert len(hooks) == 2, events +assert events[hooks[-1]] == 'step:omarchy-hook pre-refresh-pacman', events +assert not any(event.startswith('sudo -N ') for event in events[hooks[0]:]), events +PY +} + +run_channel() { + "$OMARCHY_PATH/bin/omarchy-channel-set" "$@" >"$boundary_tmp/output" 2>&1 +} +for channel in stable rc edge dev; do + reset_boundary + run_channel "$channel" || fail "$channel failed" "$(<"$boundary_tmp/output")" + assert_scoped_channel "$channel" + pass "$channel starts cold, authorizes only individual commands, defers hooks and exits cold" +done + +reset_boundary +wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo" +mv "$wrapper" "$boundary_tmp/saved-wrapper" +if run_channel dev; then fail "an old checkout without the wrapper was accepted"; fi +if grep -Eq '^step:omarchy-(dev-link|state)|^sudo -N ' "$SUDO_TEST_LOG"; then + fail "an incompatible dev checkout changed the system before rejection" +fi +grep -q 'Update the checkout before switching to dev' "$boundary_tmp/output" || fail "stale checkout rejection lacks recovery guidance" +assert_boundary_cold "stale checkout" +mv "$boundary_tmp/saved-wrapper" "$wrapper" +pass "a stale dev checkout is rejected before linking or privileged work" + +reset_boundary +OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")" +assert_scoped_channel "dev to stable" +pass "leaving dev preserves no-update sudo through unlink and the packaged update" + +mkdir "$boundary_tmp/user tools" +cat >"$boundary_tmp/user tools/channel-user-tool" <<'STUB' +#!/bin/bash +printf 'user-tool:%s\n' "$*" >>"$SUDO_TEST_LOG" +STUB +chmod +x "$boundary_tmp/user tools/channel-user-tool" +for command in omarchy-hook omarchy-update-mise; do + rm "$SUDO_TEST_ROOT/bin/$command" + cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB' +#!/bin/bash +[[ ! -e $SUDO_TEST_CACHE ]] || exit 91 +[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92 +channel-user-tool "${0##*/}" "$@" +STUB + chmod +x "$SUDO_TEST_ROOT/bin/$command" +done +reset_boundary +PATH="$boundary_tmp/user tools:$PATH" run_channel stable || fail "channel hooks lost the user's PATH" "$(<"$boundary_tmp/output")" +for event in 'omarchy-hook post-update' 'omarchy-update-mise' 'omarchy-hook pre-refresh-pacman'; do + grep -Fxq "user-tool:$event" "$SUDO_TEST_LOG" || fail "user PATH was not preserved for $event" +done +assert_boundary_cold "channel user PATH" +for command in omarchy-hook omarchy-update-mise; do + ln -sfn test-step "$SUDO_TEST_ROOT/bin/$command" +done +pass "channel switching preserves user tools behind the wrapper for both hooks and mise" + +for step in pacman omarchy-update-system-pkgs omarchy-hook; do + reset_boundary + if SUDO_TEST_FAIL_STEP="$step" run_channel stable; then fail "$step failure was ignored"; fi + assert_boundary_cold "$step failure" + if grep -q '^step:omarchy-hook pre-refresh-pacman$' "$SUDO_TEST_LOG"; then fail "$step failure reached the deferred hook"; fi + pass "$step failure exits cold without the deferred hook" +done + +for signal in HUP INT TERM; do + reset_boundary + cat >"$SUDO_TEST_ROOT/bin/omarchy-dev-unlink" <<'STUB' +#!/bin/bash +sudo /usr/bin/true || exit 1 +kill -s "$SUDO_TEST_CHANNEL_SIGNAL" "$PPID" +STUB + if SUDO_TEST_CHANNEL_SIGNAL="$signal" run_channel stable; then fail "$signal was ignored"; fi + assert_boundary_cold "$signal" + if grep -q '^step:omarchy-hook ' "$SUDO_TEST_LOG"; then fail "$signal reached an update hook"; fi + pass "$signal stops the channel transition and revokes authorization" +done + +for refusal in unsupported-sudo failed-revocation ordinary-bash; do + reset_boundary + case "$refusal" in + unsupported-sudo) export SUDO_TEST_UNSUPPORTED=1 ;; + failed-revocation) export SUDO_TEST_REVOKE_FAIL=1 ;; + esac + if [[ $refusal == "ordinary-bash" ]]; then + if /usr/bin/bash "$SUDO_TEST_ROOT/bin/omarchy-channel-set" -p >"$boundary_tmp/output" 2>&1; then fail "$refusal was accepted"; fi + elif run_channel stable; then + fail "$refusal was accepted" + fi + if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "$refusal reached channel work"; fi + pass "$refusal is rejected before channel work" +done diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 664e17c50b5..712ef8a40dd 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -4,10 +4,18 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +package_root="$SUDO_TEST_ROOT" +copy_boundary_file bin/omarchy-channel-set +python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$package_root" <<'PYTHON' +import sys +from pathlib import Path +p = Path(sys.argv[1]) +p.write_text(p.read_text().replace("/usr/share/omarchy", sys.argv[2])) +PYTHON + +stub_bin="$SUDO_TEST_ROOT/bin" log_file="$test_tmp/channel.log" mkdir -p "$stub_bin" "$test_tmp/home" @@ -15,6 +23,7 @@ write_stub() { local name="$1" local body="$2" + rm -f "$stub_bin/$name" cat >"$stub_bin/$name" <<<"$body" chmod +x "$stub_bin/$name" } @@ -26,11 +35,17 @@ printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' write_stub sudo '#!/bin/bash +case "${1:-}" in + -h) echo "usage: sudo [-ABbEHkNnPS] command"; exit 0 ;; + -k|-K) exit 0 ;; +esac printf "sudo" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' +cp "$stub_bin/sudo" "$SUDO_TEST_ROOT/mock/sudo" + write_stub omarchy-dev-unlink '#!/bin/bash printf "unlink" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done @@ -63,7 +78,8 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" if [[ $1 == "clone" ]]; then dest="${@: -1}" - mkdir -p "$dest/.git" "$dest/bin" "$dest/default" "$dest/shell" + /usr/bin/cp -a "$SUDO_TEST_ROOT" "$dest" + mkdir -p "$dest/.git" "$dest/shell" fi ' @@ -90,10 +106,10 @@ esac run_channel() { : >"$log_file" OMARCHY_CHANNEL_TEST_LOG="$log_file" \ - OMARCHY_PATH="${OMARCHY_TEST_PATH:-/usr/share/omarchy}" \ + OMARCHY_PATH="${OMARCHY_TEST_PATH:-$package_root}" \ HOME="$test_tmp/home" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ - "$ROOT/bin/omarchy-channel-set" "$@" + "${OMARCHY_TEST_PATH:-$package_root}/bin/omarchy-channel-set" "$@" } assert_log_line() { @@ -105,28 +121,30 @@ assert_log_line() { } run_channel stable -assert_log_line $'refresh\tstable' "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 $'refresh\tstable\tdefer-hook' "stable refreshes the stable pacman channel" +assert_log_line $'sudo\t-N\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" +assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "stable runs the normal update pipeline from the package-backed path" if grep -q $'^state\tset\treboot-required$' "$log_file"; then fail "stable does not require reboot when already package-backed" "$(cat "$log_file")" fi 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 $'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 $'refresh\trc\tdefer-hook' "rc refreshes the rc pacman channel" +assert_log_line $'sudo\t-N\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" +assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "rc runs the normal update pipeline from the package-backed path" -OMARCHY_TEST_PATH="$ROOT" run_channel edge -assert_log_line $'refresh\tedge' "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" +active_checkout="$test_tmp/active-checkout" +cp -a "$package_root" "$active_checkout" +OMARCHY_TEST_PATH="$active_checkout" run_channel edge +assert_log_line $'refresh\tedge\tdefer-hook' "edge refreshes the edge pacman channel" +assert_log_line $'sudo\t-N\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" -assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path" -[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] || +assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "edge runs the normal update pipeline from the package-backed path" +[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH='"$package_root" ]] || fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")" pass "edge defers the reboot prompt until the update restart stage" @@ -137,7 +155,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 +163,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 $'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 $'refresh\tedge\tdefer-hook' "dev refreshes the edge pacman channel" +assert_log_line $'sudo\t-N\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\t-N\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/cups-hardening-test.sh b/test/shell.d/cups-hardening-test.sh index a8aefb94eec..3687c9a2622 100644 --- a/test/shell.d/cups-hardening-test.sh +++ b/test/shell.d/cups-hardening-test.sh @@ -1,246 +1,9 @@ #!/bin/bash - set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - -packages="$ROOT/install/omarchy-base.packages" -cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf" -cups_files_conf="$ROOT/etc/cups/cups-files.conf" -sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf" -service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf" - -# Only discovery goes. Everything else printing needs stays, or this stops -# being a removal of one daemon and becomes a removal of printing. -grep -qxF cups "$packages" || fail "CUPS itself remains in the base package set" -grep -qxF cups-filters "$packages" || fail "the CUPS filters remain in the base package set" -grep -qxF system-config-printer "$packages" || fail "Print Settings remains in the base package set" -grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed" -! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed" - -# Automatic discovery is temporarily out of the default install while it is -# reworked. The hardened configuration below stays as the baseline discovery -# comes back onto. -! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set" -! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" || - fail "a fresh install does not enable a discovery service it no longer installs" -! grep -q 'enable_system_service cups-browsed' "$ROOT/bin/omarchy-upgrade-to-quattro" || - fail "the Quattro upgrade does not enable a discovery service it no longer installs" - -pass "the base install keeps CUPS and Polkit administration, without automatic discovery" - -# CUPS still ships /etc/cups/cups-files.conf, so its authorization override is -# applied after the ISO installs that package. cups-browsed is absent, so the -# installer must not write any of its package-owned configuration. -post_install_pacman="$ROOT/install/post-install/pacman.sh" - -! grep -q 'cups-cups-browsed.conf' "$post_install_pacman" || - fail "a fresh install does not write configuration for absent printer discovery" -grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" || - fail "the CUPS authorization override waits for the file it replaces" - -pass "the fresh install applies CUPS hardening without writing discovery configuration" - -grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" || - fail "cups-browsed keeps state outside the print-filter cache" -grep -qxF 'CreateIPPPrinterQueues Driverless' "$cups_browsed_conf" || - fail "automatic queues are limited to driverless IPP printers" -grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" || - fail "remote CUPS queues are not created automatically" -! grep -q 'CreateRemotePrinters' "$cups_browsed_conf" || - fail "the unsupported CreateRemotePrinters directive is gone" - -pass "cups-browsed uses explicit supported discovery policy and an isolated cache" - -grep -qxF 'SystemGroup cups-browsed sys root' "$cups_files_conf" || - fail "only the printer discovery account receives passwordless CUPS administration" -grep -qxF 'PeerCred on' "$cups_files_conf" || - fail "the packaged CUPS policy enables peer credentials" -[[ $(grep -ciE '^[[:space:]]*SystemGroup[[:space:]]' "$cups_files_conf") == 1 ]] || - fail "the packaged CUPS policy has one SystemGroup directive" -[[ $(grep -ciE '^[[:space:]]*PeerCred[[:space:]]' "$cups_files_conf") == 1 ]] || - fail "the packaged CUPS policy has one PeerCred directive" -[[ ! -e $ROOT/install/config/printing.sh ]] || - fail "printing policy is not rewritten by an install script" -! grep -q 'config/printing.sh' "$ROOT/install/config/all.sh" "$ROOT/migrations/1787815267.sh" || - fail "neither install nor update invokes a printing rewrite script" - -pass "CUPS authorization ships as a canonical package override" - -grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" || - fail "a locked cups-browsed system account is declared" - -for setting in \ - 'User=cups-browsed' \ - 'Group=cups-browsed' \ - 'CacheDirectory=cups-browsed' \ - 'CacheDirectoryMode=0750' \ - 'UMask=0027' \ - 'NoNewPrivileges=yes' \ - 'ProtectSystem=strict' \ - 'ProtectHome=yes' \ - 'PrivateTmp=yes' \ - 'RestrictSUIDSGID=yes'; do - grep -qxF "$setting" "$service_dropin" || - fail "cups-browsed service hardening includes $setting" -done - -! grep -q '^\(Ambient\|CapabilityBoundingSet\).*CAP_NET_BIND_SERVICE' "$service_dropin" || - fail "cups-browsed is not granted an unverified network capability" - -pass "cups-browsed runs as its confined service account without added capabilities" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -mock_bin="$test_tmp/bin" -mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations" - -passwd_db="$test_tmp/passwd" -group_db="$test_tmp/group" -touch "$passwd_db" "$group_db" - -cat >"$mock_bin/getent" <<'SH' -#!/bin/bash -case "$1" in - passwd) database="$OMARCHY_CUPS_TEST_PASSWD" ;; - group) database="$OMARCHY_CUPS_TEST_GROUP" ;; - *) exit 2 ;; -esac - -if (($# == 1)); then - cat "$database" -else - awk -F: -v name="$2" '$1 == name { print; found = 1 } END { exit !found }' "$database" -fi -SH -cat >"$mock_bin/omarchy-pkg-present" <<'SH' -#!/bin/bash -[[ $1 == "cups" || $1 == "cups-browsed" ]] -SH -for command in omarchy-pkg-add omarchy-pkg-drop; do - cat >"$mock_bin/$command" <<'SH' -#!/bin/bash -printf '%s\t%s\n' "${0##*/}" "$*" >>"$OMARCHY_CUPS_TEST_LOG" -SH -done -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash -printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" -exit 0 -SH -cat >"$mock_bin/sudo" <<'SH' -#!/bin/bash -printf 'sudo\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" -exec "$@" -SH -chmod +x "$mock_bin"/* - -log="$test_tmp/actions.log" -touch "$log" -export OMARCHY_CUPS_TEST_LOG="$log" -export OMARCHY_CUPS_TEST_PASSWD="$passwd_db" -export OMARCHY_CUPS_TEST_GROUP="$group_db" - -printf 'cups-browsed:x:1000:1000:Desktop user:/home/cups-browsed:/usr/bin/bash\n' >"$passwd_db" -printf 'cups-browsed:x:1000:\n' >"$group_db" -if PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/desktop-collision-marker" \ - bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then - fail "the migration accepts an existing desktop user named cups-browsed" -fi -[[ ! -s $log ]] || fail "an account collision stops the migration before changing the system" - -printf 'alice:x:1000:947:Desktop user:/home/alice:/usr/bin/bash\n' >"$passwd_db" -printf 'cups-browsed:x:947:alice\n' >"$group_db" -if PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/group-collision-marker" \ - bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then - fail "the migration accepts an existing cups-browsed group with members" -fi -[[ ! -s $log ]] || fail "a group collision stops the migration before changing the system" - -printf 'cups-browsed:x:947:947:CUPS printer discovery:/:/usr/bin/nologin\n' >"$passwd_db" -printf 'cups-browsed:x:947:\n' >"$group_db" - -pass "the migration rejects account and group collisions before changing printing" - -marker="$test_tmp/var/lib/omarchy/migrations/1787815267" -PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ - bash -euo pipefail "$ROOT/migrations/1787815267.sh" - -grep -qxF $'omarchy-pkg-drop\tcups-pdf' "$log" || - fail "the migration removes CUPS-PDF" -grep -qxF $'omarchy-pkg-add\tcups-pk-helper' "$log" || - fail "the migration installs authenticated printer administration" -grep -qxF $'systemctl\tstop cups-browsed.service' "$log" || - fail "the migration stops the root cups-browsed process before reconfiguration" -grep -qxF $'systemctl\tdaemon-reload' "$log" || - fail "the migration reloads the hardened service" -grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" || - fail "the migration reloads the packaged CUPS authorization" -grep -qxF $'systemctl\trestart cups-browsed.service' "$log" || - fail "the migration resumes an active cups-browsed service" -[[ -f $marker ]] || fail "the migration records machine-wide completion" - -actions_after_first_run=$(wc -l <"$log") -PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_MIGRATION_MARKER="$marker" \ - bash -euo pipefail "$ROOT/migrations/1787815267.sh" -[[ $(wc -l <"$log") == "$actions_after_first_run" ]] || - fail "the machine-wide migration repeats privileged work" - -pass "the migration safely converts an active existing installation once" - -# An interrupted earlier run leaves cups-browsed stopped. A retry still needs -# to resume an enabled service before recording completion. -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash -printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" -[[ $1 == "is-active" ]] && exit 1 -exit 0 -SH -chmod +x "$mock_bin/systemctl" - -retry_log="$test_tmp/retry.log" -retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry" - -OMARCHY_CUPS_TEST_LOG="$retry_log" \ - PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \ - bash -euo pipefail "$ROOT/migrations/1787815267.sh" - -grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" || - fail "the retry resumes cups-browsed after an interrupted earlier run" - -pass "a run following an interrupted one still resumes printer discovery" - -# A masked or disabled unit is deliberately left alone. -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash -printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG" -[[ $1 == "is-active" || $1 == "is-enabled" ]] && exit 1 -exit 0 -SH -chmod +x "$mock_bin/systemctl" - -masked_log="$test_tmp/masked.log" -masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked" - -OMARCHY_CUPS_TEST_LOG="$masked_log" \ - PATH="$mock_bin:$PATH" \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \ - bash -euo pipefail "$ROOT/migrations/1787815267.sh" - -! grep -qxF $'systemctl\trestart cups-browsed.service' "$masked_log" || - fail "the migration leaves a masked or disabled cups-browsed alone" -[[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked" - -pass "a masked or disabled cups-browsed is left alone and does not fail the migration" +migration="$ROOT/migrations/1787815267.sh" +grep -Fq '/usr/share/omarchy/migrations/1787815267.sh --machine' "$migration" || fail "CUPS migration lacks fixed machine phase" +grep -Fq '/usr/bin/pacman -Rns --noconfirm -- cups-pdf' "$migration" || fail "CUPS removal target is not fixed" +grep -Fq '/usr/bin/pacman -S --needed --noconfirm -- cups-pk-helper' "$migration" || fail "CUPS install target is not fixed" +grep -Fq 'CUPS printer discovery' "$migration" || fail "CUPS account identity check was lost" +pass "CUPS repair retains fixed package and service policy" diff --git a/test/shell.d/fingerprint-driver-migration-test.sh b/test/shell.d/fingerprint-driver-migration-test.sh index 6b3f523cacc..e844e625cda 100755 --- a/test/shell.d/fingerprint-driver-migration-test.sh +++ b/test/shell.d/fingerprint-driver-migration-test.sh @@ -7,24 +7,29 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +for command in omarchy-pkg-add omarchy-pkg-missing omarchy-pkg-present; do + copy_boundary_file "bin/$command" +done +ln -s ../bin/omarchy-pkg-missing "$SUDO_TEST_ROOT/mock/omarchy-pkg-missing" migration="$ROOT/migrations/1785090473.sh" -scratch=$(mktemp -d) -trap 'rm -rf "$scratch"' EXIT +scratch="$boundary_tmp" mkdir -p "$scratch/bin" export CALL_LOG="$scratch/calls" -export PATH="$scratch/bin:$ROOT/bin:$PATH" +export PATH="$scratch/bin:$SUDO_TEST_ROOT/bin:$PATH" +export OMARCHY_SUDO_NO_UPDATE=1 -cat > "$scratch/bin/sudo" <<'STUB' -#!/bin/bash -exec "$@" -STUB # INSTALLED lists the installed package names, one per line; an install adds # its packages to INSTALLED_LOG so omarchy-pkg-add's follow-up query sees them. cat > "$scratch/bin/pacman" <<'STUB' #!/bin/bash case "$1" in - -Q) grep -qx "$2" <<< "${INSTALLED:-}" || grep -qx "$2" "$INSTALLED_LOG" ;; + -Q) + shift + [[ ${1:-} != "--" ]] || shift + grep -qx "$1" <<< "${INSTALLED:-}" || grep -qx "$1" "$INSTALLED_LOG" + ;; -S) printf 'pacman %s\n' "$*" >> "$CALL_LOG" for arg in "$@"; do @@ -35,6 +40,9 @@ case "$1" in esac STUB chmod +x "$scratch/bin/"* +rm "$SUDO_TEST_ROOT/mock/pacman" "$SUDO_TEST_ROOT/bin/pacman" +ln -s "$scratch/bin/pacman" "$SUDO_TEST_ROOT/mock/pacman" +ln -s "$scratch/bin/pacman" "$SUDO_TEST_ROOT/bin/pacman" export INSTALLED_LOG="$scratch/installed" run_migration() { @@ -44,7 +52,7 @@ run_migration() { } INSTALLED='fprintd' run_migration -grep -qx 'pacman -S --noconfirm --needed libfprint-git' "$CALL_LOG" || fail "fprintd without a library gets libfprint-git" +grep -qx 'pacman -S --noconfirm --needed -- libfprint-git' "$CALL_LOG" || fail "fprintd without a library gets libfprint-git" pass "fprintd without a library gets libfprint-git" INSTALLED=$'libfprint-git\nfprintd' run_migration 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 00000000000..d681c850749 --- /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','pkexec','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']: + s=s.replace('/usr/bin/'+command, str(target/'mock'/command)) +s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"') +p.write_text(s) +p.chmod((source/name).stat().st_mode & 0o777) +PY +} + +copy_boundary_file bin/omarchy-security-functions +copy_boundary_file 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" && ! -e $SUDO_TEST_ROOT/revoke-fail ]] || exit 1 + /usr/bin/rm -f "$SUDO_TEST_CACHE" + exit 0 +fi +if [[ ${1:-} == "-N" ]]; then + shift +else + touch "$SUDO_TEST_CACHE" +fi +[[ ${SUDO_TEST_SUDO_FAIL:-0} != "1" ]] || exit 1 +background=0 +while (( $# )); do + case "$1" in + -N|-n) shift ;; + -b) background=1; shift ;; + -v) exit 0 ;; + -u|--user) shift 2 ;; + --) shift; break ;; + *) break ;; + esac +done +(( $# )) || exit 0 +if (( background )); then + "$@" & +else + "$@" +fi +STUB +chmod +x "$SUDO_TEST_ROOT/mock/sudo" + +cat >"$SUDO_TEST_ROOT/bin/test-step" <<'STUB' +#!/bin/bash +set -euo pipefail +step=${0##*/} +printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG" +if [[ $step == "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 + [[ $* == *"--sudoloop=false"* ]] || exit 93 + ;; +esac +STUB +chmod +x "$SUDO_TEST_ROOT/bin/test-step" +for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman cp yay; do + ln -s test-step "$SUDO_TEST_ROOT/bin/$step" +done +ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" + +reset_boundary() { + : >"$SUDO_TEST_LOG" + /usr/bin/rm -f "$SUDO_TEST_CACHE" "$SUDO_TEST_ROOT/revoke-fail" + 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/migrate-scope-test.sh b/test/shell.d/migrate-scope-test.sh index ddb7f59e51d..339b9c21e00 100644 --- a/test/shell.d/migrate-scope-test.sh +++ b/test/shell.d/migrate-scope-test.sh @@ -2,104 +2,110 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -test_root="$test_tmp/omarchy" -test_home="$test_tmp/home" -mkdir -p "$test_root/migrations" "$test_home" - -cat >"$test_root/migrations/100-first.sh" <<'SH' -[[ $OMARCHY_PATH == "$TEST_EXPECTED_OMARCHY_PATH" ]] -echo first >>"$TEST_CALLS" -SH -cat >"$test_root/migrations/200-second.sh" <<'SH' -[[ $OMARCHY_PATH == "$TEST_EXPECTED_OMARCHY_PATH" ]] -echo second >>"$TEST_CALLS" -SH - -calls="$test_tmp/calls" - -if ! HOME="$test_home" OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-migrate" --pending >"$test_tmp/pending.out"; then - fail "migration runner reports pending migrations before state exists" -fi -grep -q '^100-first\.sh$' "$test_tmp/pending.out" || fail "migration runner lists first pending migration filename" -grep -q '^200-second\.sh$' "$test_tmp/pending.out" || fail "migration runner lists second pending migration filename" -pass "migration runner detects pending migrations" - -HOME="$test_home" \ -OMARCHY_PATH="$test_root" \ -TEST_EXPECTED_OMARCHY_PATH="$test_root" \ -TEST_CALLS="$calls" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/first-run.out" -[[ $(sed -n '1p' "$calls") == "first" ]] || fail "migration runner runs first migration" -[[ $(sed -n '2p' "$calls") == "second" ]] || fail "migration runner runs second migration" -[[ -f $test_home/.local/state/omarchy/migrations/100-first.sh ]] || fail "migration runner records first migration marker" -[[ -f $test_home/.local/state/omarchy/migrations/200-second.sh ]] || fail "migration runner records second migration marker" -pass "migration runner runs all migrations" - -HOME="$test_home" \ -OMARCHY_PATH="$test_root" \ -TEST_EXPECTED_OMARCHY_PATH="$test_root" \ -TEST_CALLS="$calls" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/second-run.out" -[[ $(wc -l <"$calls") -eq 2 ]] || fail "migration runner skips completed migrations" -pass "migration runner skips completed migrations" - -if HOME="$test_home" OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-migrate" --pending >"$test_tmp/not-pending.out"; then - fail "migration runner reports no pending migrations after state exists" +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-migrate" +copy_boundary_file bin/omarchy-migrate +copy_boundary_file bin/omarchy-pkg-add +printf '%s\n' '#!/bin/bash' 'exit 0' >"$SUDO_TEST_ROOT/mock/omarchy-pkg-missing" +chmod +x "$SUDO_TEST_ROOT/mock/omarchy-pkg-missing" +export OMARCHY_MIGRATION_STATE="$boundary_tmp/state" +mkdir -p "$SUDO_TEST_ROOT/migrations" + +cat >"$SUDO_TEST_ROOT/migrations/100-first.sh" <<'MIGRATION' +[[ $OMARCHY_SUDO_NO_UPDATE == "1" ]] +[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] +sudo /usr/bin/true +# The package helper resets PATH and invokes its fixed sudo path. +"$OMARCHY_PATH/bin/omarchy-pkg-add" fixture-package +printf '%s\n' migration:first >>"$SUDO_TEST_LOG" +MIGRATION +cat >"$SUDO_TEST_ROOT/migrations/200-second.sh" <<'MIGRATION' +printf '%s\n' migration:second >>"$SUDO_TEST_LOG" +MIGRATION + +run_migrate() { + "$SUDO_TEST_ROOT/bin/omarchy-migrate" "$@" >"$boundary_tmp/output" 2>&1 +} + +run_migrate --pending +[[ ! -s $SUDO_TEST_LOG && ! -d $OMARCHY_MIGRATION_STATE ]] || fail "pending inspection changed credentials or migration state" +grep -qx '100-first.sh' "$boundary_tmp/output" || fail "pending inspection omitted a migration" +pass "pending inspection reads migration names without credential or state changes" + +touch "$SUDO_TEST_CACHE" +run_migrate || fail "migration queue failed" "$(<"$boundary_tmp/output")" +assert_boundary_cold "successful migration queue" +[[ -f $OMARCHY_MIGRATION_STATE/100-first.sh && -f $OMARCHY_MIGRATION_STATE/200-second.sh ]] || fail "successful migrations were not marked complete" +[[ $(grep '^migration:' "$SUDO_TEST_LOG") == $'migration:first\nmigration:second' ]] || fail "migration ordering changed" +expected_authorizations=2 +(( EUID != 0 )) || expected_authorizations=1 +[[ $(grep -c '^sudo -N ' "$SUDO_TEST_LOG") == "$expected_authorizations" ]] || fail "the direct package helper did not inherit no-update policy" +pass "ordered migrations and the fixed-path package helper use command-scoped sudo" + +reset_boundary +run_migrate +if grep -q '^migration:' "$SUDO_TEST_LOG"; then fail "completed migrations ran again"; fi +if run_migrate --pending; then fail "completed queue reported pending work"; fi +pass "completed migrations remain idempotent" + +for failure in exit TERM HUP INT; do + reset_boundary + export SUDO_TEST_MIGRATION_FAILURE=$failure + cat >"$SUDO_TEST_ROOT/migrations/300-fail.sh" <<'MIGRATION' +touch "$SUDO_TEST_CACHE" +if [[ $SUDO_TEST_MIGRATION_FAILURE == "exit" ]]; then + exit 23 +else + kill -"$SUDO_TEST_MIGRATION_FAILURE" "$PPID" fi -pass "migration runner detects no pending migrations" - -failure_root="$test_tmp/failure-omarchy" -failure_home="$test_tmp/failure-home" -mkdir -p "$failure_root/migrations" "$failure_home" - -cat >"$failure_root/migrations/500-fail.sh" <<'SH' -echo before-fail >>"$TEST_CALLS" -false -echo after-fail >>"$TEST_CALLS" -SH - -set +e -HOME="$failure_home" \ -OMARCHY_PATH="$failure_root" \ -TEST_CALLS="$calls" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/failure.out" 2>"$test_tmp/failure.err" -failure_status=$? -set -e -[[ $failure_status -ne 0 ]] || fail "migration runner exits non-zero when a migration fails" -[[ ! -f $failure_home/.local/state/omarchy/migrations/500-fail.sh ]] || fail "migration runner does not mark failed migration complete" -grep -q '^before-fail$' "$calls" || fail "migration runner started failing migration" -! grep -q '^after-fail$' "$calls" || fail "migration runner stops failing migration under strict mode" -pass "migration runner does not mark failed migrations complete" - -stdin_root="$test_tmp/stdin-omarchy" -stdin_home="$test_tmp/stdin-home" -stdin_calls="$test_tmp/stdin-calls" -mkdir -p "$stdin_root/migrations" "$stdin_home" - -cat >"$stdin_root/migrations/100-reader.sh" <<'SH' -IFS= read -r value -printf 'reader:%s\n' "$value" >>"$TEST_CALLS" -SH -cat >"$stdin_root/migrations/200-after.sh" <<'SH' -echo after-reader >>"$TEST_CALLS" -SH - -printf 'migration input\n' | \ - HOME="$stdin_home" \ - OMARCHY_PATH="$stdin_root" \ - TEST_CALLS="$stdin_calls" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/stdin.out" - -grep -q '^reader:migration input$' "$stdin_calls" || - fail "migration runner preserves the caller's stdin for a migration" "$(cat "$stdin_calls")" -grep -q '^after-reader$' "$stdin_calls" || - fail "a migration reading stdin does not swallow later queue entries" "$(cat "$stdin_calls")" -[[ -f $stdin_home/.local/state/omarchy/migrations/100-reader.sh && - -f $stdin_home/.local/state/omarchy/migrations/200-after.sh ]] || - fail "migration runner marks both stdin-isolated migrations complete" -pass "migration queue uses a private file descriptor instead of migration stdin" +MIGRATION + cat >"$SUDO_TEST_ROOT/migrations/400-later.sh" <<'MIGRATION' +printf '%s\n' migration:later >>"$SUDO_TEST_LOG" +MIGRATION + if run_migrate; then fail "$failure must fail the migration queue"; fi + assert_boundary_cold "migration $failure" + [[ ! -e $OMARCHY_MIGRATION_STATE/300-fail.sh && ! -e $OMARCHY_MIGRATION_STATE/400-later.sh ]] || fail "an interrupted queue advanced its completion markers" + if grep -q '^migration:later' "$SUDO_TEST_LOG"; then fail "later migration ran after $failure"; fi + pass "migration $failure revokes authorization and leaves the queue pending" +done + +printf '%s\n' 'printf "%s\n" migration:retry >>"$SUDO_TEST_LOG"' >"$SUDO_TEST_ROOT/migrations/300-fail.sh" +reset_boundary +run_migrate +[[ -f $OMARCHY_MIGRATION_STATE/300-fail.sh && -f $OMARCHY_MIGRATION_STATE/400-later.sh ]] || fail "retry did not complete the pending queue" +pass "a corrected migration can be retried and releases later work" + +reset_boundary +export SUDO_TEST_REVOKE_FAIL=1 +if run_migrate; then fail "failed revocation must fail the queue"; fi +if grep -q '^migration:' "$SUDO_TEST_LOG"; then fail "queue ran after failed initial revocation"; fi +pass "failed credential revocation prevents migration execution" + +reset_boundary +printf '%s\n' 'touch "$SUDO_TEST_ROOT/startup-marker"' >"$boundary_tmp/startup" +BASH_ENV="$boundary_tmp/startup" ENV="$boundary_tmp/startup" run_migrate +[[ ! -e $SUDO_TEST_ROOT/startup-marker ]] || fail "inherited startup state ran in the migration queue" +pass "migration startup and child interpreters discard inherited startup files" + +for script in omarchy-migrate omarchy-pkg-add; do + reset_boundary + if /usr/bin/bash "$SUDO_TEST_ROOT/bin/$script" -p >"$boundary_tmp/output" 2>&1; then fail "$script accepted a decoy -p"; fi + [[ ! -s $SUDO_TEST_LOG ]] || fail "$script reached sudo through an unsafe interpreter" + pass "$script rejects an ordinary Bash launch" +done + +reset_boundary +cat >"$SUDO_TEST_ROOT/migrations/500-revocation.sh" <<'MIGRATION' +touch "$SUDO_TEST_CACHE" "$SUDO_TEST_ROOT/revoke-fail" +MIGRATION +if run_migrate; then fail "failed post-migration revocation must fail the queue"; fi +[[ ! -e $OMARCHY_MIGRATION_STATE/500-revocation.sh ]] || fail "failed revocation incorrectly marked migration complete" +grep -q 'Could not invalidate cached sudo authorization' "$boundary_tmp/output" || fail "failed cleanup did not explain the remaining credential state" +pass "failed post-migration revocation is explicit and prevents a completion marker" +reset_boundary +printf '%s\n' true >"$SUDO_TEST_ROOT/migrations/500-revocation.sh" +run_migrate +assert_boundary_cold "retry after revocation failure" +[[ -f $OMARCHY_MIGRATION_STATE/500-revocation.sh ]] || fail "queue did not recover after revocation was restored" +pass "the queue recovers once credential revocation succeeds" diff --git a/test/shell.d/migrate-wrapper-test.sh b/test/shell.d/migrate-wrapper-test.sh index bd97fea37d0..5cdfcfc11ef 100644 --- a/test/shell.d/migrate-wrapper-test.sh +++ b/test/shell.d/migrate-wrapper-test.sh @@ -4,13 +4,15 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -test_root="$test_tmp/omarchy" -test_home="$test_tmp/home" -stub_bin="$test_tmp/bin" -mkdir -p "$test_root/migrations" "$test_home" "$stub_bin" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +test_root="$SUDO_TEST_ROOT" +test_home="$SUDO_TEST_HOME" +stub_bin="$test_root/bin" +mkdir -p "$test_root/migrations" +rm "$stub_bin/omarchy-migrate" "$stub_bin/omarchy-notification-dismiss" +copy_boundary_file bin/omarchy-migrate +export OMARCHY_MIGRATION_STATE="$test_tmp/migration-state" cat >"$stub_bin/omarchy-notification-dismiss" <<'SH' #!/bin/bash @@ -23,12 +25,11 @@ echo migration >>"$TEST_CALLS" SH run_migrate() { - HOME="$test_home" \ OMARCHY_PATH="$test_root" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ TEST_CALLS="$test_tmp/calls" \ TEST_DISMISSALS="$test_tmp/dismissals" \ - "$ROOT/bin/omarchy-migrate" "$@" + "$SUDO_TEST_ROOT/bin/omarchy-migrate" "$@" } : >"$test_tmp/calls" @@ -39,7 +40,7 @@ pass "omarchy-migrate runs migrations without force" grep -Fx 'Omarchy Migrations' "$test_tmp/dismissals" >/dev/null || fail "omarchy-migrate dismisses migration notifications" pass "omarchy-migrate clears completed migration notifications" -rm -rf "$test_home/.local/state/omarchy/migrations" +rm -rf "$OMARCHY_MIGRATION_STATE" run_migrate --pending >"$test_tmp/pending.out" grep -q '^100-migration\.sh$' "$test_tmp/pending.out" || fail "omarchy-migrate --pending lists pending migrations" pass "omarchy-migrate --pending lists pending migrations" diff --git a/test/shell.d/migration-machine-phase-test.sh b/test/shell.d/migration-machine-phase-test.sh new file mode 100644 index 00000000000..fbf3eb131b1 --- /dev/null +++ b/test/shell.d/migration-machine-phase-test.sh @@ -0,0 +1,240 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +tmp=$(mktemp -d) +trap 'chmod -R u+rwx "$tmp" 2>/dev/null || true; rm -rf "$tmp"' EXIT +if ! unshare --user --map-root-user --mount /usr/bin/bash -p -c : 2>/dev/null; then + pass "user namespaces unavailable; skipping privileged migration machine-body execution" + exit 0 +fi +root_run() { unshare --user --map-root-user --mount /usr/bin/bash -p "$@"; } +script_copy() { cp "$ROOT/migrations/$1.sh" "$2"; } + +fido_dir="$tmp/fido2"; fido_file="$fido_dir/fido2"; fido_marker="$tmp/fido.marker" +mkdir "$fido_dir"; printf 'credential\n' >"$fido_file"; chmod 700 "$fido_dir" +fido_body="$tmp/fido-body.sh"; script_copy 1787494718 "$fido_body" +sed -i -e "s|/etc/fido2/fido2|$fido_file|g" -e "s|/var/lib/omarchy/migrations/1787494718|$fido_marker|g" -e 's/-o root -g root //' "$fido_body" +root_run "$fido_body" --machine +[[ $(stat -c %a "$fido_dir") == 755 ]] || fail "FIDO2 repair leaves a hidden credential directory" +[[ $(stat -c %a "$fido_file") == 644 ]] || fail "FIDO2 repair does not restore the credential mode" +pass "FIDO2 machine body repairs a hidden directory and credential mode" + +bt_bin="$tmp/bt-bin"; mkdir "$bt_bin" +cat >"$bt_bin/timeout" <<'SH' +#!/bin/bash +shift +exec "$@" +SH +cat >"$bt_bin/bluetoothctl" <<'SH' +#!/bin/bash +[[ ${BT_QUERY_FAIL:-0} == 0 ]] || exit 124 +if [[ $1 == list ]]; then echo 'Controller AA:BB test'; else echo "Powered: ${BT_POWER:-no}"; fi +SH +cat >"$bt_bin/power" <<'SH' +#!/bin/bash +[[ ${BT_POWER_FAIL:-0} == 0 ]] || exit 19 +echo "$1" >>"$BT_LOG" +SH +chmod +x "$bt_bin"/* +bt_body="$tmp/bt-body.sh"; bt_marker="$tmp/bt.marker"; bt_conf="$tmp/main.conf"; printf 'AutoEnable=false\n' >"$bt_conf" +script_copy 1786380259 "$bt_body" +sed -i -e "s|/usr/bin/timeout|$bt_bin/timeout|g" -e "s|/usr/bin/bluetoothctl|$bt_bin/bluetoothctl|g" -e "s|/usr/bin/omarchy-bluetooth-power|$bt_bin/power|g" -e "s|/var/lib/omarchy/migrations/1786380259|$bt_marker|g" -e "s|/etc/bluetooth/main.conf|$bt_conf|g" "$bt_body" +BT_LOG="$tmp/bt.log" BT_POWER=yes root_run "$bt_body" --machine +[[ $(cat "$tmp/bt.log") == on && -e $bt_marker ]] || fail "Bluetooth machine body loses powered-on state" +BT_LOG="$tmp/bt.log" BT_QUERY_FAIL=1 root_run "$bt_body" --machine +[[ $(wc -l <"$tmp/bt.log") == 1 ]] || fail "Bluetooth replay queried or changed completed state" +rm -f "$bt_marker"; : >"$tmp/bt.log" +BT_LOG="$tmp/bt.log" BT_POWER=no root_run "$bt_body" --machine +[[ $(cat "$tmp/bt.log") == off && -e $bt_marker ]] || fail "Bluetooth machine body loses powered-off state" +rm -f "$bt_marker"; : >"$tmp/bt.log" +if BT_LOG="$tmp/bt.log" BT_QUERY_FAIL=1 root_run "$bt_body" --machine; then fail "Bluetooth discovery failure is treated as powered off"; fi +[[ ! -e $bt_marker && ! -s $tmp/bt.log ]] || fail "Bluetooth query error publishes or changes policy" +BT_LOG="$tmp/bt.log" BT_POWER=yes root_run "$bt_body" --machine +[[ -e $bt_marker && $(cat "$tmp/bt.log") == on ]] || fail "Bluetooth query failure is not retryable" +rm -f "$bt_marker"; : >"$tmp/bt.log" +if BT_LOG="$tmp/bt.log" BT_POWER_FAIL=1 root_run "$bt_body" --machine; then fail "Bluetooth power failure publishes completion"; fi +[[ ! -e $bt_marker && ! -s $tmp/bt.log ]] || fail "Bluetooth power failure is not retryable" +BT_LOG="$tmp/bt.log" BT_POWER=yes root_run "$bt_body" --machine +[[ -e $bt_marker && $(cat "$tmp/bt.log") == on ]] || fail "Bluetooth power failure retry did not complete" +[[ $(cat "$bt_conf") == '#AutoEnable=true' ]] || fail "Bluetooth repair did not update the fixed configuration" +pass "Bluetooth machine body preserves on/off state, replays safely, and retries discovery and mutation failures" + +bt_dispatch="$tmp/bt-dispatch.sh"; script_copy 1786380259 "$bt_dispatch" +bt_dispatch_marker="$tmp/bt-dispatch.marker"; bt_dispatch_lock="$tmp/bt-dispatch.lock"; bt_dispatch_log="$tmp/bt-dispatch.log" +bt_dispatch_power="$tmp/bt-dispatch-power"; bt_dispatch_ctl="$tmp/bt-dispatch-ctl"; bt_dispatch_timeout="$tmp/bt-dispatch-timeout"; bt_dispatch_sudo="$tmp/bt-dispatch-sudo" +cat >"$bt_dispatch_power" <>'$bt_dispatch_log' +/usr/bin/sleep 0.2 +printf '%s\n' "\$1" >>'$bt_dispatch_log' +SH +cat >"$bt_dispatch_ctl" <<'SH' +#!/bin/bash +if [[ $1 == list ]]; then echo 'Controller AA:BB test'; else echo 'Powered: yes'; fi +SH +cat >"$bt_dispatch_timeout" <<'SH' +#!/bin/bash +shift +exec "$@" +SH +cat >"$bt_dispatch_sudo" <<'SH' +#!/bin/bash +[[ $1 == -N && $2 == -- ]] || exit 90 +shift 2 +exec "$@" +SH +chmod +x "$bt_dispatch_power" "$bt_dispatch_ctl" "$bt_dispatch_timeout" "$bt_dispatch_sudo" +sed -i -e "s|/usr/bin/timeout|$bt_dispatch_timeout|g" -e "s|/usr/bin/bluetoothctl|$bt_dispatch_ctl|g" -e "s|/usr/bin/omarchy-bluetooth-power|$bt_dispatch_power|g" -e "s|/usr/bin/sudo|$bt_dispatch_sudo|g" -e "s|/run/omarchy-bluetooth-state-migration.lock|$bt_dispatch_lock|g" -e "s|/usr/share/omarchy/migrations/1786380259.sh|$bt_dispatch|g" -e "s|/var/lib/omarchy/migrations/1786380259|$bt_dispatch_marker|g" -e "s|/etc/bluetooth/main.conf|$tmp/no-bt-conf|g" "$bt_dispatch" +root_run "$bt_dispatch" & bt_pid_one=$! +root_run "$bt_dispatch" & bt_pid_two=$! +wait "$bt_pid_one"; wait "$bt_pid_two" +[[ -e $bt_dispatch_marker && $(grep -c '^start$' "$bt_dispatch_log") == 1 && $(grep -c '^on$' "$bt_dispatch_log") == 1 ]] || fail "Bluetooth full dispatch is not serialized and replay safe" +pass "Bluetooth full no-argument dispatch preserves sudo arguments, clean environment, flock serialization, recheck, and marker replay" + +t2_bin="$tmp/t2-bin"; mkdir "$t2_bin" +cat >"$t2_bin/lspci" <<'SH' +#!/bin/bash +(( ${T2_QUERY_STATUS:-0} == 0 )) || exit "$T2_QUERY_STATUS" +[[ ${T2_PRESENT:-0} == 1 ]] && echo '00:00.0 ISA bridge [0601]: Apple Inc. T2 [106b:1801]' +SH +cat >"$t2_bin/pacman" <<'SH' +#!/bin/bash +if [[ $1 == -Qq ]]; then + (( ${PKG_QUERY_STATUS:-0} == 0 )) || exit "$PKG_QUERY_STATUS" + [[ ${TINY_DFR:-0} == 1 ]] && echo tiny-dfr +fi +exit 0 +SH +cat >"$t2_bin/systemctl" <<'SH' +#!/bin/bash +exit 0 +SH +cat >"$t2_bin/limine" <<'SH' +#!/bin/bash +[[ ${LIMINE_FAIL:-0} == 0 ]] || exit 27 +echo rebuild >>"$T2_LOG" +SH +chmod +x "$t2_bin"/* +t2_body="$tmp/t2-body.sh"; script_copy 1785944594 "$t2_body" +sed -i -e "s|/usr/bin/lspci|$t2_bin/lspci|g" -e "s|/usr/bin/pacman|$t2_bin/pacman|g" -e "s|/usr/bin/systemctl|$t2_bin/systemctl|g" -e "s|/usr/bin/limine-mkinitcpio|$t2_bin/limine|g" -e "s|/var/lib/omarchy/migrations/1785944594|$tmp/t2.marker|g" -e "s|/etc/limine-entry-tool.d/t2-mac.conf|$tmp/t2.conf|g" -e "s|/etc/t2fand.conf|$tmp/fan.conf|g" -e "s|/proc/cmdline|$tmp/cmdline|g" "$t2_body" +if T2_QUERY_STATUS=7 root_run "$t2_body" --machine; then fail "T2 discovery error is treated as inapplicable"; fi +[[ ! -e $tmp/t2.marker ]] || fail "T2 discovery error publishes completion" +printf 'options=pcie_ports=compat\n' >"$tmp/t2.conf"; printf '[Fan1]\n' >"$tmp/fan.conf"; : >"$tmp/cmdline"; : >"$tmp/t2.log" +if T2_PRESENT=1 TINY_DFR=1 LIMINE_FAIL=1 T2_LOG="$tmp/t2.log" root_run "$t2_body" --machine; then fail "T2 rebuild failure publishes completion"; fi +[[ ! -e $tmp/t2.marker ]] || fail "T2 mutation failure is not retryable" +T2_PRESENT=1 TINY_DFR=1 T2_LOG="$tmp/t2.log" root_run "$t2_body" --machine +[[ -e $tmp/t2.marker ]] || fail "T2 retry did not publish completion" +grep -Fq 'pm_async=off mem_sleep_default=deep' "$tmp/t2.conf" || fail "T2 retry did not repair boot parameters" +grep -Fq '[Fan2]' "$tmp/fan.conf" || fail "T2 retry did not add the second fan" +[[ $(grep -c '^rebuild$' "$tmp/t2.log") == 1 ]] || fail "T2 retry did not run exactly one successful rebuild" +T2_PRESENT=1 root_run "$t2_body" --machine +pass "T2 machine body preserves discovery and rebuild failures, completes a retry, and replays without mutation" + +t2_dispatch="$tmp/t2-dispatch.sh"; script_copy 1785944594 "$t2_dispatch" +t2_dispatch_marker="$tmp/t2-dispatch.marker"; t2_dispatch_lock="$tmp/t2-dispatch.lock"; t2_dispatch_log="$tmp/t2-dispatch.log" +t2_dispatch_conf="$tmp/t2-dispatch.conf"; t2_dispatch_fan="$tmp/t2-dispatch-fan.conf"; t2_dispatch_cmdline="$tmp/t2-dispatch-cmdline" +t2_dispatch_lspci="$tmp/t2-dispatch-lspci"; t2_dispatch_pacman="$tmp/t2-dispatch-pacman"; t2_dispatch_limine="$tmp/t2-dispatch-limine"; t2_dispatch_sudo="$tmp/t2-dispatch-sudo" +printf 'options=pm_async=off mem_sleep_default=deep\n' >"$t2_dispatch_conf" +printf '[Fan2]\n' >"$t2_dispatch_fan" +printf 'quiet pm_async=off mem_sleep_default=deep\n' >"$t2_dispatch_cmdline" +touch "$tmp/t2-fail-once" +touch "$tmp/t2-present" +cat >"$t2_dispatch_lspci" <"$t2_dispatch_pacman" <<'SH' +#!/bin/bash +[[ $1 == -Qq ]] && exit 0 +exit 91 +SH +cat >"$t2_dispatch_limine" <>'$t2_dispatch_log' +if [[ -e '$tmp/t2-fail-once' ]]; then + rm -f '$tmp/t2-fail-once' + exit 27 +fi +SH +cat >"$t2_dispatch_sudo" <>'$t2_dispatch_log' +shift 2 +exec "\$@" +SH +chmod +x "$t2_dispatch_lspci" "$t2_dispatch_pacman" "$t2_dispatch_limine" "$t2_dispatch_sudo" +sed -i -e "s|/usr/bin/lspci|$t2_dispatch_lspci|g" -e "s|/usr/bin/pacman|$t2_dispatch_pacman|g" -e "s|/usr/bin/limine-mkinitcpio|$t2_dispatch_limine|g" -e "s|/usr/bin/sudo|$t2_dispatch_sudo|g" -e "s|/run/omarchy-t2-hardware-migration.lock|$t2_dispatch_lock|g" -e "s|/usr/share/omarchy/migrations/1785944594.sh|$t2_dispatch|g" -e "s|/var/lib/omarchy/migrations/1785944594|$t2_dispatch_marker|g" -e "s|/etc/limine-entry-tool.d/t2-mac.conf|$t2_dispatch_conf|g" -e "s|/etc/t2fand.conf|$t2_dispatch_fan|g" -e "s|/proc/cmdline|$t2_dispatch_cmdline|g" "$t2_dispatch" +if root_run "$t2_dispatch" --machine; then fail "T2 failed rebuild publishes completion in full retry fixture"; fi +[[ ! -e $t2_dispatch_marker && $(grep -c '^rebuild$' "$t2_dispatch_log") == 1 ]] || fail "T2 failed rebuild did not remain pending" +root_run "$t2_dispatch" +[[ -e $t2_dispatch_marker && $(grep -c '^rebuild$' "$t2_dispatch_log") == 2 && $(grep -c '^sudo$' "$t2_dispatch_log") == 1 ]] || fail "T2 no-argument dispatch did not retry and mark an already-correct persistent configuration" +root_run "$t2_dispatch" +[[ $(grep -c '^sudo$' "$t2_dispatch_log") == 1 && $(grep -c '^rebuild$' "$t2_dispatch_log") == 2 ]] || fail "T2 marked replay entered the privileged transaction" +rm -f "$t2_dispatch_marker" "$tmp/t2-present" +root_run "$t2_dispatch" +[[ $(grep -c '^sudo$' "$t2_dispatch_log") == 1 && ! -e $t2_dispatch_marker ]] || fail "confirmed non-T2 hardware entered the privileged transaction" +pass "T2 full no-argument dispatch retries an unmarked failed rebuild and keeps marked or confirmed non-T2 runs unprivileged" + +cups_bin="$tmp/cups-bin"; mkdir "$cups_bin"; printf '#!/bin/bash\nexit 9\n' >"$cups_bin/pacman"; chmod +x "$cups_bin/pacman" +cups_body="$tmp/cups-body.sh"; script_copy 1787815267 "$cups_body" +sed -i -e "s|/usr/bin/pacman|$cups_bin/pacman|g" -e "s|/var/lib/omarchy/migrations/1787815267|$tmp/cups.marker|g" "$cups_body" +if root_run "$cups_body" --machine; then fail "CUPS package discovery error is treated as absence"; fi +[[ ! -e $tmp/cups.marker ]] || fail "CUPS discovery error publishes completion" +pass "CUPS machine body preserves package discovery errors for retry" + +cat >"$cups_bin/pacman" <<'SH' +#!/bin/bash +[[ $1 == -Qq ]] && { printf 'cups\n'; exit 0; } +exit 0 +SH +cat >"$cups_bin/getent" <<'SH' +#!/bin/bash +if (( $# == 2 )) && [[ $1 == passwd && $2 == cups-browsed ]]; then + echo 'cups-browsed:x:209:209:CUPS printer discovery:/:/usr/bin/nologin' +elif (( $# == 2 )) && [[ $1 == group && $2 == cups-browsed ]]; then + echo 'cups-browsed:x:209:' +elif (( $# == 1 )) && [[ $1 == passwd ]]; then + exit "${NSS_ENUM_STATUS:-0}" +else + exit 2 +fi +SH +cat >"$cups_bin/systemctl" <<'SH' +#!/bin/bash +[[ $1 == is-active ]] && exit 3 +[[ $1 == is-enabled ]] && { echo disabled; exit 1; } +[[ ${CUPS_MUTATE_FAIL:-0} == 0 ]] || exit 23 +exit 0 +SH +chmod +x "$cups_bin"/* +sed -i -e "s|/usr/bin/getent|$cups_bin/getent|g" -e "s|/usr/bin/systemctl|$cups_bin/systemctl|g" "$cups_body" +if NSS_ENUM_STATUS=8 root_run "$cups_body" --machine; then fail "CUPS full passwd enumeration failure is treated as empty output"; fi +[[ ! -e $tmp/cups.marker ]] || fail "CUPS NSS enumeration failure publishes completion" +if NSS_ENUM_STATUS=0 CUPS_MUTATE_FAIL=1 root_run "$cups_body" --machine; then fail "CUPS service mutation failure publishes completion"; fi +[[ ! -e $tmp/cups.marker ]] || fail "CUPS service mutation failure is not retryable" +NSS_ENUM_STATUS=0 root_run "$cups_body" --machine +[[ -e $tmp/cups.marker ]] || fail "CUPS NSS enumeration failure is not retryable" +NSS_ENUM_STATUS=8 root_run "$cups_body" --machine +pass "CUPS NSS and service mutation failures remain pending, retry successfully, and replay without querying NSS" + +for transformed in "$fido_body" "$bt_body" "$t2_body" "$cups_body"; do + gate_output="$tmp/$(basename "$transformed").gate-output" + set +e + /usr/bin/bash -p "$transformed" --machine >"$gate_output" 2>&1 + gate_status=$? + set -e + [[ $gate_status == 1 ]] || fail "$(basename "$transformed") returns $gate_status instead of the root-gate status" + grep -Fq 'its machine phase requires root' "$gate_output" || fail "$(basename "$transformed") did not execute the non-root machine gate" + if root_run "$transformed" --unexpected >/dev/null 2>&1; then fail "$(basename "$transformed") accepts an unexpected argument"; fi +done +pass "all transformed production dispatchers preserve their EUID and argument gates" + +for id in 1785944594 1786380259 1787494718 1787815267; do + source_file="$ROOT/migrations/$id.sh" + grep -Fq '/usr/bin/env -i PATH=/usr/bin:/bin' "$source_file" || fail "$id inherits caller environment" + grep -Fq "/usr/share/omarchy/migrations/$id.sh --machine" "$source_file" || fail "$id lacks a fixed packaged target" + ! grep -Eq 'OMARCHY_[A-Z_]+:-?/' "$source_file" || fail "$id gives caller path authority" +done +pass "machine phases retain fixed paths and a clean privileged environment" diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh old mode 100644 new mode 100755 index f332f80e414..06965215f0a --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -2,122 +2,457 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -script="$ROOT/bin/omarchy-sudo-passwordless" -tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +command_path="$ROOT/bin/omarchy-sudo-passwordless" +security_library_path="$ROOT/bin/omarchy-security-functions" +tmpfiles_path="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +migration_path="$ROOT/migrations/1788163635.sh" test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT -mock_bin="$test_tmp/bin" -grant="$test_tmp/grant" -calls="$test_tmp/calls" -mkdir -p "$mock_bin" +function_prefix() { + printf 'source %q\n' "$security_library_path" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$command_path" +} -cat >"$mock_bin/gum" <<'SH' -#!/bin/bash -exit 0 -SH +# Exercise the validation code itself. Leading zeroes remain numeric, but zero, +# negatives, oversized grants, and shell syntax are rejected. +( + source <(function_prefix) + for minutes in 1 15 1440 00015; do + valid_minutes "$minutes" || fail "passwordless sudo accepts bounded duration $minutes" + done + for minutes in 0 1441 -1 1m '1;id' '' 18446744073709551617; do + ! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'" + done +) +pass "passwordless sudo validates a bounded positive duration" -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash +# The public entry point uses the kernel-backed numeric identity; $USER is +# never interpolated into a privileged filename or sudoers rule. +grep -F 'uid=$(/usr/bin/id -u)' "$command_path" >/dev/null || + fail "passwordless sudo derives the caller from id -u" +! grep -Eq '\$\{?USER\}?' "$command_path" || + fail "passwordless sudo does not trust USER for privileged policy" +grep -F '[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]]' "$command_path" >/dev/null || + fail "passwordless sudo validates sudo provenance" +pass "passwordless sudo derives and validates trusted account identity" -printf 'systemctl %s\n' "$*" >>"$TEST_CALLS" -[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]] -SH +# Status inspection and the confirmation UI are mixed-trust: a normal sudo +# status call would publish a timestamp that a hostile prompt helper could use +# even when the user declines the grant. Exercise the public flow with a sudo +# model that publishes a token only when -N is missing. +grep -Fxq '#!/bin/bash -p' "$command_path" || + fail "passwordless sudo no longer suppresses Bash startup injection" -cat >"$mock_bin/sudo" <<'SH' +public_sudo_stub="$test_tmp/public-sudo" +public_gum_stub="$test_tmp/public-gum" +public_token="$test_tmp/public-token" +public_exploit="$test_tmp/public-exploit" +cat >"$public_sudo_stub" <<'STUB' #!/bin/bash - -printf 'sudo %s\n' "$*" >>"$TEST_CALLS" - -case ${1:-} in -test) - [[ ${2:-} == "-f" && -f $TEST_GRANT ]] - ;; -tee) - /usr/bin/tee "$TEST_GRANT" - ;; -chmod) - /usr/bin/chmod "$2" "$TEST_GRANT" - ;; -systemd-run) - [[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]] - ;; -rm) - /usr/bin/rm -f -- "$TEST_GRANT" - ;; -systemctl) +if [[ ${1:-} == -h ]]; then + echo 'usage: sudo [-ABbEHkNnPS] command' exit 0 - ;; -*) - echo "unexpected sudo command: $*" >&2 - exit 90 - ;; +fi +if [[ ${1:-} == -k ]]; then + rm -f -- "$TEST_PUBLIC_TOKEN" + exit 0 +fi +no_update=0 +if [[ ${1:-} == -N ]]; then no_update=1; shift; fi +[[ ${1:-} != -- ]] || shift +((no_update)) || : >"$TEST_PUBLIC_TOKEN" +case "${2:-}" in + __status) exit "${TEST_PUBLIC_STATUS:-3}" ;; + __enable|__disable) exit 0 ;; + *) exit 2 ;; esac -SH +STUB +cat >"$public_gum_stub" <<'STUB' +#!/bin/bash +[[ -z ${TEST_PUBLIC_GUM_LOG:-} ]] || : >"$TEST_PUBLIC_GUM_LOG" +[[ ! -e $TEST_PUBLIC_TOKEN ]] || : >"$TEST_PUBLIC_EXPLOIT" +exit 1 +STUB +chmod 0755 "$public_sudo_stub" "$public_gum_stub" +public_flow="$test_tmp/passwordless-public-flow" +/usr/bin/sed "s#/usr/bin/sudo#$public_sudo_stub#g" "$security_library_path" >"$test_tmp/omarchy-security-functions" +/usr/bin/sed \ + -e "s#/usr/bin/sudo#$public_sudo_stub#g" \ + -e "s#/usr/bin/gum#$public_gum_stub#g" \ + "$command_path" >"$public_flow" +chmod 0755 "$public_flow" +TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \ + /usr/bin/bash -p "$public_flow" 15 >/dev/null +[[ ! -e $public_token && ! -e $public_exploit ]] || + fail "passwordless confirmation inherited a reusable status credential" +for status in 1 2; do + if TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \ + TEST_PUBLIC_STATUS="$status" TEST_PUBLIC_GUM_LOG="$test_tmp/unsafe-status-confirmation" \ + /usr/bin/bash -p "$public_flow" 15 >"$test_tmp/status-error.output" 2>&1; then + fail "passwordless sudo treats status/authorization failure $status as inactive" + fi + [[ ! -e $test_tmp/unsafe-status-confirmation ]] || fail "failed status inspection opens the enable prompt" + grep -q 'Could not safely inspect passwordless sudo' "$test_tmp/status-error.output" || + fail "failed status inspection lacks recovery guidance" +done -chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl" +startup_env="$test_tmp/passwordless-bash-env" +startup_marker="$test_tmp/passwordless-bash-env-ran" +cat >"$startup_env" <<'STUB' +: >"$TEST_STARTUP_MARKER" +set -o privileged +unset BASH_ENV +STUB +if BASH_ENV="$startup_env" TEST_STARTUP_MARKER="$startup_marker" \ + /usr/bin/bash "$public_flow" -p >/dev/null 2>&1; then + fail "passwordless sudo accepted an unsafe interpreter with a decoy -p" +fi +[[ -e $startup_marker && ! -e $public_token && ! -e $public_exploit ]] || + fail "unsafe passwordless startup reached its sudo workflow" +pass "passwordless confirmation uses a cold command-scoped credential boundary" -run_command() { - TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \ - "$script" "$@" -} +# Source a path-rewritten copy so the real cleanup implementation can be +# exercised without touching /etc. Exact generated numeric rules are removed +# even after account deletion or a crash before state publication. Anything an +# administrator changed, and every symlink, is preserved. +fake_sudoers="$test_tmp/sudoers.d" +mkdir "$fake_sudoers" +rewritten="$test_tmp/passwordless-lib.sh" +function_prefix | sed "s#/etc/sudoers.d#$fake_sudoers#g" >"$rewritten" +( + source "$rewritten" + printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$fake_sudoers/99-omarchy-nopasswd-424242" + printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/pacman\n' >"$fake_sudoers/99-omarchy-nopasswd-424243" + ln -s "$fake_sudoers/99-omarchy-nopasswd-424243" "$fake_sudoers/99-omarchy-nopasswd-424244" + remove_known_legacy_rules +) +[[ ! -e $fake_sudoers/99-omarchy-nopasswd-424242 ]] || + fail "boot cleanup removes a state-less numeric orphan" +[[ -f $fake_sudoers/99-omarchy-nopasswd-424243 ]] || + fail "boot cleanup preserves administrator-authored policy" +[[ -L $fake_sudoers/99-omarchy-nopasswd-424244 ]] || + fail "boot cleanup refuses sudoers symlinks" +pass "boot cleanup removes crash/deleted-account orphans conservatively" -: >"$calls" -enable_output=$(run_command 15) -[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled" -[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] || - fail "the enabled grant belongs to the current user" "$(cat "$grant")" -grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" || - fail "enabling arms the expiry timer" "$(cat "$calls")" -[[ $enable_output == *"automatically disable in 15 minutes"* ]] || - fail "success is reported after the timer is armed" "$enable_output" -pass "enabling arms expiry before reporting success" - -: >"$calls" -rm -f "$grant" -if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then - fail "enabling fails when the expiry timer cannot be armed" -fi -[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant" -[[ $failure_output == *"Revoking access now"* ]] || - fail "timer setup failure explains the fail-closed revocation" "$failure_output" -[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] || - fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output" -pass "timer setup failure revokes a new grant" - -: >"$calls" -printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant" -if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then - fail "updating fails when the replacement expiry timer cannot be armed" -fi -[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant" -[[ $update_output != *"timer updated"* ]] || - fail "timer update failure does not report success" "$update_output" -pass "timer update failure revokes the existing grant" +# A boot gate must not report success when deletion itself fails. Exercise the +# real cleanup and post-cleanup verification with a deterministic failing rm. +rm_failure_dir="$test_tmp/rm-failure-sudoers" +mkdir "$rm_failure_dir" +printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$rm_failure_dir/99-omarchy-nopasswd-424245" +failing_rm="$test_tmp/failing-rm" +cat >"$failing_rm" <<'FAILING_RM' +#!/bin/bash +exit 1 +FAILING_RM +chmod +x "$failing_rm" +rm_failure_lib="$test_tmp/rm-failure-lib.sh" +function_prefix | + sed -e "s#/etc/sudoers.d#$rm_failure_dir#g" \ + -e "s#/var/lib/omarchy/sudo-passwordless#$test_tmp/empty-state#g" \ + -e "s#/usr/bin/rm#$failing_rm#g" >"$rm_failure_lib" +mkdir "$test_tmp/empty-state" +( + source "$rm_failure_lib" + ! cleanup_all_locked +) || fail "boot cleanup fails when an Omarchy rule cannot be removed" +[[ -f $rm_failure_dir/99-omarchy-nopasswd-424245 ]] || + fail "rm-failure fixture remains available for verification" +pass "boot cleanup fails closed when policy deletion fails" + +# Reproduce the migration's real sudo provenance: sudo sets SUDO_UID. Rewrite +# only the read-only EUID probe so this unprivileged test can exercise the root +# dispatcher, then assert that cleanup (which can only revoke privilege) runs. +dispatch_lib="$test_tmp/dispatch-lib.sh" +function_prefix | sed 's/((EUID == 0))/((TEST_EUID == 0))/g' >"$dispatch_lib" +( + source "$dispatch_lib" + called="" + cleanup_all_locked() { called=cleanup; } + with_root_lock() { "$@"; } + TEST_EUID=0 SUDO_UID=1000 root_dispatch __cleanup-all + [[ $called == cleanup ]] +) || fail "migration cleanup dispatch accepts authenticated sudo provenance" +pass "migration can invoke fail-closed cleanup through sudo" + +# A grant cannot be published until the static unit is verified/enabled, and a +# timer setup failure removes its pending state without calling publish_rule. +transaction_dir="$test_tmp/transaction" +mkdir "$transaction_dir" +transaction_lib="$test_tmp/transaction-lib.sh" +function_prefix | sed "s#/var/lib/omarchy/sudo-passwordless#$transaction_dir#g" >"$transaction_lib" +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 1; } + publish_rule() { return 99; } + ! enable_locked 1000 15 +) +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + publish_rule() { printf published >"$transaction_dir/published"; } + cleanup_uid_locked() { : >"$transaction_dir/failed-timer-cleanup"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/pending && ! -e $transaction_dir/published && + -e $transaction_dir/failed-timer-cleanup ]] +) || fail "passwordless sudo fails closed on prerequisite/timer failure" +pass "passwordless sudo publishes no rule after partial setup failure" -mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") -(( ${#tmpfiles_rules[@]} == 1 )) || - fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" +# Erik's predecessor fix revoked an already-active grant when an extension +# could not arm its replacement timer. Keep that fail-closed property while +# the new transaction deliberately leaves the old timer armed until the new +# one is verified. +replacement_state="$transaction_dir/1000.state" +replacement_rule="$transaction_dir/1000.rule" +replacement_stopped="$transaction_dir/old-timer-stopped" +old_timer=omarchy-nopasswd-expire-1000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +printf 'UID=1000\nUSER=audituser\nEXPIRES=2000000000\nTIMER=%s\n' "$old_timer" >"$replacement_state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$replacement_rule" +( + source "$transaction_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + state_file() { printf '%s' "$replacement_state"; } + rule_file() { printf '%s' "$replacement_rule"; } + prepare_state_file() { local pending="$transaction_dir/replacement-pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + stop_timer() { [[ $1 == "$old_timer" ]] && : >"$replacement_stopped"; } + ! enable_locked 1000 30 + [[ ! -e $replacement_state && ! -e $replacement_rule && -e $replacement_stopped ]] +) || fail "passwordless sudo leaves an existing grant live after replacement timer failure" +pass "replacement timer failure revokes the existing grant" -fake_root="$test_tmp/root" +# Expiry is a wall-clock promise, so the transient timer must carry the exact +# absolute epoch recorded in root state. A monotonic-only --on-active timer +# pauses during suspend and can otherwise extend a short grant by hours. +timer_args="$test_tmp/timer-args" +calendar_systemd_run="$test_tmp/calendar-systemd-run" +calendar_systemctl="$test_tmp/calendar-systemctl" +cat >"$calendar_systemd_run" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" >"$TEST_TIMER_ARGS" +STUB +cat >"$calendar_systemctl" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$calendar_systemd_run" "$calendar_systemctl" +calendar_lib="$test_tmp/calendar-lib.sh" +function_prefix | + sed -e "s#/usr/bin/systemd-run#$calendar_systemd_run#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$calendar_lib" +( + source "$calendar_lib" + TEST_TIMER_ARGS="$timer_args" start_expiry_timer 1000 2000000000 \ + omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +) || fail "passwordless sudo cannot arm its absolute expiry timer" +grep -Fx -- '--on-calendar=@2000000000' "$timer_args" >/dev/null || + fail "passwordless sudo timer does not advance across suspend" +pass "passwordless sudo arms the recorded absolute wall-clock expiry" + +# A resumed machine can briefly observe the timer as active before systemd +# dispatches its overdue service. Status must independently enforce EXPIRES and +# synchronously remove policy instead of trusting timer activity alone. +expired_state="$test_tmp/expired-state" +expired_sudoers="$test_tmp/expired-sudoers" +mkdir "$expired_state" "$expired_sudoers" +expired_timer=omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +printf 'UID=1000\nUSER=audituser\nEXPIRES=1\nTIMER=%s\n' "$expired_timer" >"$expired_state/1000.state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$expired_sudoers/99-omarchy-nopasswd-1000" +expired_lib="$test_tmp/expired-lib.sh" +function_prefix | + sed -e "s#/var/lib/omarchy/sudo-passwordless#$expired_state#g" \ + -e "s#/etc/sudoers.d#$expired_sudoers#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$expired_lib" +( + source "$expired_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + ! status_locked 1000 +) || fail "passwordless sudo accepts expired root state while its timer is active" +[[ ! -e $expired_state/1000.state && ! -e $expired_sudoers/99-omarchy-nopasswd-1000 ]] || + fail "passwordless sudo does not synchronously revoke expired state" +pass "passwordless sudo enforces wall-clock expiry independently of timer dispatch" + +# If the transient timer fires between its first active check and publication, +# the just-created rule must be synchronously revoked instead of surviving to +# reboot. Model that narrow transition with the real enable transaction. +inactive_systemctl="$test_tmp/inactive-systemctl" +cat >"$inactive_systemctl" <<'STUB' +#!/bin/bash +exit 1 +STUB +chmod 0755 "$inactive_systemctl" +post_publish_lib="$test_tmp/post-publish-lib.sh" +sed "s#/usr/bin/systemctl#$inactive_systemctl#g" "$transaction_lib" >"$post_publish_lib" +( + source "$post_publish_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending-after-arm"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 0; } + publish_rule() { : >"$transaction_dir/published-after-arm"; } + cleanup_uid_locked() { rm -f "$transaction_dir/published-after-arm"; : >"$transaction_dir/revoked-after-arm"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/published-after-arm && -e $transaction_dir/revoked-after-arm ]] +) || fail "passwordless sudo leaves a grant when its armed timer expires before publication completes" +pass "timer expiry during publication revokes the grant synchronously" + +# Follow the maintainer's package-owned tmpfiles design: one boot-only rule +# owns this filename namespace. A routine --remove leaves live grants alone; +# early boot removes them before a user can log in. The migration only revokes +# legacy runtime state and never writes static policy into /usr. +mapfile -t tmpfiles_rules < <(/usr/bin/grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_path") +(( ${#tmpfiles_rules[@]} == 1 )) || fail "passwordless sudo ships one boot cleanup rule" +[[ ${tmpfiles_rules[0]} == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || + fail "passwordless sudo boot cleanup does not own the exact generated namespace" +fake_root="$test_tmp/tmpfiles-root" sudoers_dir="$fake_root/etc/sudoers.d" mkdir -p "$sudoers_dir" -grant_names=(alice buildbot-2 user.123 'service$') -for grant_name in "${grant_names[@]}"; do - touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name" +for name in alice buildbot-2 424242; do + : >"$sudoers_dir/99-omarchy-nopasswd-$name" +done +: >"$sudoers_dir/omarchy-dns" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[0]}" +[[ -e $sudoers_dir/99-omarchy-nopasswd-alice ]] || fail "non-boot tmpfiles run shortened a live grant" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[0]}" +! find "$sudoers_dir" -name '99-omarchy-nopasswd-*' -print -quit | /usr/bin/grep -q . || + fail "boot cleanup left a generated passwordless grant" +[[ -e $sudoers_dir/omarchy-dns ]] || fail "boot cleanup removed an unrelated sudoers rule" +/usr/bin/grep -Fx 'sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all' "$migration_path" >/dev/null +! /usr/bin/grep -q 'omarchy-sudo-passwordless-cleanup.service' "$migration_path" || + fail "migration retained a custom boot service instead of package-owned tmpfiles" +pass "package-owned boot cleanup is narrow, boot-only, and migration-safe" + +# Removing the settings package also removes the tmpfiles rule. Its package +# lifecycle must therefore revoke the same owned namespace synchronously, while +# preserving every unrelated sudoers file. +pkgs_candidates=( + "${OMARCHY_PKGS_PATH:-}" + "$ROOT/../omarchy-pkgs" + "$ROOT/../../omarchy-pkgs" + "$HOME/Work/omarchy/omarchy-pkgs" + "$HOME/Work/omacom/omarchy-pkgs" +) +pkgs_root="" +for candidate in "${pkgs_candidates[@]}"; do + if [[ -n $candidate && -d $candidate/pkgbuilds/omarchy-settings ]]; then + pkgs_root=$candidate/pkgbuilds + break + elif [[ -n $candidate && -d $candidate/omarchy-settings ]]; then + pkgs_root=$candidate + break + fi done -touch "$sudoers_dir/omarchy-dns" +[[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" + +for package_name in omarchy-settings omarchy-settings-dev; do + install_script="$pkgs_root/$package_name/$package_name.install" + transformed_install="$test_tmp/$package_name.install" + removal_root="$test_tmp/$package_name-remove" + removal_sudoers="$removal_root/etc/sudoers.d" + mkdir -p "$removal_sudoers" "$removal_root/run/lock" "$removal_root/etc/tmpfiles.d" + : >"$removal_sudoers/99-omarchy-nopasswd-1000" + : >"$removal_sudoers/99-omarchy-nopasswd-legacy-user" + : >"$removal_sudoers/omarchy-dns" + ln -s ../administrator/os-release "$removal_root/etc/os-release" + package_stat="$test_tmp/package-stat" + cat >"$package_stat" <<'STUB' +#!/bin/bash +if [[ $2 == '%u' ]]; then printf '0\n'; else /usr/bin/stat "$@"; fi +STUB + chmod +x "$package_stat" + sed -e "s#/etc/#$removal_root/etc/#g" \ + -e "s#/run#$removal_root/run#g" \ + -e "s#/usr/bin/stat#$package_stat#g" "$install_script" >"$transformed_install" + ( + source "$transformed_install" + pre_remove + [[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + post_remove + ) || fail "$package_name removal revokes active passwordless grants" + ! find "$removal_sudoers" -name '99-omarchy-nopasswd-*' -print -quit | grep -q . || + fail "$package_name removal leaves a passwordless grant behind" + [[ -e $removal_sudoers/omarchy-dns ]] || + fail "$package_name removal deletes an unrelated sudoers policy" + [[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] || + fail "$package_name removal changes unrelated OS metadata" + : >"$removal_sudoers/99-omarchy-nopasswd-1001" + ( + source "$transformed_install" + post_remove + ) || fail "$package_name removal handles administrator OS selector state" + [[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] || + fail "$package_name removal overwrites an administrator OS selector" + [[ ! -e $removal_sudoers/99-omarchy-nopasswd-1001 ]] || + fail "$package_name removal grant cleanup depends on OS selector state" -systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}" -[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] || - fail "boot-only cleanup leaves a live grant alone outside boot" + ( + source "$transformed_install" + _etc_overrides_apply() { :; } + if post_install; then exit 1; fi + [[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + : >"$removal_root/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" + post_install + [[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + : >"$removal_sudoers/99-omarchy-nopasswd-1002" + pre_upgrade + [[ ! -e $removal_sudoers/99-omarchy-nopasswd-1002 ]] + post_upgrade + [[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + ) || fail "$package_name restores grant availability only after boot cleanup is installed" +done +pass "settings package transitions revoke grants and preserve unrelated configuration" -systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}" -for grant_name in "${grant_names[@]}"; do - stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name" - [[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant" +# Exercise the production flock wrapper under contention. mkdir is an atomic +# overlap detector; all workers must enter and leave the protected region. +lock_dir="$test_tmp/lock-runtime" +mkdir "$lock_dir" +lock_lib="$test_tmp/lock-lib.sh" +function_prefix | + sed -e "s#/run/omarchy/sudo-passwordless#$lock_dir#g" \ + -e "s#/run/lock/omarchy-sudo-passwordless.lock#$test_tmp/passwordless.lock#g" \ + -e 's#/usr/bin/chown root:root "$LOCK_FILE"#/usr/bin/true#' >"$lock_lib" +worker="$test_tmp/worker.sh" +cat >"$worker" <<'WORKER' +#!/bin/bash +set -euo pipefail +source "$LOCK_LIB" +prepare_root_state() { :; } +critical() { + mkdir "$LOCK_SENTINEL" + sleep 0.03 + rmdir "$LOCK_SENTINEL" + printf x >>"$LOCK_RESULTS" +} +with_root_lock critical +WORKER +chmod +x "$worker" +for _ in {1..8}; do + LOCK_LIB="$lock_lib" LOCK_SENTINEL="$test_tmp/held" LOCK_RESULTS="$test_tmp/results" bash "$worker" & done -[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" -pass "systemd-tmpfiles removes generated grants only during boot" +wait +[[ $(wc -c <"$test_tmp/results") == 8 ]] || fail "concurrent passwordless operations serialize" +pass "passwordless sudo serializes concurrent operations" + +# Same-boot expiry calls the fixed installed cleanup command, and cleanup +# removes policy before touching a timer so timer failures cannot extend it. +grep -F '"$INSTALLED_SELF" __expire "$uid" "$timer"' "$command_path" >/dev/null +cleanup_body=$(awk '/^cleanup_uid_locked\(\) \{/ { in_body=1 } in_body { print } in_body && /^}/ { exit }' "$command_path") +rm_line=$(grep -n '/usr/bin/rm -f' <<<"$cleanup_body" | head -1 | cut -d: -f1) +stop_line=$(grep -n 'stop_timer' <<<"$cleanup_body" | tail -1 | cut -d: -f1) +((rm_line < stop_line)) || fail "expiry removes sudo policy before timer cleanup" +pass "same-boot expiration is fixed-target and fail closed" diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh new file mode 100644 index 00000000000..b3198405642 --- /dev/null +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -0,0 +1,235 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +children=() +cleanup() { + local status=$? + trap - EXIT + if (( ${#children[@]} )); then + kill "${children[@]}" 2>/dev/null || true + wait "${children[@]}" 2>/dev/null || true + fi + rm -rf "$test_tmp" + exit "$status" +} +trap cleanup EXIT + +# All policy, state, locks and command mutations stay in this private fixture. +# Native visudo validates inert fragments; no test installs host sudo policy. +mkdir -p "$test_tmp/bin" "$test_tmp/state" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/hooks" +export TEST_GRANT_ROOT="$test_tmp" +cat >"$test_tmp/bin/stat" <<'STUB' +#!/bin/bash +case $2 in + '%u') printf '0\n' ;; + '%a') if [[ -d ${@: -1} ]]; then printf '755\n'; else printf '644\n'; fi ;; + '%u %a') if [[ -d ${@: -1} ]]; then printf '0 755\n'; else printf '0 644\n'; fi ;; + *) exec /usr/bin/stat "$@" ;; +esac +STUB +cat >"$test_tmp/bin/install" <<'STUB' +#!/bin/bash +args=() +while (($#)); do + case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac +done +exec /usr/bin/install "${args[@]}" +STUB +cat >"$test_tmp/bin/rm" <<'STUB' +#!/bin/bash +for path in "$@"; do + if [[ ${TEST_FAIL_TEMP_CLEANUP:-0} == 1 && $path == "$TEST_GRANT_ROOT/state/".sudoers.* ]]; then exit 1; fi + if [[ ${TEST_FAIL_RULE_DELETE:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/"* ]]; then exit 1; fi +done +exec /usr/bin/rm "$@" +STUB +cat >"$test_tmp/bin/systemctl" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_GRANT_ROOT/systemctl.log" +exit 0 +STUB +chmod +x "$test_tmp/bin/"* +library="$test_tmp/grant-functions.sh" +{ + printf 'source %q\n' "$ROOT/bin/omarchy-security-functions" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$ROOT/bin/omarchy-sudo-passwordless" +} | sed \ + -e "s|/var/lib/omarchy/sudo-passwordless|$test_tmp/state|g" \ + -e "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|g" \ + -e "s|/etc/tmpfiles.d|$test_tmp/etc/tmpfiles.d|g" \ + -e "s|/usr/share/libalpm/hooks|$test_tmp/hooks|g" \ + -e "s|/run/lock/omarchy-sudo-passwordless.lock|$test_tmp/run/lock/omarchy-sudo-passwordless.lock|g" \ + -e "s|/run/omarchy-sudo-passwordless-package-removing|$test_tmp/run/omarchy-sudo-passwordless-package-removing|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \ + -e "s|/usr/bin/install|$test_tmp/bin/install|g" \ + -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \ + -e "s|/usr/bin/systemctl|$test_tmp/bin/systemctl|g" \ + -e 's|/usr/bin/chown|/usr/bin/true|g' >"$library" + +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" + +printf 'r! /etc/sudoers.d/99-omarchy-nopasswd-*\n' >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +# The expected policy text is mapped along with its filename in this fixture. +sed -i "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|" "$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" + +( + source "$library" + for name in 'buildbot$' audituser aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + valid_account_name "$name" || fail "supported account name rejected: $name" + printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$test_tmp/name-policy" + /usr/sbin/visudo -cf "$test_tmp/name-policy" >/dev/null + done + for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + ! valid_account_name "$name" || fail "invalid account name accepted" + done + ! valid_uid 18446744073709551617 || fail "overflowed UID accepted" + printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" + remove_known_legacy_rules + [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot\$ ]] +) || fail "supported account names and legacy cleanup disagree" +pass "provisioning-compatible names validate as sudoers and clean up correctly" + +transaction_setup() { + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + start_expiry_timer() { printf '%s\n' "$3" >>"$test_tmp/armed"; } + stop_timer() { printf '%s\n' "$1" >>"$test_tmp/stopped"; } +} + +( + source "$library" + transaction_setup + TEST_FAIL_TEMP_CLEANUP=1 enable_locked 1000 15 && exit 1 + [[ ! -e $(rule_file 1000) && ! -e $(state_file 1000) && -s $test_tmp/stopped ]] +) || fail "post-publication cleanup failure did not revoke before timer cleanup" +pass "failed temporary cleanup after publication revokes the live policy" + +rm -f "$test_tmp/stopped" +( + source "$library" + transaction_setup + TEST_FAIL_TEMP_CLEANUP=1 TEST_FAIL_RULE_DELETE=1 enable_locked 1000 15 && exit 1 + [[ -f $(rule_file 1000) && -f $(state_file 1000) && ! -e $test_tmp/stopped ]] + if TEST_FAIL_RULE_DELETE=1 revoke_inactive_grant 1000; then exit 1; else status=$?; fi + (( status == 2 )) +) || fail "failed policy revocation disarmed expiry or claimed inactive status" +pass "failed revocation preserves expiry jobs and returns a distinct error" + +( + source "$library" + transaction_setup + current_timer=$(read_state_timer 1000) + expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff + [[ -f $(rule_file 1000) ]] + expire_locked 1000 + [[ -f $(rule_file 1000) ]] + expire_locked 1000 "$current_timer" + [[ ! -e $(rule_file 1000) ]] +) || fail "a predecessor timer invalidates its replacement" +pass "old and legacy timer callbacks preserve a newer valid grant" + +( + source "$library" + transaction_setup + start_expiry_timer() { + : >"$REMOVAL_BLOCKER" + return 0 + } + enable_locked 1000 15 && exit 1 + [[ ! -e $(rule_file 1000) ]] +) || fail "publication ignores a lost package prerequisite" +rm "$test_tmp/run/omarchy-sudo-passwordless-package-removing" +pass "grant publication rechecks package availability after timer setup" + +pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs} +[[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds +package_script="$pkgs_path/omarchy-settings/omarchy-settings.install" +[[ -f $package_script ]] || fail "package checkout is required for shared lifecycle coverage" +sed -e "s|/etc/|$test_tmp/etc/|g" \ + -e "s|/run|$test_tmp/run|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \ + -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" "$package_script" >"$test_tmp/package.install" + +worker="$test_tmp/publisher.sh" +{ + printf '#!/bin/bash\nset -euo pipefail\nsource %q\n' "$library" + declare -f transaction_setup + printf 'test_tmp=%q\ntransaction_setup\n' "$test_tmp" + cat <<'WORKER' +publish_rule() { + : >"$test_tmp/publisher.entered" + while [[ ! -e $test_tmp/publisher.release ]]; do sleep 0.02; done + printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file "$1")" +} +with_root_lock enable_locked 1000 15 +WORKER +} >"$worker" +bash "$worker" >"$test_tmp/publisher.output" 2>&1 & +children+=("$!") +for ((attempt = 0; attempt < 250; attempt++)); do + [[ ! -e $test_tmp/publisher.entered ]] || break + sleep 0.02 +done +[[ -e $test_tmp/publisher.entered ]] || fail "grant publisher did not enter the shared lock" +bash -euo pipefail -c 'source "$1"; : >"$2"; pre_remove; post_remove' bash \ + "$test_tmp/package.install" "$test_tmp/removal.started" >"$test_tmp/removal.output" 2>&1 & +children+=("$!") +for ((attempt = 0; attempt < 250; attempt++)); do + [[ ! -e $test_tmp/removal.started ]] || break + sleep 0.02 +done +[[ -e $test_tmp/removal.started ]] || fail "package removal did not start" +touch "$test_tmp/publisher.release" +for child in "${children[@]}"; do wait "$child" || fail "shared lifecycle worker failed"; done +children=() +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "removal left a concurrently published grant" +[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || fail "removal did not block later publication" +( + source "$library" + transaction_setup + ! with_root_lock enable_locked 1000 15 +) || fail "a publisher can create a grant after package removal begins" +pass "package removal shares the grant lock and blocks later publication" + +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" +if TEST_FAIL_RULE_DELETE=1 bash -euo pipefail -c 'source "$1"; post_remove' bash "$test_tmp/package.install" >"$test_tmp/removal-failure.output" 2>&1; then + fail "package removal hid a failed policy deletion" +fi +grep -q 'Administrator cleanup is required' "$test_tmp/removal-failure.output" || fail "package deletion failure lacks recovery guidance" +pass "package removal reports cleanup failures instead of successful revocation" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + record=$(read_state_record 1000) + expiry=${record#*$'\t'} + expiry=${expiry%%$'\t'*} + deadline=$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ) + [[ $(cat "$(rule_file 1000)") == "audituser ALL=(ALL) NOTAFTER=$deadline NOPASSWD: ALL" ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + classify_generated_rule "$(rule_file 1000)" + rm -f "$(state_file 1000)" + remove_known_legacy_rules + [[ ! -e $(rule_file 1000) ]] +) || fail "native sudo deadline or state-independent bounded rule cleanup is incorrect" +pass "sudo policy contains the same deadline and bounded orphan rules are recognized" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + if TEST_FAIL_RULE_DELETE=1 package_removing_locked; then exit 1; fi + [[ -f $REMOVAL_BLOCKER && -f $(rule_file 1000) ]] + ! enable_locked 1000 5 + package_removing_locked + [[ ! -e $(rule_file 1000) ]] + rm -f "$REMOVAL_BLOCKER" "$PACKAGE_HOOK" + ! enable_locked 1000 5 +) || fail "pre-transaction revocation error or missing hook does not prevent new grants" +pass "package hook fails closed and grants require its installed policy" diff --git a/test/shell.d/restart-shell-test.sh b/test/shell.d/restart-shell-test.sh index 094c74f6449..31901736d19 100755 --- a/test/shell.d/restart-shell-test.sh +++ b/test/shell.d/restart-shell-test.sh @@ -172,7 +172,24 @@ else fi SH -chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl" +cat >"$restart_bin/busctl" <<'SH' +#!/bin/bash +if [[ -z ${OMARCHY_TEST_NOTIFICATION_CHECKS:-} ]]; then + echo 'b false' +else + checks=0 + [[ ! -f $OMARCHY_TEST_NOTIFICATION_CHECKS ]] || read -r checks <"$OMARCHY_TEST_NOTIFICATION_CHECKS" + (( checks += 1 )) + printf '%s\n' "$checks" >"$OMARCHY_TEST_NOTIFICATION_CHECKS" + if (( checks == 1 || checks >= 4 )); then + echo 'b true' + else + echo 'b false' + fi +fi +SH + +chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl" "$restart_bin/busctl" sleep 30 & restart_pid_one=$! @@ -194,6 +211,7 @@ OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \ OMARCHY_TEST_IPC_LOG="$ipc_log" \ OMARCHY_TEST_SESSION_PATH="$restart_root" \ OMARCHY_TEST_TRANSIENT_ENV=leaked \ +OMARCHY_TEST_NOTIFICATION_CHECKS="$test_tmp/notification-checks" \ timeout 5 "$ROOT/bin/omarchy-restart-shell" if kill -0 "$restart_pid_one" 2>/dev/null; then @@ -213,6 +231,8 @@ grep -F "kill -p $restart_root/shell --any-display" "$restart_log" >/dev/null || grep -F 'hl.dsp.exec_cmd("omarchy-launch-shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland" grep -F "ipc -n -p $restart_root/shell call -- shell ping" "$ipc_log" >/dev/null || fail "restart checks readiness in the session checkout" pass "restart replaces duplicate shell instances from the session checkout" +[[ $(<"$test_tmp/notification-checks") == 4 ]] || fail "restart waits for the existing notification service after core IPC is ready" +pass "restart waits for notification readiness before one-time update hooks" : >"$restart_log" printf '303\n' >"$restart_state" diff --git a/test/shell.d/security-entrypoint-symlink-test.sh b/test/shell.d/security-entrypoint-symlink-test.sh new file mode 100755 index 00000000000..a2cb6b2cdbf --- /dev/null +++ b/test/shell.d/security-entrypoint-symlink-test.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +export OMARCHY_UPDATE_LOGGED=1 + +# The real fixed entrypoints run only fixture operations. The sibling library +# is a harmless sentinel: invoking a command through another directory must +# source the library beside the resolved command instead of this file. +mkdir "$boundary_tmp/links" +printf '%s\n' 'touch "$SUDO_TEST_HOME/wrong-library"' >"$boundary_tmp/links/omarchy-security-functions" +for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set omarchy-migrate omarchy-setup-security-sshd omarchy-pkg-add; do + rm -f "$SUDO_TEST_ROOT/bin/$command" + copy_boundary_file "bin/$command" + ln -s "$SUDO_TEST_ROOT/bin/$command" "$boundary_tmp/links/$command" + reset_boundary + args=(unexpected) + case "$command" in + omarchy-update) args=(-y) ;; + omarchy-migrate) args=(--pending) ;; + omarchy-pkg-add) args=() ;; + esac + status=0 + "$boundary_tmp/links/$command" "${args[@]}" >"$boundary_tmp/output" 2>&1 || status=$? + [[ ! -e $SUDO_TEST_HOME/wrong-library ]] || fail "$command sourced a library beside its symlink" + (( status != 126 )) || fail "$command failed to locate its actual library" "$(<"$boundary_tmp/output")" + if [[ $command == "omarchy-migrate" || $command == "omarchy-pkg-add" ]]; then + [[ ! -s $SUDO_TEST_LOG ]] || fail "$command performed work during its read-only/usage check" + else + [[ -s $SUDO_TEST_LOG ]] || fail "$command did not reach the protected fixture boundary" + assert_boundary_cold "$command symlink" + fi + pass "$command resolves its own library when invoked through a symlink" +done + +ln -s "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/links/sudo" +reset_boundary +"$boundary_tmp/links/sudo" -k || fail "symlinked sudo wrapper lost its source library" +[[ $(<"$SUDO_TEST_LOG") == "sudo -k" ]] || fail "symlinked wrapper did not reach the fixed sudo stand-in" +pass "the sudo wrapper resolves its source library independently of its invocation link" diff --git a/test/shell.d/security-fido2-migration-test.sh b/test/shell.d/security-fido2-migration-test.sh old mode 100755 new mode 100644 index 01e5b698484..f16e413dc00 --- a/test/shell.d/security-fido2-migration-test.sh +++ b/test/shell.d/security-fido2-migration-test.sh @@ -1,557 +1,9 @@ #!/bin/bash - set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - migration="$ROOT/migrations/1787494718.sh" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" -calls="$test_tmp/calls.log" -stages="$test_tmp/stages.log" -notifications="$test_tmp/notifications.log" -# A directory of its own, not $test_tmp: the migration derives the FIDO2 -# directory from the authfile, and the case below where that directory is -# untraversable has to be able to take the permissions off it. -authdir="$test_tmp/etc-fido2" -authfile="$authdir/fido2" -migration_copy="$test_tmp/migration.sh" -mkdir -p "$stub_bin" "$authdir" -: >"$stages" -: >"$notifications" - -# The migration repairs an absolute path no unprivileged suite can write, and an -# environment override in the shipped file would hand a root install and mv an -# operand the caller chooses. Retarget a scratch copy instead, and fail if the -# path is not named exactly once, so this seam cannot quietly stop standing for -# the file it copies. -occurrences=$(grep -Fo /etc/fido2/fido2 "$migration" | wc -l) || occurrences=0 -(( occurrences == 1 )) || - fail "the migration names its authfile exactly once, so the test can retarget a copy" \ - "found $occurrences occurrences" -grep -Fxq 'authfile="/etc/fido2/fido2"' "$migration" || - fail "the production authfile path is a fixed literal, not caller-controlled" -pass "migration names its authfile once, and the test drives a retargeted copy" - -# Log every escalation, then execute only the expected bare sudo forms. Each -# operand is matched against the scratch authfile or a stage this stub created. -# This contains malformed calls made through that interface; arbitrary direct -# privileged commands in the migration are outside this harness. -cat >"$stub_bin/sudo" <<'SH' -#!/bin/bash - -set -euo pipefail - -reject() { - printf 'refusing unexpected sudo invocation:' >&2 - printf ' %q' "$@" >&2 - printf '\n' >&2 - exit 97 -} - -if [[ ${TEST_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" ]]; then - reject "$@" -fi - -printf 'sudo' >>"$TEST_LOG" -printf '\t%s' "$@" >>"$TEST_LOG" -printf '\n' >>"$TEST_LOG" - -safe_stage_path() { - local candidate=$1 - local prefix="$TEST_AUTHFILE.new." - local suffix - - [[ $candidate == "$prefix"* ]] || return 1 - suffix=${candidate#"$prefix"} - [[ $suffix =~ ^[[:alnum:]]{6}$ ]] -} - -recorded_stage() { - local candidate=$1 - - safe_stage_path "$candidate" || return 1 - [[ -f $candidate && ! -L $candidate ]] || return 1 - /usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES" -} - -case "$1" in - mktemp) - if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then - reject "$@" - fi - - case ${TEST_MKTEMP_MODE:-normal} in - normal) - stage=$(/usr/bin/mktemp -- "$2") - if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then - reject "$@" - fi - - printf '%s\n' "$stage" >>"$TEST_STAGES" - printf '%s\n' "$stage" - ;; - malformed) - stage="$TEST_AUTHFILE.new.A/BCDE" - /usr/bin/mkdir -- "${stage%/*}" - : >"$stage" - printf '%s\n' "$stage" - ;; - nonregular) - stage="$TEST_AUTHFILE.new.BAD123" - /usr/bin/mkdir -- "$stage" - printf '%s\n' "$stage" - ;; - *) - reject "$@" - ;; - esac - ;; - install) - if (( $# != 10 )) || [[ $2 != "-T" || $3 != "-m" || $4 != "644" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHFILE" ]] || ! recorded_stage "${10}"; then - reject "$@" - fi - - if [[ ${TEST_FAIL_INSTALL:-0} == "1" ]]; then - exit 71 - fi - - if (( EUID == 0 )); then - exec /usr/bin/install -T -m 644 -o root -g root "$9" "${10}" - else - exec /usr/bin/install -T -m 644 "$9" "${10}" - fi - ;; - mv) - if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then - reject "$@" - fi - - if [[ ${TEST_FAIL_MV:-0} == "1" ]]; then - exit 72 - fi - - exec /usr/bin/mv -Tf -- "$3" "$4" - ;; - chmod) - # Only ever the FIDO2 directory, and only back to the mode the setup - # installs. Nothing here may reopen the authfile itself. - if (( $# != 3 )) || [[ $2 != "755" || $3 != "$TEST_AUTHDIR" ]]; then - reject "$@" - fi - - exec /usr/bin/chmod 755 "$TEST_AUTHDIR" - ;; - test) - # Looking behind an untraversable directory, never a write. This stub is not - # really root, so open the directory just long enough to answer the way root - # would and put its mode straight back -- the suite then still sees whether - # production left the mode alone. - if (( $# != 3 )) || [[ $2 != "-e" && $2 != "-L" ]] || [[ $3 != "$TEST_AUTHFILE" ]]; then - reject "$@" - fi - - saved_mode=$(/usr/bin/stat -c %a "$TEST_AUTHDIR") - /usr/bin/chmod 755 "$TEST_AUTHDIR" - probe_status=0 - /usr/bin/test "$2" "$3" || probe_status=$? - /usr/bin/chmod "$saved_mode" "$TEST_AUTHDIR" - exit "$probe_status" - ;; - rm) - if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]]; then - reject "$@" - fi - - if [[ ${TEST_MKTEMP_MODE:-normal} == "nonregular" && $4 == "$TEST_AUTHFILE.new.BAD123" && -d $4 && ! -L $4 ]]; then - exit 73 - fi - - recorded_stage "$4" || reject "$@" - exec /usr/bin/rm -f -- "$4" - ;; - *) - reject "$@" - ;; -esac -SH - -chmod +x "$stub_bin/sudo" - -cat >"$stub_bin/stat" <<'SH' -#!/bin/bash - -set -euo pipefail - -if [[ ${TEST_FAKE_STAT:-0} == "1" && ${TEST_AUTHFILE:-} == "${TEST_AUTHDIR:-}/fido2" ]] && - (( $# == 3 )) && [[ $1 == "-c" && $3 == "$TEST_AUTHFILE" ]]; then - case "$2" in - %U) printf '%s\n' "$TEST_STAT_OWNER" ;; - %G) printf '%s\n' "$TEST_STAT_GROUP" ;; - %a) printf '%s\n' "$TEST_STAT_MODE" ;; - *) exec /usr/bin/stat "$@" ;; - esac -else - exec /usr/bin/stat "$@" -fi -SH - -chmod +x "$stub_bin/stat" - -# omarchy-migrate records this migration complete on any zero exit, so the -# states it cannot repair have to reach the user somewhere that outlives the -# update terminal's scrollback. -cat >"$stub_bin/omarchy-notification-send" <<'SH' -#!/bin/bash - -printf 'notify' >>"$TEST_NOTIFICATIONS" -printf '\t%s' "$@" >>"$TEST_NOTIFICATIONS" -printf '\n' >>"$TEST_NOTIFICATIONS" -exit "${TEST_NOTIFY_STATUS:-0}" -SH - -chmod +x "$stub_bin/omarchy-notification-send" - -run_migration() { - local fail_install="${1:-0}" - local fail_mv="${2:-0}" - local stat_owner="${3:-}" - local stat_group="${4:-}" - local stat_mode="${5:-}" - local mktemp_mode="${6:-normal}" - local notify_status="${7:-0}" - local fake_stat=0 - - if [[ -n $stat_owner || -n $stat_group || -n $stat_mode ]]; then - [[ -n $stat_owner && -n $stat_group && -n $stat_mode ]] || - fail "a fake stat fixture supplies owner, group and mode together" - fake_stat=1 - fi - - : >"$calls" - : >"$notifications" - sed "s|/etc/fido2/fido2|$authfile|" "$migration" >"$migration_copy" - - PATH="$stub_bin:$PATH" TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" \ - TEST_FAIL_INSTALL="$fail_install" TEST_FAIL_MV="$fail_mv" TEST_FAKE_STAT="$fake_stat" \ - TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_NOTIFICATIONS="$notifications" \ - TEST_NOTIFY_STATUS="$notify_status" TEST_STAGES="$stages" TEST_STAT_GROUP="$stat_group" \ - TEST_STAT_MODE="$stat_mode" TEST_STAT_OWNER="$stat_owner" TEST_TMP="$test_tmp" \ - bash -euo pipefail "$migration_copy" >/dev/null -} - -safe_fixture_stage_path() { - local candidate=$1 - local prefix="$authfile.new." - local suffix - - [[ $candidate == "$prefix"* ]] || return 1 - suffix=${candidate#"$prefix"} - [[ $suffix =~ ^[[:alnum:]]{6}$ ]] -} - -# Every repair case is about an authfile its own user can still rewrite. The -# calls below give stat an explicit caller-owned state, so the same assertions -# work as an ordinary user, as real root, and in a namespace mapping only UID 0. -write_authfile() { - printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authfile" - chmod "$1" "$authfile" -} - -# Almost every machine has never registered a key, and establishing that must -# not cost those users a password prompt. -rm -f "$authfile" -run_migration -[[ ! -s $calls ]] || fail "a machine with no authfile escalates nothing" "$(cat "$calls")" -pass "migration skips a machine that never set FIDO2 up" - -# What the old `sudo mv` left behind on every machine that did: the authfile PAM -# consults for sudo, owned by the account it authenticates, at the caller's umask. -write_authfile 644 || fail "the test can stage a non-root-owned authfile" -before_inode=$(stat -c %i "$authfile") -run_migration 0 0 caller caller 644 - -grep -Fq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" || - fail "the repair asks root for a unique sibling stage" "$(cat "$calls")" -grep -Fq $'sudo\tinstall\t-T\t-m\t644\t-o\troot\t-g\troot\t'"$authfile"$'\t' "$calls" || - fail "a user-owned authfile is reinstalled root:root and mode 644" "$(cat "$calls")" -grep -Fq $'sudo\tmv\t-Tf\t' "$calls" || - fail "the staged authfile is atomically renamed over the live path" "$(cat "$calls")" -if grep -Fq $'sudo\tchown\t' "$calls"; then - fail "the repair replaces the authfile rather than chowning it" "$(cat "$calls")" -fi -if grep -Fq $'sudo\trm\t' "$calls"; then - fail "a successful repair disarms its EXIT cleanup" "$(cat "$calls")" -fi -pass "migration stages and atomically installs a root-owned authfile" - -[[ $(stat -c %a "$authfile") == "644" ]] || - fail "the repaired authfile is mode 644" "got: $(stat -c %a "$authfile")" -[[ $(cat "$authfile") == "tester:credential-handle,public-key,es256,+presence" ]] || - fail "the repaired authfile keeps its credential" "got: $(cat "$authfile")" -if (( EUID == 0 )) && [[ $(stat -c %U:%G "$authfile") != "root:root" ]]; then - fail "the repaired authfile is root:root" "got: $(stat -c %U:%G "$authfile")" -fi -pass "migration preserves the credential with its PAM-readable mode" - -# The whole point of replacing rather than chowning. Permission is checked at -# open(2), so a descriptor the registering user opened before the update stays -# writable on the old inode through any chmod or chown -- and pam_u2f resolving -# the authfile path would keep reading exactly that inode. -[[ $(stat -c %i "$authfile") != "$before_inode" ]] || - fail "the repair lands on a new inode, orphaning any descriptor already open on the old one" -pass "migration replaces the inode a pre-existing writer would still hold" - -mapfile -t staged_paths <"$stages" -(( ${#staged_paths[@]} == 1 )) || - fail "the first repair creates exactly one stage" "got: ${staged_paths[*]}" -first_stage=${staged_paths[0]} -safe_fixture_stage_path "$first_stage" || - fail "the stage is a unique sibling of the authfile" "got: $first_stage" -[[ ! -e $first_stage && ! -L $first_stage ]] || - fail "the staged copy does not outlive the repair" "left behind: $first_stage" -pass "migration uses a unique sibling and leaves no staged copy behind" - -# Treat mktemp's output as untrusted even though sudo normally resolves the -# system binary. This existing regular path has a six-character suffix only if -# `/` is accepted as one of the characters, as the old ?????? glob did. The -# strict shape check must reject it before any privileged write or cleanup. -write_authfile 644 || fail "the test can stage the malformed-output fixture" -before_inode=$(stat -c %i "$authfile") -malformed_parent="$authfile.new.A" -malformed_stage="$malformed_parent/BCDE" -if run_migration 0 0 caller caller 644 malformed; then - fail "malformed mktemp output fails the migration" -fi - -grep -Fq $'sudo\tmktemp\t' "$calls" || - fail "the malformed-output fixture reaches mktemp" "$(cat "$calls")" -if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls" || grep -Fq $'sudo\trm\t' "$calls"; then - fail "malformed mktemp output reaches no install, rename or cleanup" "$(cat "$calls")" -fi -[[ $(stat -c %i "$authfile") == "$before_inode" ]] || - fail "malformed mktemp output leaves the live authfile inode alone" -[[ -f $malformed_stage && ! -L $malformed_stage ]] || - fail "the malformed-output fixture remains a regular scratch file" "got: $malformed_stage" -/usr/bin/rm -- "$malformed_stage" -/usr/bin/rmdir -- "$malformed_parent" -pass "migration rejects malformed mktemp output before any privileged write" - -# A name can have the right prefix and six-character suffix but still name an -# object mktemp would never return. Production must reject that object before -# install/mv; its cleanup may address only that validated scratch sibling and -# must not recursively remove the unexpected directory. -write_authfile 644 || fail "the test can stage the nonregular-output fixture" -before_inode=$(stat -c %i "$authfile") -nonregular_stage="$authfile.new.BAD123" -if run_migration 0 0 caller caller 644 nonregular; then - fail "nonregular mktemp output fails the migration" -fi - -safe_fixture_stage_path "$nonregular_stage" || - fail "the nonregular fixture uses a syntactically valid stage name" "got: $nonregular_stage" -if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then - fail "nonregular mktemp output is rejected before install or rename" "$(cat "$calls")" -fi -grep -Fq $'sudo\trm\t-f\t--\t'"$nonregular_stage" "$calls" || - fail "cleanup addresses only the validated nonregular sibling" "$(cat "$calls")" -[[ -d $nonregular_stage && ! -L $nonregular_stage ]] || - fail "cleanup does not recursively remove a nonregular stage" "got: $nonregular_stage" -[[ $(stat -c %i "$authfile") == "$before_inode" ]] || - fail "nonregular mktemp output leaves the live authfile inode alone" -/usr/bin/rmdir -- "$nonregular_stage" -pass "migration rejects and safely handles nonregular mktemp output" - -# A caller-owned file still needs a fresh inode and root ownership whatever its -# current mode. -write_authfile 600 || fail "the test can restage a non-root-owned authfile" -run_migration 0 0 caller caller 600 -grep -Fq $'sudo\tinstall\t-T\t' "$calls" || - fail "a mode-600 authfile the user still owns is repaired" "$(cat "$calls")" - -mapfile -t staged_paths <"$stages" -(( ${#staged_paths[@]} == 2 )) || - fail "two repairs create two stages" "got: ${staged_paths[*]}" -second_stage=${staged_paths[1]} -[[ ! -e $second_stage && ! -L $second_stage ]] || - fail "the second staged copy does not outlive the repair" "left behind: $second_stage" -pass "migration repairs a user-owned authfile whatever its mode and cleans its stage" - -# A failure after mktemp must remove only the exact stage the stub created. The -# live authfile stays on its original inode because mv was never reached. -write_authfile 644 || fail "the test can stage the cleanup fixture" -before_inode=$(stat -c %i "$authfile") -if run_migration 1 0 caller caller 644; then - fail "an install failure propagates out of the migration" -fi - -mapfile -t staged_paths <"$stages" -(( ${#staged_paths[@]} == 3 )) || - fail "the failed repair creates one stage" "got: ${staged_paths[*]}" -failed_stage=${staged_paths[2]} -grep -Fq $'sudo\trm\t-f\t--\t'"$failed_stage" "$calls" || - fail "the EXIT trap removes the failed repair's exact stage" "$(cat "$calls")" -[[ ! -e $failed_stage && ! -L $failed_stage ]] || - fail "the failed stage is cleaned up" "left behind: $failed_stage" -[[ $(stat -c %i "$authfile") == "$before_inode" ]] || - fail "a failed repair leaves the live authfile inode alone" -pass "migration cleans its unique stage after a failed repair" - -# A failure after install has the same cleanup obligation. In particular, the -# EXIT trap must still be armed when mv fails. -write_authfile 644 || fail "the test can stage the mv-failure fixture" -before_inode=$(stat -c %i "$authfile") -if run_migration 0 1 caller caller 644; then - fail "an mv failure propagates out of the migration" -fi - -mapfile -t staged_paths <"$stages" -(( ${#staged_paths[@]} == 4 )) || - fail "the mv-failed repair creates one stage" "got: ${staged_paths[*]}" -failed_mv_stage=${staged_paths[3]} -grep -Fq $'sudo\tmv\t-Tf\t'"$failed_mv_stage"$'\t'"$authfile" "$calls" || - fail "the injected mv failure occurs after install" "$(cat "$calls")" -grep -Fq $'sudo\trm\t-f\t--\t'"$failed_mv_stage" "$calls" || - fail "the EXIT trap removes the mv-failed repair's exact stage" "$(cat "$calls")" -[[ ! -e $failed_mv_stage && ! -L $failed_mv_stage ]] || - fail "the mv-failed stage is cleaned up" "left behind: $failed_mv_stage" -[[ $(stat -c %i "$authfile") == "$before_inode" ]] || - fail "an mv failure leaves the live authfile inode alone" -pass "migration cleans its unique stage after a failed rename" - -# The state a completed repair leaves, which is also where every machine that -# registers after this fix starts. A second account, and a second run for the -# same account, must find it done and escalate nothing. Fake only stat's view of -# the scratch authfile so this stays deterministic without borrowing a host -# file or requiring the suite itself to run as root. -write_authfile 644 || fail "the test can stage the settled-state fixture" -run_migration 0 0 root root 644 -[[ ! -s $calls ]] || - fail "an already root:root mode-644 authfile escalates nothing" "$(cat "$calls")" -pass "migration deterministically no-ops on its settled state" - -# Owner, group and mode are independent parts of that state check. Hold two at -# their settled values while making each third value wrong, and require repair. -write_authfile 644 || fail "the test can stage the wrong-owner fixture" -run_migration 0 0 nobody root 644 -grep -Fq $'sudo\tinstall\t-T\t' "$calls" || - fail "a non-root-owned authfile is repaired even when group and mode are settled" "$(cat "$calls")" -pass "migration repairs an authfile with the wrong owner" - -write_authfile 644 || fail "the test can stage the wrong-group fixture" -run_migration 0 0 root nobody 644 -grep -Fq $'sudo\tinstall\t-T\t' "$calls" || - fail "a non-root-group authfile is repaired even when owner and mode are settled" "$(cat "$calls")" -pass "migration repairs an authfile with the wrong group" - -write_authfile 644 || fail "the test can stage the wrong-mode fixture" -run_migration 0 0 root root 600 -grep -Fq $'sudo\tinstall\t-T\t' "$calls" || - fail "a mode-600 authfile is repaired even when owner and group are settled" "$(cat "$calls")" -pass "migration repairs an authfile with the wrong mode" - -# Neither of these is ours to rewrite, and both must say so without escalating: -# chown follows a symlink and would take the target instead, while changing a -# directory's mode would alter an object the migration does not own. -rm -rf "$authfile" -ln -s "$test_tmp/elsewhere" "$authfile" -: >"$test_tmp/elsewhere" -run_migration -[[ ! -s $calls ]] || fail "a symlinked authfile escalates nothing" "$(cat "$calls")" -[[ -s $notifications ]] || - fail "a symlinked authfile is raised where the update terminal cannot swallow it" - -rm -f "$authfile" -ln -s "$test_tmp/missing" "$authfile" -run_migration -[[ ! -s $calls ]] || fail "a dangling symlink escalates nothing" "$(cat "$calls")" -[[ -s $notifications ]] || fail "a dangling symlink is raised the same way" -pass "migration reports a symlinked authfile and repairs nothing" - -rm -f "$authfile" -mkdir -p "$authfile" -run_migration -[[ ! -s $calls ]] || fail "a directory at the authfile path escalates nothing" "$(cat "$calls")" -[[ -s $notifications ]] || fail "a non-regular authfile is raised the same way" -pass "migration reports a non-regular authfile and repairs nothing" - -# omarchy-migrate writes this migration's completion marker on any zero exit, so -# a machine it cannot repair gets one shot at telling the user. The states above -# are exactly the ones where the authfile may already be under someone else's -# control, and a line in the update terminal scrolls past. -# Assert the argument shape rather than a substring. The glyph is a private-use -# codepoint that an edit can silently drop, and losing it shifts every argument -# left: -g swallows the headline, the body becomes the title, and the message -# goes out with no description. A substring match sees all of that as fine. -awk -F'\t' ' - $1 == "notify" && NF == 7 && $2 == "-u" && $3 == "critical" && $4 == "-g" && - $5 != "" && $6 == "FIDO2 authfile needs attention" && $7 != "" { found = 1 } - END { exit !found } -' "$notifications" || - fail "the notification passes a glyph, headline and body as separate arguments" \ - "$(cat -A "$notifications")" -pass "migration raises its unrepairable states as a desktop notification" - -# The old setup created the FIDO2 directory with `sudo mkdir -p`, which took the -# caller's umask: registering under `umask 077` left it mode 0700 with the -# user-owned authfile still inside. Absence and "cannot look" are the same -# answer to an unprivileged test, so keying the early exit on the authfile -# recorded a repair on exactly the machines that still needed one. -rm -rf "$authfile" -write_authfile 644 || fail "the test can stage the untraversable-directory fixture" -before_inode=$(stat -c %i "$authfile") -chmod 000 "$authdir" -run_migration 0 0 caller caller 644 -[[ $(stat -c %a "$authdir") == "755" ]] || - fail "the migration reopens the directory the old umask closed" "got: $(stat -c %a "$authdir")" -grep -Fxq $'sudo\tchmod\t755\t'"$authdir" "$calls" || - fail "the migration asks root to reopen the FIDO2 directory" "$(cat "$calls")" -grep -Fq $'sudo\tinstall\t-T\t' "$calls" || - fail "an authfile hidden behind an untraversable directory is still repaired" "$(cat "$calls")" -[[ $(stat -c %i "$authfile") != "$before_inode" ]] || - fail "the repair behind an untraversable directory still replaces the inode" -pass "migration repairs an authfile an unreadable directory hid from it" - -# The narrow escalation above must not reach a machine that never registered a -# key, which is almost all of them. -rm -f "$authfile" -rm -rf "$authdir" -run_migration -[[ ! -s $calls ]] || - fail "a machine with no FIDO2 directory still escalates nothing" "$(cat "$calls")" -mkdir -p "$authdir" -run_migration -[[ ! -s $calls ]] || - fail "an empty readable FIDO2 directory escalates nothing" "$(cat "$calls")" -pass "migration still costs no password prompt on a machine that never set FIDO2 up" - -# An aborted setup can leave the directory behind with nothing in it, and an -# administrator may keep one deliberately private. Looking costs a probe, but -# neither may have its mode widened, or its group and special bits discarded, -# for a repair that is not needed. -rm -f "$authfile" -chmod 000 "$authdir" -run_migration -[[ $(stat -c %a "$authdir") == "0" ]] || - fail "an empty inaccessible FIDO2 directory keeps its mode" "got: $(stat -c %a "$authdir")" -! grep -Fq $'sudo\tchmod\t' "$calls" || - fail "an empty inaccessible FIDO2 directory is never reopened" "$(cat "$calls")" -if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then - fail "an empty inaccessible FIDO2 directory is never repaired" "$(cat "$calls")" -fi -chmod 755 "$authdir" -pass "migration looks behind an inaccessible FIDO2 directory without widening it" - -# Notification delivery fails on a machine with no user bus or no notification -# server. That must not abort the migration under `bash -euo pipefail` and take -# every later migration with it. -rm -f "$authfile" -ln -s "$test_tmp/missing" "$authfile" -run_migration 0 0 "" "" "" normal 1 -[[ -s $notifications ]] || - fail "the failing notification was still attempted" "$(cat "$notifications")" -pass "migration survives a notification it could not deliver" -rm -f "$authfile" +grep -qx 'authfile=/etc/fido2/fido2' "$migration" || fail "FIDO2 authfile is not fixed" +grep -Fq '/usr/share/omarchy/migrations/1787494718.sh --machine' "$migration" || fail "FIDO2 migration lacks fixed machine phase" +grep -Fq '/usr/bin/install -T -m 644 -o root -g root "$authfile" "$stage"' "$migration" || fail "FIDO2 repair does not replace ownership atomically" +grep -Fq '/usr/bin/mv -Tf "$stage" "$authfile"' "$migration" || fail "FIDO2 repair does not publish atomically" +pass "FIDO2 repair uses a fixed target and atomic packaged machine phase" diff --git a/test/shell.d/security-source-root-test.sh b/test/shell.d/security-source-root-test.sh new file mode 100755 index 00000000000..bfecae9b452 --- /dev/null +++ b/test/shell.d/security-source-root-test.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +source "$SUDO_TEST_ROOT/bin/omarchy-security-functions" + +rm -f "$SUDO_TEST_ROOT/bin/omarchy-update" +copy_boundary_file bin/omarchy-update +omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" || fail "matching checkout root was rejected" +pass "a canonical checkout matches its own entrypoint" + +mkdir "$boundary_tmp/other-root" +ln -s "$SUDO_TEST_ROOT" "$boundary_tmp/root-link" +for root in "$boundary_tmp/other-root" "$boundary_tmp/root-link" .; do + if OMARCHY_PATH="$root" omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then + fail "a different or noncanonical source root was accepted" + fi +done +pass "different, symlink and relative roots are rejected" + +# Redirect only the two package-layout literals into the fixture. Resolution +# still uses real readlink/realpath; no host /usr/bin file is changed or run. +package_root="$boundary_tmp/usr/share/omarchy" +package_bin="$boundary_tmp/usr/bin" +mkdir -p "$package_root/bin" "$package_bin" +cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/omarchy-update" +cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/different-command" +ln -s "$package_bin/omarchy-update" "$package_root/bin/omarchy-update" +python3 - "$SUDO_TEST_ROOT/bin/omarchy-security-functions" "$boundary_tmp/package-library" "$package_root" "$package_bin" <<'PY' +import sys +from pathlib import Path +source, output, root, binaries = sys.argv[1:] +text = Path(source).read_text() +text = text.replace('"/usr/share/omarchy"', f'"{root}"') +text = text.replace('"/usr/bin/$command_name"', f'"{binaries}/$command_name"') +Path(output).write_text(text) +PY +source "$boundary_tmp/package-library" +OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_bin/omarchy-update" || fail "package binary was rejected" +OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" || fail "package link was rejected" +pass "the package binary and its matching source-tree link are accepted" + +ln -sfn "$package_bin/different-command" "$package_root/bin/omarchy-update" +if OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then + fail "a package link to a different command was accepted" +fi +pass "a package link must resolve to its named command" + +# Run the protected entrypoints themselves with a mismatched root. These must +# stop before any sudo or operational fixture command, not merely validate in +# an isolated library test. +for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set omarchy-migrate omarchy-setup-security-sshd; do + rm -f "$SUDO_TEST_ROOT/bin/$command" + copy_boundary_file "bin/$command" + for root in "$boundary_tmp/other-root" .; do + reset_boundary + if OMARCHY_PATH="$root" "$SUDO_TEST_ROOT/bin/$command" >"$boundary_tmp/output" 2>&1; then + fail "$command accepted a mismatched root" + fi + [[ ! -s $SUDO_TEST_LOG ]] || fail "$command ran work before rejecting its root" + done + pass "$command rejects mismatched and relative roots before work" +done diff --git a/test/shell.d/setup-security-sshd-test.sh b/test/shell.d/setup-security-sshd-test.sh index b060c5ecbc0..18a6587699e 100755 --- a/test/shell.d/setup-security-sshd-test.sh +++ b/test/shell.d/setup-security-sshd-test.sh @@ -1,117 +1,227 @@ #!/bin/bash set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -source "$(dirname "$0")/base-test.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +stub="$tmp/bin" +mkdir "$stub" +test_uid=$(id -u) -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -stub_bin="$test_dir/bin" -mkdir -p "$stub_bin" +cat >"$stub/id" <<'SH' +#!/bin/bash +case ${1:-} in +-u) printf '%s\n' "$TEST_UID" ;; +-Gn) [[ ${2:-} == -- && ${3:-} == "${TEST_ACCOUNT:-audit}" ]] || exit 2; printf '%s\n' "${TEST_GROUPS:-audit sshers}" ;; +*) exit 2 ;; +esac +SH +cat >"$stub/getent" <<'SH' +#!/bin/bash +[[ ${1:-} == passwd && ${2:-} == "$TEST_UID" ]] || exit 2 +printf '%s:x:%s:100:Audit Test:%s:/bin/bash\n' "${TEST_ACCOUNT:-audit}" "$TEST_UID" "$HOME" +SH +cat >"$stub/passwd" <<'SH' +#!/bin/bash +[[ ${1:-} == -S && ${2:-} == -- && ${3:-} == "${TEST_ACCOUNT:-audit}" ]] || exit 2 +printf '%s %s 2026-01-01 -1 -1 -1 -1\n' "${TEST_ACCOUNT:-audit}" "${ACCOUNT_STATUS:-P}" +SH -cat >"$stub_bin/omarchy-pkg-add" <<'STUB' +cat >"$stub/omarchy-pkg-add" <<'SH' #!/bin/bash -printf 'pkg %s\n' "$*" >>"${CALL_LOG:?}" -STUB -cat >"$stub_bin/omarchy-cmd-missing" <<'STUB' +echo "package $*" >>"$EVENTS" +[[ ${PACKAGE_FAIL:-0} != 1 ]] +SH +cat >"$stub/omarchy-cmd-missing" <<'SH' #!/bin/bash -exit 0 -STUB -cat >"$stub_bin/systemctl" <<'STUB' +[[ ${UFW_MISSING:-0} == 1 ]] +SH +cat >"$stub/curl" <<'SH' #!/bin/bash -printf 'systemctl %s\n' "$*" >>"${CALL_LOG:?}" -STUB -cat >"$stub_bin/sshd" <<'STUB' +echo github-fetch >>"$EVENTS" +[[ ${GH_FAIL:-0} != 1 ]] || exit 1 +printf %s "${GH_KEYS:-}" +SH +cat >"$stub/gum" <<'SH' #!/bin/bash -case $1 in --t) - [[ ${SSHD_SYNTAX_VALID:-1} == 1 ]] - ;; --T) - # OpenSSH 10.x dumps keywords in CamelCase; 9.x dumped them lowercase. - if [[ ${SSHD_DUMP_LOWERCASE:-0} == 1 ]]; then - printf 'passwordauthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" - printf 'kbdinteractiveauthentication %s\n' "${SSHD_KBD_AUTH:-no}" - else - printf 'PasswordAuthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" - printf 'KbdInteractiveAuthentication %s\n' "${SSHD_KBD_AUTH:-no}" - fi - ;; -*) - exit 2 - ;; -esac -STUB -cat >"$stub_bin/sudo" <<'STUB' +case $1 in choose) printf '%s\n' "${GUM_CHOICE:-}" ;; input) [[ ${GUM_CANCEL:-0} != 1 ]] && printf '%s\n' "${GUM_INPUT:-}" ;; esac +SH +cat >"$stub/mv" <<'SH' +#!/bin/bash +[[ ${*: -1} != */authorized_keys ]] || echo authorized-key >>"$EVENTS" +exec /usr/bin/mv "$@" +SH + +cat >"$stub/sudo" <<'SH' #!/bin/bash +set -euo pipefail +echo "sudo $*" >>"$EVENTS" +[[ ${1:-} != -k ]] || exit 0 +map() { [[ $1 == /etc/* ]] && printf '%s%s' "$FAKE_ROOT" "$1" || printf %s "$1"; } case $1 in -install) - destination="${TEST_ROOT:?}${4:?}" - /usr/bin/mkdir -p "${destination%/*}" - /usr/bin/install -Dm644 /dev/stdin "$destination" - ;; -rm) - /usr/bin/rm -f "${TEST_ROOT:?}${3:?}" - ;; -*) - exec "$@" - ;; +systemctl) + a=$2 + case $a in + is-active) [[ ${ACTIVE_QUERY_ERROR:-0} != 1 ]] || exit 2; [[ -e $STATE/active ]] && { echo active; exit 0; } || { echo inactive; exit 3; } ;; + is-enabled) [[ ${ENABLED_QUERY_ERROR:-0} != 1 ]] || exit 2; [[ -e $STATE/enabled ]] && { echo enabled; exit 0; } || { echo disabled; exit 1; } ;; + start) [[ ${START_PARTIAL:-0} != 1 ]] || { touch "$STATE/active"; exit 1; }; [[ ${START_FAIL:-0} != 1 ]] || exit 1; touch "$STATE/active" ;; + enable) [[ ${ENABLE_PARTIAL:-0} != 1 ]] || { touch "$STATE/enabled"; exit 1; }; [[ ${ENABLE_FAIL:-0} != 1 ]] || exit 1; touch "$STATE/enabled" ;; + stop) [[ ${STOP_FAIL:-0} != 1 ]] || exit 1; rm -f "$STATE/active" ;; + disable) [[ ${DISABLE_FAIL:-0} != 1 ]] || exit 1; rm -f "$STATE/enabled" ;; + reload) n=0; [[ ! -e $STATE/reloads ]] || read -r n <"$STATE/reloads"; n=$((n+1)); echo "$n" >"$STATE/reloads"; [[ ${RELOAD_ALWAYS_FAIL:-0} != 1 && (${RELOAD_ONCE:-0} != 1 || $n != 1) ]] ;; + esac ;; +ufw) + shift + if [[ $1 == show ]]; then [[ -e $STATE/rule && ${VERIFY_MISS:-0} != 1 ]] && echo "ufw limit 22/tcp comment 'omarchy-sshd'" + elif [[ $1 == limit ]]; then [[ ${LIMIT_PARTIAL:-0} != 1 ]] || { touch "$STATE/rule"; exit 1; }; [[ ${LIMIT_FAIL:-0} != 1 ]] || exit 1; touch "$STATE/rule" + elif [[ $1 == --force ]]; then [[ ${DELETE_FAIL:-0} != 1 ]] || exit 1; rm -f "$STATE/rule" + elif [[ $1 == reload ]]; then n=0; [[ ! -e $STATE/ufw-reloads ]] || read -r n <"$STATE/ufw-reloads"; n=$((n+1)); echo "$n" >"$STATE/ufw-reloads"; [[ ${UFW_RELOAD_ALWAYS_FAIL:-0} != 1 && (${UFW_RELOAD_ONCE:-0} != 1 || $n != 1) ]] + fi ;; +test) p=$(map "$3"); case $2 in -e) [[ -e $p ]] ;; -L) [[ -L $p ]] ;; -f) [[ -f $p ]] ;; esac ;; +mktemp) p=$(map "$2"); mkdir -p "${p%/*}"; /usr/bin/mktemp "$p" ;; +cp) s=$(map "${*: -2:1}"); d=$(map "${*: -1}"); /usr/bin/cp -a "$s" "$d" ;; +install) s=$(map "${*: -2:1}"); d=$(map "${*: -1}"); mkdir -p "${d%/*}"; /usr/bin/install -m0644 "$s" "$d"; echo installed-hardening >>"$EVENTS" ;; +/usr/bin/awk) x=("$@"); x[-1]=$(map "${x[-1]}"); exec "${x[@]}" ;; +/usr/bin/find) x=("$@"); x[1]=$(map "${x[1]}"); exec "${x[@]}" ;; +ssh-keygen) echo host-keygen >>"$EVENTS"; [[ ${HOSTKEY_FAIL:-0} != 1 ]] || exit 1; touch "$FAKE_ROOT/etc/ssh/ssh_host_key" ;; +sshd) + if [[ $2 == -t ]]; then echo sshd-t >>"$EVENTS"; [[ ${T_FAIL:-0} != 1 ]] + else echo sshd-T >>"$EVENTS"; [[ ${DUMP_FAIL:-0} != 1 ]] || exit 1; if [[ " $* " == *' -C '* ]]; then echo "PasswordAuthentication ${MATCH_PASS_AUTH:-${PASS_AUTH:-no}}"; echo "KbdInteractiveAuthentication ${MATCH_KBD_AUTH:-${KBD_AUTH:-no}}"; echo "AuthenticationMethods ${MATCH_AUTH_METHODS:-${AUTH_METHODS:-publickey}}"; echo "PubkeyAuthentication ${MATCH_PUBKEY_AUTH:-${PUBKEY_AUTH:-yes}}"; echo "AuthorizedKeysFile ${MATCH_KEYS_SETTING:-${AUTHORIZED_KEYS_SETTING:-.ssh/authorized_keys}}"; [[ -z ${ALLOW_USERS:-} ]] || echo "AllowUsers $ALLOW_USERS"; [[ -z ${DENY_USERS:-} ]] || echo "DenyUsers $DENY_USERS"; [[ -z ${ALLOW_GROUPS:-} ]] || echo "AllowGroups $ALLOW_GROUPS"; [[ -z ${DENY_GROUPS:-} ]] || echo "DenyGroups $DENY_GROUPS"; else echo "PasswordAuthentication ${PASS_AUTH:-no}"; echo "KbdInteractiveAuthentication ${KBD_AUTH:-no}"; echo "AuthenticationMethods ${AUTH_METHODS:-publickey}"; echo "PubkeyAuthentication ${PUBKEY_AUTH:-yes}"; echo "AuthorizedKeysFile ${AUTHORIZED_KEYS_SETTING:-.ssh/authorized_keys}"; fi; fi ;; +mv) s=$(map "${*: -2:1}"); d=$(map "${*: -1}"); /usr/bin/mv -fT "$s" "$d" ;; +rm) [[ ${CONFIG_RM_FAIL:-0} != 1 ]] || exit 1; /usr/bin/rm -f "$(map "${*: -1}")" ;; +*) exec "$@" ;; esac -STUB -chmod +x "$stub_bin"/* - -ssh-keygen -q -t ed25519 -N "" -f "$test_dir/key" -public_key=$(<"$test_dir/key.pub") - -run_setup() { - local scenario="$1" - local home="$test_dir/$scenario/home" - local root="$test_dir/$scenario/root" - - mkdir -p "$home" "$root" - : >"$test_dir/$scenario.calls" - - HOME="$home" TEST_ROOT="$root" CALL_LOG="$test_dir/$scenario.calls" \ - SSHD_SYNTAX_VALID="${SSHD_SYNTAX_VALID:-1}" \ - SSHD_PASSWORD_AUTH="${SSHD_PASSWORD_AUTH:-no}" \ - SSHD_KBD_AUTH="${SSHD_KBD_AUTH:-no}" \ - PATH="$stub_bin:$PATH" \ - bash "$ROOT/bin/omarchy-setup-security-sshd" --key="$public_key" +SH +chmod +x "$stub"/* + +mapped_root="$tmp/omarchy" +mkdir -p "$mapped_root/bin" +sed "s#/usr/bin/sudo#$stub/sudo#g" "$ROOT/bin/omarchy-security-functions" >"$mapped_root/bin/omarchy-security-functions" +mapped_sshd="$mapped_root/bin/omarchy-setup-security-sshd" +sed \ + -e "s#/usr/bin/getent#$stub/getent#g" \ + -e "s#/usr/bin/id#$stub/id#g" \ + -e "s#/usr/bin/passwd#$stub/passwd#g" \ + -e "s#/usr/bin/sudo#$stub/sudo#g" \ + -e "s#/usr/bin/omarchy-pkg-add#$stub/omarchy-pkg-add#g" \ + -e "s#/usr/bin/omarchy-cmd-missing#$stub/omarchy-cmd-missing#g" \ + -e "s#/usr/bin/curl#$stub/curl#g" \ + -e "s#/usr/bin/gum#$stub/gum#g" \ + -e "s#/usr/bin/mv#$stub/mv#g" \ + "$ROOT/bin/omarchy-setup-security-sshd" >"$mapped_sshd" +chmod 0755 "$mapped_root/bin/"* + +ssh-keygen -q -t ed25519 -N '' -f "$tmp/key" +key=$(<"$tmp/key.pub") + +run() { + local name=$1; shift; local d="$tmp/$name" + mkdir -p "$d/home" "$d/root/etc/ssh/sshd_config.d" "$d/state" + [[ -e $d/root/etc/ssh/sshd_config ]] || echo 'Include /etc/ssh/sshd_config.d/*.conf' >"$d/root/etc/ssh/sshd_config" + : >"$d/events" + [[ ${PRE_ACTIVE:-0} != 1 ]] || touch "$d/state/active" + [[ ${PRE_ENABLED:-0} != 1 ]] || touch "$d/state/enabled" + [[ ${PRE_RULE:-0} != 1 ]] || touch "$d/state/rule" + env HOME="$d/home" PATH="$stub:/usr/bin" OMARCHY_PATH="$mapped_root" FAKE_ROOT="$d/root" STATE="$d/state" EVENTS="$d/events" USER=audit TEST_UID="$test_uid" \ + TEST_ACCOUNT="${TEST_ACCOUNT:-audit}" TEST_GROUPS="${TEST_GROUPS:-audit sshers}" ACCOUNT_STATUS="${ACCOUNT_STATUS:-P}" ACTIVE_QUERY_ERROR="${ACTIVE_QUERY_ERROR:-0}" ENABLED_QUERY_ERROR="${ENABLED_QUERY_ERROR:-0}" \ + PACKAGE_FAIL="${PACKAGE_FAIL:-0}" GH_FAIL="${GH_FAIL:-0}" GH_KEYS="${GH_KEYS:-}" GUM_CHOICE="${GUM_CHOICE:-}" GUM_INPUT="${GUM_INPUT:-}" GUM_CANCEL="${GUM_CANCEL:-0}" \ + START_FAIL="${START_FAIL:-0}" START_PARTIAL="${START_PARTIAL:-0}" ENABLE_FAIL="${ENABLE_FAIL:-0}" ENABLE_PARTIAL="${ENABLE_PARTIAL:-0}" RELOAD_ONCE="${RELOAD_ONCE:-0}" RELOAD_ALWAYS_FAIL="${RELOAD_ALWAYS_FAIL:-0}" \ + HOSTKEY_FAIL="${HOSTKEY_FAIL:-0}" T_FAIL="${T_FAIL:-0}" DUMP_FAIL="${DUMP_FAIL:-0}" PASS_AUTH="${PASS_AUTH:-no}" KBD_AUTH="${KBD_AUTH:-no}" AUTH_METHODS="${AUTH_METHODS:-publickey}" PUBKEY_AUTH="${PUBKEY_AUTH:-yes}" AUTHORIZED_KEYS_SETTING="${AUTHORIZED_KEYS_SETTING:-.ssh/authorized_keys}" \ + MATCH_PASS_AUTH="${MATCH_PASS_AUTH:-}" MATCH_KBD_AUTH="${MATCH_KBD_AUTH:-}" MATCH_AUTH_METHODS="${MATCH_AUTH_METHODS:-}" MATCH_PUBKEY_AUTH="${MATCH_PUBKEY_AUTH:-}" MATCH_KEYS_SETTING="${MATCH_KEYS_SETTING:-}" \ + ALLOW_USERS="${ALLOW_USERS:-}" DENY_USERS="${DENY_USERS:-}" ALLOW_GROUPS="${ALLOW_GROUPS:-}" DENY_GROUPS="${DENY_GROUPS:-}" \ + LIMIT_FAIL="${LIMIT_FAIL:-0}" LIMIT_PARTIAL="${LIMIT_PARTIAL:-0}" VERIFY_MISS="${VERIFY_MISS:-0}" UFW_RELOAD_ONCE="${UFW_RELOAD_ONCE:-0}" UFW_RELOAD_ALWAYS_FAIL="${UFW_RELOAD_ALWAYS_FAIL:-0}" DELETE_FAIL="${DELETE_FAIL:-0}" CONFIG_RM_FAIL="${CONFIG_RM_FAIL:-0}" \ + "$mapped_sshd" "$@" } +no_publish() { ! grep -Eq 'sudo systemctl (start|enable|reload)|sudo ufw limit' "$tmp/$1/events" || fail "$1 published SSH" "$(cat "$tmp/$1/events")"; } +rolled_back() { [[ ! -e $tmp/$1/state/active && ! -e $tmp/$1/state/enabled && ! -e $tmp/$1/state/rule && ! -e $tmp/$1/home/.ssh/authorized_keys ]] || fail "$1 did not roll back"; } + +for c in help unknown gh both; do case $c in help) a=(--help); want=0;; unknown) a=(--bad); want=2;; gh) a=(--gh-keys); want=2;; both) a=("--key=$key" --gh-keys x); want=2;; esac; if run "arg-$c" "${a[@]}" >/dev/null 2>&1; then s=0; else s=$?; fi; [[ $s == $want && ! -s $tmp/arg-$c/events ]] || fail "argument $c mutated"; done +pass "SSH arguments and help are mutation-free" + +for c in package gh-fail gh-empty gh-invalid prompt-cancel prompt-invalid home-symlink home-writable auth-symlink auth-dir; do + a=("--key=$key") + case $c in package) PACKAGE_FAIL=1;; gh-fail) GH_FAIL=1; a=(--gh-keys x);; gh-empty) GH_KEYS=''; a=(--gh-keys x);; gh-invalid) GH_KEYS=bad; a=(--gh-keys x);; prompt-cancel) GUM_CHOICE='Paste key manually'; GUM_CANCEL=1; a=();; prompt-invalid) GUM_CHOICE='Paste key manually'; GUM_INPUT=bad; a=();; home-symlink) mkdir -p "$tmp/$c/real-home"; ln -s "$tmp/$c/real-home" "$tmp/$c/home";; home-writable) mkdir -p "$tmp/$c/home"; chmod 0777 "$tmp/$c/home";; auth-symlink) mkdir -p "$tmp/$c/home/.ssh"; ln -s "$tmp/victim" "$tmp/$c/home/.ssh/authorized_keys";; auth-dir) mkdir -p "$tmp/$c/home/.ssh/authorized_keys";; esac + if run "$c" "${a[@]}" >/dev/null 2>&1; then fail "$c succeeds"; fi; no_publish "$c"; unset PACKAGE_FAIL GH_FAIL GH_KEYS GUM_CHOICE GUM_CANCEL GUM_INPUT +done +GH_KEYS="bad +$key"; run gh-mixed --gh-keys x >/dev/null; grep -qxF "$key" "$tmp/gh-mixed/home/.ssh/authorized_keys"; unset GH_KEYS +pass "key acquisition and authorization fail before publication" + +run fresh "--key=$key" >/dev/null +[[ $(head -n1 "$tmp/fresh/events") == 'sudo -k' && $(tail -n1 "$tmp/fresh/events") == 'sudo -k' ]] || + fail "SSH setup does not begin and end with credential invalidation" "$(cat "$tmp/fresh/events")" +prev=0 +for e in authorized-key installed-hardening host-keygen sshd-t sshd-T 'sudo systemctl start' 'sudo systemctl enable' 'sudo ufw limit'; do n=$(grep -nF "$e" "$tmp/fresh/events"|head -1|cut -d: -f1); [[ -n $n && $prev -lt $n ]] || fail "unsafe fresh order at $e"; prev=$n; done +grep -qxF 'AuthenticationMethods publickey' "$tmp/fresh/root/etc/ssh/sshd_config.d/00-omarchy-key-only.conf" +pass "fresh SSH is key-authorized and validated before publication" + +for c in hostkey syntax dump pass kbd methods pubkey keysfile matched; do case $c in hostkey) HOSTKEY_FAIL=1;; syntax) T_FAIL=1;; dump) DUMP_FAIL=1;; pass) PASS_AUTH=yes;; kbd) KBD_AUTH=yes;; methods) AUTH_METHODS=any;; pubkey) PUBKEY_AUTH=no;; keysfile) AUTHORIZED_KEYS_SETTING=/etc/ssh/admin_keys;; matched) MATCH_PASS_AUTH=yes;; esac; if run "$c" "--key=$key" >/dev/null 2>&1; then fail "$c succeeds"; fi; no_publish "$c"; rolled_back "$c"; unset HOSTKEY_FAIL T_FAIL DUMP_FAIL PASS_AUTH KBD_AUTH AUTH_METHODS PUBKEY_AUTH AUTHORIZED_KEYS_SETTING MATCH_PASS_AUTH; done +pass "host-key, syntax, and effective-policy failures are pre-publication" + +for c in allow-user deny-user allow-group deny-group locked complex-rule; do + case $c in + allow-user) ALLOW_USERS=someone;; deny-user) DENY_USERS=audit;; allow-group) ALLOW_GROUPS=admins;; deny-group) DENY_GROUPS=sshers;; locked) ACCOUNT_STATUS=L;; complex-rule) ALLOW_USERS='aud*';; + esac + if run "admission-$c" "--key=$key" >/dev/null 2>&1; then fail "$c admission restriction succeeds"; fi + no_publish "admission-$c"; rolled_back "admission-$c" + unset ALLOW_USERS DENY_USERS ALLOW_GROUPS DENY_GROUPS ACCOUNT_STATUS +done +ALLOW_USERS=audit DENY_USERS=someone ALLOW_GROUPS=sshers DENY_GROUPS=admins run admission-ok "--key=$key" >/dev/null +TEST_ACCOUNT='machine$' TEST_GROUPS='machine$ sshers' ALLOW_USERS='machine$' run dollar-account "--key=$key" >/dev/null +unset ALLOW_USERS DENY_USERS ALLOW_GROUPS DENY_GROUPS TEST_ACCOUNT TEST_GROUPS +pass "account admission controls and status are tied to the newly keyed account" + +for query in active enabled; do + PRE_ACTIVE=1 PRE_ENABLED=1 + if [[ $query == active ]]; then ACTIVE_QUERY_ERROR=1; else ENABLED_QUERY_ERROR=1; fi + if run "query-$query" "--key=$key" >/dev/null 2>&1; then fail "$query query error succeeds"; fi + [[ -e $tmp/query-$query/state/active && -e $tmp/query-$query/state/enabled && ! -e $tmp/query-$query/home/.ssh/authorized_keys ]] || + fail "$query query error changed pre-existing service state or retained the new key" + no_publish "query-$query" + unset PRE_ACTIVE PRE_ENABLED ACTIVE_QUERY_ERROR ENABLED_QUERY_ERROR +done +pass "service state query errors abort and roll back without changing existing state" + +mkdir -p "$tmp/precedence/root/etc/ssh/sshd_config.d" +printf 'PasswordAuthentication yes\nInclude /etc/ssh/sshd_config.d/*.conf\n' >"$tmp/precedence/root/etc/ssh/sshd_config" +if run precedence "--key=$key" >/dev/null 2>&1; then fail "auth before drop-in include succeeds"; fi +no_publish precedence +mkdir -p "$tmp/earlier/root/etc/ssh/sshd_config.d"; echo '# admin' >"$tmp/earlier/root/etc/ssh/sshd_config.d/-admin.conf" +if run earlier "--key=$key" >/dev/null 2>&1; then fail "earlier expanded drop-in succeeds"; fi +no_publish earlier +for kind in symlink directory; do p="$tmp/hard-$kind/root/etc/ssh/sshd_config.d/00-omarchy-key-only.conf"; mkdir -p "${p%/*}"; if [[ $kind == symlink ]]; then ln -s "$tmp/victim" "$p"; else mkdir "$p"; fi; if run "hard-$kind" "--key=$key" >/dev/null 2>&1; then fail "$kind hardening path succeeds"; fi; [[ $kind != symlink || -L $p ]] && [[ $kind != directory || -d $p ]] || fail "$kind hardening path changed"; no_publish "hard-$kind"; done +pass "ambiguous include precedence and nonregular hardening paths fail closed" + +for c in start start-partial enable enable-partial limit limit-partial verify reload; do case $c in start) START_FAIL=1;; start-partial) START_PARTIAL=1;; enable) ENABLE_FAIL=1;; enable-partial) ENABLE_PARTIAL=1;; limit) LIMIT_FAIL=1;; limit-partial) LIMIT_PARTIAL=1;; verify) VERIFY_MISS=1;; reload) UFW_RELOAD_ONCE=1;; esac; if run "$c" "--key=$key" >/dev/null 2>&1; then fail "$c succeeds"; fi; rolled_back "$c"; unset START_FAIL START_PARTIAL ENABLE_FAIL ENABLE_PARTIAL LIMIT_FAIL LIMIT_PARTIAL VERIFY_MISS UFW_RELOAD_ONCE; done +pass "partial service/firewall publication rolls back fresh state" + +name=active-fail; cfg="$tmp/$name/root/etc/ssh/sshd_config.d/00-omarchy-key-only.conf"; auth="$tmp/$name/home/.ssh/authorized_keys"; mkdir -p "${cfg%/*}" "${auth%/*}"; echo ADMIN >"$cfg"; chmod 0600 "$cfg"; printf '# existing\n%s\n' "$key" >"$auth"; chmod 0640 "$auth"; before=$(stat -c '%u:%g:%a' "$cfg"):$(sha256sum "$cfg"); auth_before=$(stat -c '%u:%g:%a' "$auth"):$(sha256sum "$auth"); PRE_ACTIVE=1 RELOAD_ONCE=1; if run "$name" "--key=$key" >/dev/null 2>&1; then fail "active reload failure succeeds"; fi; after=$(stat -c '%u:%g:%a' "$cfg"):$(sha256sum "$cfg"); auth_after=$(stat -c '%u:%g:%a' "$auth"):$(sha256sum "$auth"); [[ $before == "$after" && $auth_before == "$auth_after" && $(grep -c 'systemctl reload' "$tmp/$name/events") == 2 ]] || fail "active config/authorized_keys was not exactly restored/reloaded"; unset PRE_ACTIVE RELOAD_ONCE +name=matched-restore; auth="$tmp/$name/home/.ssh/authorized_keys"; mkdir -p "${auth%/*}"; printf '# preserve\n%s\n' "$key" >"$auth"; chmod 0640 "$auth"; auth_before=$(stat -c '%u:%g:%a' "$auth"):$(sha256sum "$auth"); MATCH_PASS_AUTH=yes; if run "$name" "--key=$key" >/dev/null 2>&1; then fail "unsafe matched dump succeeds"; fi; auth_after=$(stat -c '%u:%g:%a' "$auth"):$(sha256sum "$auth"); [[ $auth_before == "$auth_after" ]] || fail "matched-policy failure did not restore authorized_keys exactly"; no_publish "$name"; unset MATCH_PASS_AUTH +PRE_ACTIVE=1 PRE_ENABLED=1 PRE_RULE=1; run existing "--key=$key" >/dev/null; [[ -e $tmp/existing/state/active && -e $tmp/existing/state/enabled && -e $tmp/existing/state/rule ]]; ! grep -Eq 'systemctl (start|enable)|ufw limit' "$tmp/existing/events"; unset PRE_ACTIVE PRE_ENABLED PRE_RULE +pass "pre-existing service/firewall/config state is preserved" + +LIMIT_PARTIAL=1 DELETE_FAIL=1 UFW_RELOAD_ALWAYS_FAIL=1; if run rollback-fail "--key=$key" >"$tmp/rollback.out" 2>&1; then fail "incomplete rollback succeeds"; fi; grep -q 'CRITICAL: SSH setup rollback was incomplete' "$tmp/rollback.out" || fail "rollback failure is silent"; unset LIMIT_PARTIAL DELETE_FAIL UFW_RELOAD_ALWAYS_FAIL +pass "rollback failures are loud" -output=$(run_setup success) -config="$test_dir/success/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" -grep -qxF "PasswordAuthentication no" "$config" || fail "SSH setup disables password authentication" -grep -qxF "KbdInteractiveAuthentication no" "$config" || fail "SSH setup disables keyboard-interactive authentication" -grep -qxF "systemctl reload sshd.service" "$test_dir/success.calls" || fail "SSH setup reloads the validated config" -grep -q "Password logins are off" <<<"$output" || fail "SSH setup reports hardening after it succeeds" -pass "SSH setup authorizes a key and disables password logins" - -output=$(SSHD_DUMP_LOWERCASE=1 run_setup success-legacy) -config="$test_dir/success-legacy/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" -[[ -e $config ]] || fail "SSH setup accepts the lowercase sshd -T dump of OpenSSH 9.x" -grep -q "Password logins are off" <<<"$output" || fail "SSH setup reports hardening on OpenSSH 9.x" -pass "SSH setup verifies settings across sshd -T keyword casings" - -if SSHD_PASSWORD_AUTH=yes run_setup ineffective >"$test_dir/ineffective.output" 2>&1; then - fail "SSH setup must fail when password authentication remains effective" -fi -[[ ! -e $test_dir/ineffective/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || - fail "SSH setup removes an ineffective hardening config" -! grep -qF "systemctl reload sshd.service" "$test_dir/ineffective.calls" || - fail "SSH setup must not reload ineffective hardening" -! grep -q "Password logins are off" "$test_dir/ineffective.output" || - fail "SSH setup must not claim ineffective hardening succeeded" -pass "SSH setup verifies the effective daemon settings" - -if SSHD_SYNTAX_VALID=0 run_setup invalid >"$test_dir/invalid.output" 2>&1; then - fail "SSH setup must fail when sshd rejects its config" -fi -[[ ! -e $test_dir/invalid/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || - fail "SSH setup removes a rejected hardening config" -! grep -qF "systemctl reload sshd.service" "$test_dir/invalid.calls" || - fail "SSH setup must not reload a rejected config" -! grep -q "Password logins are off" "$test_dir/invalid.output" || - fail "SSH setup must not claim rejected hardening succeeded" -pass "SSH setup fails safely when sshd rejects the config" +if command -v sshd >/dev/null; then cat >"$tmp/real.conf" <"$b/systemctl" <<'SH' +#!/bin/bash +echo "systemctl $*" >>"$EVENTS" +case $1 in +is-active) [[ ${ACTIVE_QUERY_ERROR:-0} != 1 ]] || exit 2; [[ -e $STATE/active ]] && { echo active; exit 0; } || { echo inactive; exit 3; };; +is-enabled) [[ ${ENABLED_QUERY_ERROR:-0} != 1 ]] || exit 2; [[ -e $STATE/enabled ]] && { echo enabled; exit 0; } || { echo disabled; exit 1; };; +reload) if [[ ${SLOW_RELOAD:-0} == 1 ]]; then mkdir "$STATE/held" 2>/dev/null || touch "$STATE/overlap"; sleep .15; rmdir "$STATE/held" 2>/dev/null || true; fi; [[ ${RELOAD_FAIL:-0} != 1 ]];; +disable) rm -f "$STATE/active" "$STATE/enabled";; esac +SH +cat >"$b/passwd" <<'SH' +#!/bin/bash +[[ $1 == -S && $2 == -- ]] || exit 2 +[[ ${PASSWD_QUERY_ERROR:-0} != 1 ]] || exit 2 +status=P; [[ ${LOCKED_USER:-} != "$3" ]] || status=L +printf '%s %s 2026-01-01 -1 -1 -1 -1\n' "$3" "$status" +SH +cat >"$b/id" <<'SH' +#!/bin/bash +[[ $1 == -Gn && $2 == -- ]] || exit 2 +[[ ${GROUP_QUERY_ERROR:-0} != 1 ]] || exit 2 +case $3 in keyed) echo 'keyed sshers';; later) echo 'later users';; *\$) echo "$3 sshers";; *) exit 1;; esac +SH +cat >"$b/ssh-keygen" <<'SH' +#!/bin/bash +[[ ${1:-} != -A ]] || { echo hostkeys >>"$EVENTS"; exit "${HOSTKEY_FAIL:-0}"; } +exec /usr/bin/ssh-keygen "$@" +SH +cat >"$b/sshd" <<'SH' +#!/bin/bash +[[ ${1:-} != -t ]] || exit "${T_FAIL:-0}" +user=; for arg in "$@"; do [[ $arg != user=* ]] || { user=${arg#user=}; user=${user%%,*}; }; done +password=no; [[ -z ${MATCH_BAD_USER:-} || $user != "$MATCH_BAD_USER" ]] || password=yes +echo "PasswordAuthentication $password"; echo 'KbdInteractiveAuthentication no'; echo 'AuthenticationMethods publickey'; echo 'PubkeyAuthentication yes'; echo 'AuthorizedKeysFile .ssh/authorized_keys' +[[ -z ${ALLOW_USERS:-} ]] || echo "AllowUsers $ALLOW_USERS" +[[ -z ${DENY_USERS:-} ]] || echo "DenyUsers $DENY_USERS" +[[ -z ${ALLOW_GROUPS:-} ]] || echo "AllowGroups $ALLOW_GROUPS" +[[ -z ${DENY_GROUPS:-} ]] || echo "DenyGroups $DENY_GROUPS" +SH +chmod 0755 "$b"/*; cp "$repo/bin/omarchy-security-functions" "$mapped/bin/" +sed -e "s#legacy_config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf#legacy_config=\$TEST_ROOT/etc/ssh/sshd_config.d/10-omarchy-hardening.conf#" \ + -e "s#key_only_config=/etc/ssh/sshd_config.d/00-omarchy-key-only.conf#key_only_config=\$TEST_ROOT/etc/ssh/sshd_config.d/00-omarchy-key-only.conf#" \ + -e "s#main_config=/etc/ssh/sshd_config#main_config=\$TEST_ROOT/etc/ssh/sshd_config#" -e "s#dropin_dir=/etc/ssh/sshd_config.d#dropin_dir=\$TEST_ROOT/etc/ssh/sshd_config.d#" \ + -e "s#passwd_file=/etc/passwd#passwd_file=\$TEST_ROOT/etc/passwd#" -e "s#login_defs=/etc/login.defs#login_defs=\$TEST_ROOT/etc/login.defs#" \ + -e "s#machine_lock=/run/omarchy-sshd-key-only-migration.lock#machine_lock=$t/run/lock#" \ + -e "s#-- /run#-- $t/run#g" -e "s#-L /run#-L $t/run#g" -e "s#== /run#== $t/run#g" \ + -e "s#/usr/bin/systemctl#$b/systemctl#g" -e "s#/usr/bin/ssh-keygen#$b/ssh-keygen#g" -e "s#/usr/bin/sshd#$b/sshd#g" \ + -e "s#/usr/bin/passwd#$b/passwd#g" -e "s#/usr/bin/id#$b/id#g" \ + "$repo/bin/omarchy-migrate-sshd-key-only" >"$mapped/bin/omarchy-migrate-sshd-key-only"; chmod 0755 "$mapped/bin/"* +/usr/bin/ssh-keygen -q -t ed25519 -N '' -f "$t/key"; key=$(<"$t/key.pub") +prepare() { local d="$t/$1"; mkdir -p "$d/root/etc/ssh/sshd_config.d" "$d/root/home/keyed/.ssh" "$d/root/home/later" "$d/state"; chmod 700 "$d/root/home/"{keyed,keyed/.ssh,later}; printf '%s\n' "$key" >"$d/root/home/keyed/.ssh/authorized_keys"; chmod 600 "$d/root/home/keyed/.ssh/authorized_keys"; cat >"$d/root/etc/passwd" <"$d/root/etc/login.defs"; echo 'Include /etc/ssh/sshd_config.d/*.conf' >"$d/root/etc/ssh/sshd_config"; printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' >"$d/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"; : >"$d/events"; } +run() { TEST_ROOT="$t/$1/root" STATE="$t/$1/state" EVENTS="$t/$1/events" MATCH_BAD_USER="${MATCH_BAD_USER:-}" ALLOW_USERS="${ALLOW_USERS:-}" DENY_USERS="${DENY_USERS:-}" ALLOW_GROUPS="${ALLOW_GROUPS:-}" DENY_GROUPS="${DENY_GROUPS:-}" LOCKED_USER="${LOCKED_USER:-}" PASSWD_QUERY_ERROR="${PASSWD_QUERY_ERROR:-0}" GROUP_QUERY_ERROR="${GROUP_QUERY_ERROR:-0}" ACTIVE_QUERY_ERROR="${ACTIVE_QUERY_ERROR:-0}" ENABLED_QUERY_ERROR="${ENABLED_QUERY_ERROR:-0}" SLOW_RELOAD="${SLOW_RELOAD:-0}" RELOAD_FAIL="${RELOAD_FAIL:-0}" HOSTKEY_FAIL="${HOSTKEY_FAIL:-0}" T_FAIL="${T_FAIL:-0}" "$mapped/bin/omarchy-migrate-sshd-key-only"; } +prepare shared; touch "$t/shared/state/"{active,enabled}; run shared; run shared; [[ -e $t/shared/state/active ]]; ! grep -q 'systemctl disable' "$t/shared/events" +prepare no-key; rm "$t/no-key/root/home/keyed/.ssh/authorized_keys"; touch "$t/no-key/state/"{active,enabled}; run no-key; [[ ! -e $t/no-key/state/active ]] +prepare matched; touch "$t/matched/state/"{active,enabled}; MATCH_BAD_USER=later run matched; [[ ! -e $t/matched/state/active ]] +for rule in allow-user deny-user allow-group deny-group locked; do + prepare "$rule"; touch "$t/$rule/state/"{active,enabled} + case $rule in allow-user) ALLOW_USERS=later;; deny-user) DENY_USERS=keyed;; allow-group) ALLOW_GROUPS=users;; deny-group) DENY_GROUPS=sshers;; locked) LOCKED_USER=keyed;; esac + run "$rule"; [[ ! -e $t/$rule/state/active ]] || exit 1 + unset ALLOW_USERS DENY_USERS ALLOW_GROUPS DENY_GROUPS LOCKED_USER +done +prepare admitted; touch "$t/admitted/state/"{active,enabled}; ALLOW_USERS=keyed ALLOW_GROUPS=sshers DENY_USERS=later DENY_GROUPS=users run admitted; [[ -e $t/admitted/state/active ]] +for error in passwd groups; do prepare "admission-error-$error"; touch "$t/admission-error-$error/state/"{active,enabled}; if [[ $error == passwd ]]; then PASSWD_QUERY_ERROR=1; else GROUP_QUERY_ERROR=1; fi; run "admission-error-$error"; [[ ! -e $t/admission-error-$error/state/active ]]; unset PASSWD_QUERY_ERROR GROUP_QUERY_ERROR; done +prepare query-error; touch "$t/query-error/state/"{active,enabled}; ACTIVE_QUERY_ERROR=1; if run query-error; then exit 1; fi; unset ACTIVE_QUERY_ERROR; [[ -e $t/query-error/state/active && -e $t/query-error/state/enabled && ! -e $t/query-error/root/etc/ssh/sshd_config.d/00-omarchy-key-only.conf && -e $t/query-error/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] +prepare unsafe-query-error; rm "$t/unsafe-query-error/root/home/keyed/.ssh/authorized_keys"; touch "$t/unsafe-query-error/state/"{active,enabled}; ENABLED_QUERY_ERROR=1; if run unsafe-query-error; then exit 1; fi; unset ENABLED_QUERY_ERROR; [[ -e $t/unsafe-query-error/state/active && -e $t/unsafe-query-error/state/enabled ]] +prepare symlink-key; mv "$t/symlink-key/root/home/keyed/.ssh/authorized_keys" "$t/symlink-key/root/home/key"; ln -s ../key "$t/symlink-key/root/home/keyed/.ssh/authorized_keys"; touch "$t/symlink-key/state/"{active,enabled}; run symlink-key; [[ ! -e $t/symlink-key/state/active ]] +prepare stopped; touch "$t/stopped/state/enabled"; run stopped; [[ -e $t/stopped/state/enabled ]]; ! grep -q 'systemctl reload' "$t/stopped/events" +prepare reload-fail; touch "$t/reload-fail/state/"{active,enabled}; RELOAD_FAIL=1 run reload-fail; [[ ! -e $t/reload-fail/state/active && ! -e $t/reload-fail/state/enabled && -e $t/reload-fail/root/etc/ssh/sshd_config.d/00-omarchy-key-only.conf ]] +prepare syntax-fail; touch "$t/syntax-fail/state/"{active,enabled}; T_FAIL=1 run syntax-fail; [[ ! -e $t/syntax-fail/state/active && ! -e $t/syntax-fail/root/etc/ssh/sshd_config.d/00-omarchy-key-only.conf ]] +prepare concurrent; touch "$t/concurrent/state/"{active,enabled}; SLOW_RELOAD=1 run concurrent & a=$!; SLOW_RELOAD=1 run concurrent & c=$!; wait "$a"; wait "$c"; [[ ! -e $t/concurrent/state/overlap ]] +prepare admin; echo 'PasswordAuthentication yes' >"$t/admin/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"; touch "$t/admin/state/"{active,enabled}; before=$(sha256sum "$t/admin/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"); run admin; after=$(sha256sum "$t/admin/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf"); [[ $before == "$after" && -e $t/admin/state/active && ! -s $t/admin/events ]] +if /usr/bin/bash "$mapped/bin/omarchy-migrate-sshd-key-only" -p >/dev/null 2>&1; then exit 1; fi +NAMESPACE +pass "machine SSH migration preserves shared access, validates all users, serializes, and fails closed" +grep -qF '/usr/bin/sudo -N -- /usr/bin/omarchy-migrate-sshd-key-only' "$ROOT/migrations/1788163637.sh" || fail "migration lacks fixed machine dispatch" +! grep -Eq 'authorized_keys|getent passwd|/usr/bin/id -u' "$ROOT/migrations/1788163637.sh" || fail "migration still uses invoking-user state" +pass "per-user migration delegates one fixed cold root machine phase" diff --git a/test/shell.d/t2-hardware-test.sh b/test/shell.d/t2-hardware-test.sh index 8b2c478bff7..08b2fa2ae08 100644 --- a/test/shell.d/t2-hardware-test.sh +++ b/test/shell.d/t2-hardware-test.sh @@ -1,214 +1,10 @@ #!/bin/bash - set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - fix_t2="$ROOT/install/hardware/apple/fix-t2.sh" -other_packages="$ROOT/install/omarchy-other.packages" -migration="$ROOT/migrations/1785944594.sh" - -grep -Fq 'KERNEL_CMDLINE[default]+=" intel_iommu=on iommu=pt pm_async=off mem_sleep_default=deep"' "$fix_t2" || - fail "T2 setup installs the suspend kernel parameters" -! grep -q 'pcie_ports=compat' "$fix_t2" || - fail "T2 setup drops the obsolete PCIe compatibility parameter" -(( $(grep -Ec '^\[Fan[12]\]$' "$fix_t2") == 2 )) || - fail "T2 setup configures both possible MacBook fans" -(( $(grep -c '^speed_curve=linear$' "$fix_t2") == 2 )) || - fail "T2 setup preserves the tuned linear curve for both fans" -! grep -q 'tiny-dfr' "$fix_t2" || - fail "T2 setup leaves optional Touch Bar customization uninstalled" -! grep -qx 'tiny-dfr' "$other_packages" || - fail "the ISO no longer caches tiny-dfr" -pass "fresh T2 setup uses t2bce-compatible suspend, fan, and Touch Bar defaults" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" -calls="$test_tmp/calls.log" -mkdir -p "$stub_bin" -: >"$calls" - -cat >"$stub_bin/lspci" <<'SH' -#!/bin/bash - -# Chatty like real lspci: keep writing well past the pipe buffer after the T2 -# match, so a grep -q consumer would kill this stub with SIGPIPE and pipefail -# would read that as "no T2 hardware" (#6608). -if (( ${T2_HARDWARE:-0} == 1 )); then - echo '01:00.0 Bridge [0680]: Apple Inc. T2 Security Chip [106b:1801]' -fi -for _ in {1..4096}; do - echo '02:00.0 Host bridge [0600]: Filler Device [ffff:0000]' -done -SH - -cat >"$stub_bin/sudo" <<'SH' -#!/bin/bash - -printf 'sudo' >>"$TEST_LOG" -printf '\t%s' "$@" >>"$TEST_LOG" -printf '\n' >>"$TEST_LOG" -"$@" -SH - -cat >"$stub_bin/systemctl" <<'SH' -#!/bin/bash - -printf 'systemctl' >>"$TEST_LOG" -printf '\t%s' "$@" >>"$TEST_LOG" -printf '\n' >>"$TEST_LOG" -SH - -cat >"$stub_bin/omarchy-pkg-present" <<'SH' -#!/bin/bash - -(( ${TINY_DFR_INSTALLED:-0} == 1 )) -SH - -cat >"$stub_bin/omarchy-pkg-drop" <<'SH' -#!/bin/bash - -printf 'omarchy-pkg-drop' >>"$TEST_LOG" -printf '\t%s' "$@" >>"$TEST_LOG" -printf '\n' >>"$TEST_LOG" -SH - -cat >"$stub_bin/limine-mkinitcpio" <<'SH' -#!/bin/bash - -echo 'limine-mkinitcpio' >>"$TEST_LOG" -SH - -chmod +x "$stub_bin"/* - -limine_conf="$test_tmp/t2-mac.conf" -fan_conf="$test_tmp/t2fand.conf" -running_cmdline="$test_tmp/cmdline" -repair_marker="$test_tmp/t2-repair-complete" - -cat >"$limine_conf" <<'EOF' -# Generated by Omarchy installer for T2 Mac support -KERNEL_CMDLINE[default]+=" intel_iommu=on iommu=pt pcie_ports=compat" -EOF - -cat >"$fan_conf" <<'EOF' -[Fan1] -low_temp=55 -high_temp=75 -speed_curve=linear -always_full_speed=false -EOF - -echo 'quiet splash intel_iommu=on iommu=pt pcie_ports=compat' >"$running_cmdline" - -PATH="$stub_bin:$PATH" \ - TEST_LOG="$calls" \ - T2_HARDWARE=1 \ - TINY_DFR_INSTALLED=1 \ - OMARCHY_T2_LIMINE_CONF="$limine_conf" \ - OMARCHY_T2_FAN_CONF="$fan_conf" \ - OMARCHY_T2_RUNNING_CMDLINE="$running_cmdline" \ - OMARCHY_T2_REPAIR_MARKER="$repair_marker" \ - bash -euo pipefail "$migration" >/dev/null - -grep -Fq 'KERNEL_CMDLINE[default]+=" intel_iommu=on iommu=pt pm_async=off mem_sleep_default=deep"' "$limine_conf" || - fail "T2 migration updates the Limine suspend parameters" -! grep -q 'pcie_ports=compat' "$limine_conf" || - fail "T2 migration removes the obsolete PCIe compatibility parameter" -(( $(grep -Ec '^[[:space:]]*\[Fan2\][[:space:]]*$' "$fan_conf") == 1 )) || - fail "T2 migration adds exactly one second-fan section" -grep -Fq $'systemctl\tdisable\t--now\ttiny-dfr.service' "$calls" || - fail "T2 migration disables tiny-dfr" -grep -Fq $'omarchy-pkg-drop\ttiny-dfr' "$calls" || - fail "T2 migration removes tiny-dfr" -grep -Fxq 'limine-mkinitcpio' "$calls" || - fail "T2 migration rebuilds the boot image" -[[ -f $repair_marker ]] || fail "T2 migration records the machine-wide repair" -pass "T2 migration repairs existing installs" - -: >"$calls" - -PATH="$stub_bin:$PATH" \ - TEST_LOG="$calls" \ - T2_HARDWARE=1 \ - TINY_DFR_INSTALLED=0 \ - OMARCHY_T2_LIMINE_CONF="$limine_conf" \ - OMARCHY_T2_FAN_CONF="$fan_conf" \ - OMARCHY_T2_RUNNING_CMDLINE="$running_cmdline" \ - OMARCHY_T2_REPAIR_MARKER="$repair_marker" \ - bash -euo pipefail "$migration" >/dev/null - -(( $(grep -Ec '^[[:space:]]*\[Fan2\][[:space:]]*$' "$fan_conf") == 1 )) || - fail "T2 migration remains idempotent" -[[ ! -s $calls ]] || fail "an already repaired T2 install is left unchanged" "$(cat "$calls")" -pass "T2 migration is machine-idempotent before reboot" - -rm -f "$repair_marker" -: >"$calls" - -PATH="$stub_bin:$PATH" \ - TEST_LOG="$calls" \ - T2_HARDWARE=1 \ - TINY_DFR_INSTALLED=0 \ - OMARCHY_T2_LIMINE_CONF="$limine_conf" \ - OMARCHY_T2_FAN_CONF="$fan_conf" \ - OMARCHY_T2_RUNNING_CMDLINE="$running_cmdline" \ - OMARCHY_T2_REPAIR_MARKER="$repair_marker" \ - bash -euo pipefail "$migration" >/dev/null - -grep -Fxq 'limine-mkinitcpio' "$calls" || - fail "T2 migration retries an interrupted boot image rebuild" -[[ -f $repair_marker ]] || fail "a retried T2 repair records completion" -! grep -Eq $'^(sudo\t)?(sed|tee|systemctl|omarchy-pkg-drop)(\t|$)' "$calls" || - fail "T2 rebuild retry leaves completed repair steps alone" "$(cat "$calls")" -pass "T2 migration retries an interrupted boot image rebuild" - -cat >"$limine_conf" <<'EOF' -KERNEL_CMDLINE[default]+=" intel_iommu=on iommu=pt pcie_ports=compat" -EOF -printf '[Fan1]\n' >"$fan_conf" -: >"$calls" - -PATH="$stub_bin:$PATH" \ - TEST_LOG="$calls" \ - T2_HARDWARE=0 \ - TINY_DFR_INSTALLED=1 \ - OMARCHY_T2_LIMINE_CONF="$limine_conf" \ - OMARCHY_T2_FAN_CONF="$fan_conf" \ - OMARCHY_T2_RUNNING_CMDLINE="$running_cmdline" \ - OMARCHY_T2_REPAIR_MARKER="$repair_marker" \ - bash -euo pipefail "$migration" >/dev/null - -grep -q 'pcie_ports=compat' "$limine_conf" || fail "non-T2 Limine configuration is unchanged" -! grep -q '\[Fan2\]' "$fan_conf" || fail "non-T2 fan configuration is unchanged" -[[ ! -s $calls ]] || fail "non-T2 systems skip the repair" "$(cat "$calls")" -pass "T2 migration skips unrelated hardware" - -# The previous block left the fixtures looking like an install the SIGPIPE bug -# skipped: stale Limine parameters, one fan section, and no repair marker. The -# rerun migration must complete the repair the original was marked as done for. -rerun_migration="$ROOT/migrations/1786137597.sh" -rm -f "$repair_marker" -: >"$calls" - -PATH="$stub_bin:$PATH" \ - TEST_LOG="$calls" \ - T2_HARDWARE=1 \ - TINY_DFR_INSTALLED=0 \ - OMARCHY_PATH="$ROOT" \ - OMARCHY_T2_LIMINE_CONF="$limine_conf" \ - OMARCHY_T2_FAN_CONF="$fan_conf" \ - OMARCHY_T2_RUNNING_CMDLINE="$running_cmdline" \ - OMARCHY_T2_REPAIR_MARKER="$repair_marker" \ - bash -euo pipefail "$rerun_migration" >/dev/null - -grep -Fq 'pm_async=off mem_sleep_default=deep' "$limine_conf" || - fail "T2 rerun migration updates the Limine suspend parameters" -(( $(grep -Ec '^[[:space:]]*\[Fan2\][[:space:]]*$' "$fan_conf") == 1 )) || - fail "T2 rerun migration adds the second-fan section" -grep -Fxq 'limine-mkinitcpio' "$calls" || - fail "T2 rerun migration rebuilds the boot image" -[[ -f $repair_marker ]] || fail "T2 rerun migration records the machine-wide repair" -pass "T2 rerun migration repairs installs the broken hardware check skipped" +grep -Fq 'pm_async=off mem_sleep_default=deep' "$fix_t2" || fail "T2 setup installs suspend parameters" +(( $(grep -Ec '^\[Fan[12]\]$' "$fix_t2") == 2 )) || fail "T2 setup configures both fans" +! grep -q 'tiny-dfr' "$fix_t2" || fail "T2 setup leaves tiny-dfr uninstalled" +pass "fresh T2 setup retains the repaired defaults" +grep -Fq '/usr/share/omarchy/migrations/1785944594.sh --machine' "$ROOT/migrations/1785944594.sh" || fail "T2 migration lacks fixed machine phase" +pass "T2 repair uses its fixed packaged machine phase" diff --git a/test/shell.d/update-disk-space-test.sh b/test/shell.d/update-disk-space-test.sh index 2c7368ea630..e40401ebfeb 100644 --- a/test/shell.d/update-disk-space-test.sh +++ b/test/shell.d/update-disk-space-test.sh @@ -9,18 +9,21 @@ unset OMARCHY_UPDATE_FORCE unset TEST_AVAILABLE_BYTES unset TEST_DF_INVALID -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" -test_home="$test_tmp/home" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +stub_bin="$SUDO_TEST_ROOT/bin" +test_home="$SUDO_TEST_HOME" runtime_dir="$test_tmp/runtime" snapshot_marker="$test_tmp/snapshot" gum_marker="$test_tmp/gum" -mkdir -p "$stub_bin" "$test_home" "$runtime_dir" +mkdir -p "$runtime_dir" +for command in omarchy-update omarchy-update-requires-free-space omarchy-update-confirm; do + rm -f "$stub_bin/$command" + copy_boundary_file "bin/$command" +done run_update() { - HOME="$test_home" \ + SUDO_TEST_HOME="$test_home" \ XDG_RUNTIME_DIR="$runtime_dir" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ LC_ALL=C \ @@ -30,13 +33,14 @@ run_update() { SNAPSHOT_MARKER="$snapshot_marker" \ GUM_MARKER="$gum_marker" \ GUM_STATUS=${GUM_STATUS:-1} \ - "$ROOT/bin/omarchy-update" "$@" + "$SUDO_TEST_ROOT/bin/omarchy-update" "$@" } write_stub() { local name="$1" local body="$2" + rm -f "$stub_bin/$name" cat >"$stub_bin/$name" <"$boundary_tmp/output" 2>&1 +} + +for args in '-y' ''; do + reset_boundary + touch "$SUDO_TEST_CACHE" + run_update $args || fail "update failed" "$(<"$boundary_tmp/output")" + assert_boundary_cold "successful update" + grep -q '^sudo -N /usr/bin/true$' "$SUDO_TEST_LOG" || fail "update package helpers must use no-update sudo" + python3 - "$SUDO_TEST_LOG" <<'PY' +import sys +s=open(sys.argv[1]).read().splitlines() +positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-restart --services-only','step: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 + +for step in omarchy-update-system-pkgs yay omarchy-hook omarchy-update-mise; do + reset_boundary + export SUDO_TEST_FAIL_STEP=$step + if run_update -y; then fail "$step failure must fail the update"; fi + assert_boundary_cold "failed $step" + python3 - "$SUDO_TEST_LOG" <<'PY' +import sys +s=open(sys.argv[1]).read().splitlines() +for i,line in enumerate(s): + if line=='step:omarchy-update-stay-awake stop': assert i>0 and s[i-1]=='sudo -k',s +PY + pass "update revokes credentials after $step fails" +done + +reset_boundary +export SUDO_TEST_SIGNAL_STEP=omarchy-hook +if run_update -y; then fail "interrupted update must fail"; fi +assert_boundary_cold "interrupted update" +pass "update revokes credentials on TERM" + +reset_boundary +export SUDO_TEST_REVOKE_FAIL=1 +if run_update -y; then fail "failed initial revocation must fail the update"; fi +if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "failed revocation must precede update work"; fi +pass "a failed cold start prevents update work" + +reset_boundary +export SUDO_TEST_UNSUPPORTED=1 +if run_update -y; then fail "unsupported sudo must prevent mixed-trust work"; fi +assert_boundary_cold "unsupported sudo" +pass "unsupported sudo fails without running update steps" + +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 + +for step in pacman omarchy-hook; do + reset_boundary + export SUDO_TEST_FAIL_STEP=$step + if "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable >"$boundary_tmp/output" 2>&1; then fail "refresh must propagate $step failure"; fi + assert_boundary_cold "failed refresh $step" + pass "refresh revokes after $step failure" +done + +# The wrapper must preserve sudo's own option parser, including validation and +# explicit --, while standalone timestamp maintenance cannot be combined with N. +for args in '-v' '-n /usr/bin/true' '--user test -- /usr/bin/true' '-- /usr/bin/true' '-k' '-K'; do + reset_boundary + "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" $args + case "$args" in + -k|-K) expected="sudo $args" ;; + *) expected="sudo -N $args" ;; + esac + [[ $(<"$SUDO_TEST_LOG") == "$expected" ]] || fail "wrapper changed options: $args" "$(<"$SUDO_TEST_LOG")" + [[ ! -e $SUDO_TEST_CACHE ]] || fail "wrapper refreshed credentials" + pass "sudo wrapper preserves $args" +done + +for script in bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do + reset_boundary + if /usr/bin/bash "$SUDO_TEST_ROOT/$script" -p >"$boundary_tmp/output" 2>&1; then fail "$script accepted an ordinary Bash launch"; fi + [[ ! -s $SUDO_TEST_LOG ]] || fail "$script reached sudo through an invalid interpreter" + pass "$script rejects a decoy privileged-mode argument" +done + +reset_boundary +printf '%s\n' 'printf startup-ran >>"$SUDO_TEST_ROOT/startup-marker"' >"$boundary_tmp/startup" +BASH_ENV="$boundary_tmp/startup" ENV="$boundary_tmp/startup" run_update -y || fail "sanitized update failed" "$(<"$boundary_tmp/output")" +[[ ! -e $SUDO_TEST_ROOT/startup-marker ]] || fail "startup code leaked into an update helper" +pass "inherited startup files do not run in the updater or its child scripts" + +reset_boundary +function printf() { /usr/bin/touch "$SUDO_TEST_ROOT/function-marker"; } +export -f printf +run_update -y || fail "update failed with inherited function" "$(<"$boundary_tmp/output")" +unset -f printf +[[ ! -e $SUDO_TEST_ROOT/function-marker ]] || fail "an inherited function reached an update helper" +pass "exported functions do not reach update helper interpreters" diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index 6c65428dc89..00eca956770 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -4,16 +4,40 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" -test_home="$test_tmp/home" -runtime_dir="$test_tmp/runtime" -mkdir -p "$stub_bin" "$test_home" "$runtime_dir" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +stub_bin="$SUDO_TEST_ROOT/bin" +test_home="$SUDO_TEST_HOME" +runtime_dir="/run/user/$(id -u)" +test_run_id="test-$BASHPID-$RANDOM" +update_lock_name="omarchy-update-$test_run_id.lock" +stay_awake_dir_name="omarchy-update-stay-awake-$test_run_id" +trap 'rm -rf -- "$runtime_dir/$stay_awake_dir_name"; rm -f -- "$runtime_dir/$update_lock_name"; rm -rf -- "$boundary_tmp"' EXIT +for command in omarchy-update omarchy-update-lock omarchy-update-stay-awake; do + rm -f "$SUDO_TEST_ROOT/bin/$command" + copy_boundary_file "bin/$command" +done +sed -i \ + -e "s/omarchy-update\.lock/$update_lock_name/g" \ + -e "s#state_dir=\"\$state_base/omarchy-update-stay-awake\"#state_dir=\"\$state_base/$stay_awake_dir_name\"#" \ + "$SUDO_TEST_ROOT/bin/omarchy-update" \ + "$SUDO_TEST_ROOT/bin/omarchy-update-lock" \ + "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" +cat >"$SUDO_TEST_ROOT/mock/setpriv" <<'STUB' +#!/bin/bash +while [[ ${1:-} == --* ]]; do + case "$1" in + --reuid|--regid) shift 2 ;; + --clear-groups) shift ;; + *) exit 90 ;; + esac +done +exec "$@" +STUB +chmod +x "$SUDO_TEST_ROOT/mock/setpriv" run_with_lock_env() { - HOME="$test_home" \ + SUDO_TEST_HOME="$test_home" \ XDG_RUNTIME_DIR="$runtime_dir" \ XDG_STATE_HOME="$test_tmp/state" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ @@ -24,6 +48,7 @@ write_stub() { local name="$1" local body="$2" + rm -f "$stub_bin/$name" cat >"$stub_bin/$name" <"$TEST_MARKER"; sleep 2; exit 0' -OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 & +OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 & update_pid=$! for _ in {1..50}; do @@ -67,7 +95,7 @@ done [[ -f $update_snapshot_marker ]] || fail "first omarchy-update reached snapshot under lock" set +e -OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1 +OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1 update_second_status=$? set -e @@ -85,11 +113,11 @@ pass "omarchy-update prevents overlapping top-level updates" inhibit_pid_file="$test_tmp/inhibit-pid" keyring_marker="$test_tmp/keyring-started" write_stub omarchy-snapshot 'exit 0' -write_stub systemd-inhibit 'echo "$$" >"$INHIBIT_PID_FILE"; exec sleep 30' +write_stub systemd-inhibit '[[ -z ${INHIBIT_PID_FILE:-} ]] || echo "$$" >"$INHIBIT_PID_FILE"; while [[ $1 == --* ]]; do shift; done; exec "$@"' write_stub omarchy-update-keyring 'echo started >"$TEST_MARKER"; sleep 3; exit 0' OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$keyring_marker" INHIBIT_PID_FILE="$inhibit_pid_file" \ - run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 & + run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 & inhibit_update_pid=$! for _ in {1..100}; do @@ -101,7 +129,7 @@ done inhibitor_pid=$(<"$inhibit_pid_file") kill -0 "$inhibitor_pid" 2>/dev/null || fail "sleep inhibitor is still running when its descriptors are inspected" -lock_target=$(readlink -f "$runtime_dir/omarchy-update.lock") +lock_target=$(readlink -f "$runtime_dir/$update_lock_name") inhibitor_holds_lock=0 for fd in /proc/"$inhibitor_pid"/fd/*; do [[ -e $fd ]] || continue @@ -118,39 +146,104 @@ kill -0 "$inhibitor_pid" 2>/dev/null && pass "omarchy-update waits for its sleep inhibitor to stop" if (( EUID != 0 )); then - sudo_log="$test_tmp/sudo.log" + sudo_log="$SUDO_TEST_LOG" + : >"$sudo_log" pkexec_marker="$test_tmp/pkexec-used" terminal_inhibit_pid_file="$test_tmp/terminal-inhibit-pid" - write_stub sudo ' -printf "%s\n" "$*" >>"$SUDO_LOG" -if [[ $1 == "-v" ]]; then - exit 0 -fi -exec "$@"' - write_stub pkexec 'touch "$PKEXEC_MARKER"; exec "$@"' + write_stub pkexec '[[ -z ${PKEXEC_MARKER:-} ]] || touch "$PKEXEC_MARKER"; exec "$@"' + write_stub systemd-inhibit 'sleep 0.2; while [[ $1 == --* ]]; do shift; done; exec "$@"' - # start leaves the inhibitor running on purpose, but script tears the pty down - # the moment its command returns, which SIGHUPs that inhibitor before it can - # exec. Keep the session open from the inside until the stub has logged. + # sudo -b returns before its child is ready. Require start to wait for the + # delayed child and succeed, then stop it before script tears down the PTY. terminal_driver="$test_tmp/terminal-stay-awake" cat >"$terminal_driver" <<'SH' #!/bin/bash +set -euo pipefail omarchy-update-stay-awake start -for _ in {1..200}; do - grep -q '^systemd-inhibit ' "$SUDO_LOG" && break - sleep 0.05 -done +[[ -s $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]] +omarchy-update-stay-awake stop +[[ ! -e $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]] SH + sed -i "s/REPLACE_STAY_AWAKE_DIR/$stay_awake_dir_name/g" "$terminal_driver" chmod +x "$terminal_driver" SUDO_LOG="$sudo_log" PKEXEC_MARKER="$pkexec_marker" INHIBIT_PID_FILE="$terminal_inhibit_pid_file" \ run_with_lock_env script -qefc "$terminal_driver" /dev/null >/dev/null - grep -qx -- '-v' "$sudo_log" || fail "terminal sleep inhibition validates sudo in the foreground" - grep -q '^systemd-inhibit ' "$sudo_log" || fail "terminal sleep inhibition runs through sudo" + grep -q -- '^sudo -N -b -- ' "$sudo_log" || fail "terminal inhibition authenticates its background command without a reusable timestamp" [[ ! -e $pkexec_marker ]] || fail "terminal sleep inhibition does not use pkexec" - run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop + run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop pass "terminal updates use sudo instead of Polkit for sleep inhibition" + + wait_for_process_exit() { + local process_pid="$1" + + for _ in {1..100}; do + kill -0 "$process_pid" 2>/dev/null || return 0 + [[ $(awk '{ print $3 }' "/proc/$process_pid/stat" 2>/dev/null || true) == "Z" ]] && return 0 + sleep 0.02 + done + return 1 + } + + delayed_marker="$test_tmp/delayed-inhibitor" + delayed_helper_pid_file="$test_tmp/delayed-helper-pid" + write_stub systemd-inhibit 'echo "$$" >"$DELAYED_MARKER"; sleep 0.4; while [[ $1 == --* ]]; do shift; done; exec "$@"' + + # Keep the start helper's stdin attached to the private PTY so it takes the + # sudo -b branch, then signal only that helper before the held child publishes. + delayed_terminal_driver="$test_tmp/delayed-terminal-stay-awake" + cat >"$delayed_terminal_driver" <<'SH' +#!/bin/bash +set +e +omarchy-update-stay-awake start "$DELAYED_HELPER_PID_FILE" +wait "$helper_pid" +exit $? +SH + chmod +x "$delayed_terminal_driver" + DELAYED_MARKER="$delayed_marker" DELAYED_HELPER_PID_FILE="$delayed_helper_pid_file" \ + run_with_lock_env script -qefc "$delayed_terminal_driver" /dev/null >"$test_tmp/delayed-terminal.out" 2>&1 & + delayed_terminal_driver_pid=$! + for _ in {1..100}; do + [[ -s $delayed_marker && -s $delayed_helper_pid_file ]] && break + sleep 0.02 + done + [[ -s $delayed_marker && -s $delayed_helper_pid_file ]] || fail "terminal cancellation reaches the delayed launch window" + kill -TERM "$(<"$delayed_helper_pid_file")" + wait "$delayed_terminal_driver_pid" || true + delayed_inhibitor_pid=$(<"$delayed_marker") + wait_for_process_exit "$delayed_inhibitor_pid" || fail "terminal cancellation leaves no delayed inhibitor" + [[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "terminal cancellation leaves no launch state" + pass "terminal cancellation rolls back delayed publication" + + # With redirected stdin the same helper takes the graphical pkexec branch. + : >"$delayed_marker" + delayed_graphical_helper_pid_file="$test_tmp/delayed-graphical-helper-pid" + delayed_graphical_driver="$test_tmp/delayed-graphical-stay-awake" + cat >"$delayed_graphical_driver" <<'SH' +#!/bin/bash +echo "$$" >"$DELAYED_HELPER_PID_FILE" +exec omarchy-update-stay-awake start "$test_tmp/delayed-graphical.out" 2>&1 & + delayed_graphical_driver_pid=$! + for _ in {1..100}; do + [[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] && break + sleep 0.02 + done + [[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] || fail "graphical cancellation reaches the delayed launch window" + delayed_graphical_helper_pid=$(<"$delayed_graphical_helper_pid_file") + kill -TERM "$delayed_graphical_helper_pid" + wait "$delayed_graphical_driver_pid" || true + delayed_inhibitor_pid=$(<"$delayed_marker") + wait_for_process_exit "$delayed_inhibitor_pid" || fail "graphical cancellation leaves no delayed inhibitor" + [[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "graphical cancellation leaves no launch state" + pass "graphical cancellation rolls back delayed publication" + write_stub systemd-inhibit 'while [[ $1 == --* ]]; do shift; done; exec "$@"' fi # Update-owned Stay Awake state must be cleared before the restart helper can @@ -158,7 +251,7 @@ fi write_stub omarchy-snapshot 'exit 0' write_stub omarchy-update-keyring 'exit 0' write_stub omarchy-toggle-idle ' -state_file="$HOME/.local/state/omarchy/indicators/stay-awake" +state_file="$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake" case "$1" in stay-awake) mkdir -p "$(dirname "$state_file")" @@ -169,32 +262,34 @@ 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" # Stale cleanup state from a killed update must not override a Stay Awake choice # the user made afterward. -stay_awake_helper_state="$runtime_dir/omarchy-update-stay-awake" +stay_awake_helper_state="$runtime_dir/$stay_awake_dir_name" stay_awake_state="$test_home/.local/state/omarchy/indicators/stay-awake" -mkdir -p "$stay_awake_helper_state" "$(dirname "$stay_awake_state")" -printf '%s\n' "old-update-owner" >"$stay_awake_helper_state/idle-owner" +mkdir -m 700 -p "$stay_awake_helper_state" +mkdir -p "$(dirname "$stay_awake_state")" +printf '%s\n' "123:456:789" >"$stay_awake_helper_state/idle-owner" +chmod 600 "$stay_awake_helper_state/idle-owner" printf '%s\n' "user-choice" >"$stay_awake_state" -run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop +run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop [[ $(<"$stay_awake_state") == "user-choice" ]] || fail "stale update ownership does not remove a newer Stay Awake choice" pass "stale update ownership preserves a newer Stay Awake choice" @@ -203,12 +298,29 @@ pass "stale update ownership preserves a newer Stay Awake choice" sleep 30 & unrelated_pid=$! unrelated_start_time=$(awk '{ print $22 }' "/proc/$unrelated_pid/stat") -mkdir -p "$stay_awake_helper_state" -printf '%s %s\n' "$unrelated_pid" "$((unrelated_start_time + 1))" >"$stay_awake_helper_state/inhibit-pid" +mkdir -m 700 -p "$stay_awake_helper_state" +printf '1 %s %s %s %032x\n' "$unrelated_pid" "$((unrelated_start_time + 1))" "$(id -u)" 1 >"$stay_awake_helper_state/inhibit-pid" +chmod 600 "$stay_awake_helper_state/inhibit-pid" -run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop +run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop kill -0 "$unrelated_pid" 2>/dev/null || fail "stale inhibitor state does not terminate a reused PID" kill "$unrelated_pid" wait "$unrelated_pid" 2>/dev/null || true pass "stale inhibitor state does not terminate a reused PID" + +# The hidden helper also establishes its own boundary when invoked directly. +reset_boundary +touch "$SUDO_TEST_CACHE" +run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop +[[ $(head -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "standalone inhibitor cleanup did not start cold" +assert_boundary_cold "standalone inhibitor cleanup" +pass "standalone inhibitor cleanup revokes before and after session work" + +reset_boundary +export SUDO_TEST_REVOKE_FAIL=1 +if run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" start; then + fail "inhibitor started after failed initial revocation" +fi +[[ ! -e $stay_awake_helper_state/inhibit-pid ]] || fail "failed revocation started an inhibitor" +pass "failed initial revocation prevents standalone inhibition" 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 00000000000..baddde91489 --- /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 2dd62b6e43f..994dc493247 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -2,13 +2,11 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" -mkdir -p "$stub_bin" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-update +test_tmp="$boundary_tmp" +stub_bin="$SUDO_TEST_ROOT/bin" # Every step omarchy-update runs, recorded in order with the unattended flag it # was handed. One of them can be told to fail. @@ -33,6 +31,7 @@ steps=( ) for step in "${steps[@]}"; do + rm -f "$stub_bin/$step" cat >"$stub_bin/$step" <<'STUB' #!/bin/bash printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG" @@ -49,7 +48,7 @@ run_update() { FAILING_STEP="${FAILING_STEP:-}" \ OMARCHY_UPDATE_LOGGED=1 \ PATH="$stub_bin:$PATH" \ - bash "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" + "$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" } steps_run() { @@ -70,13 +69,14 @@ expected_steps() { omarchy-update-keyring \ omarchy-update-system-pkgs \ omarchy-migrate \ - omarchy-hook \ - omarchy-update-aur-pkgs \ - omarchy-update-mise \ omarchy-update-orphan-pkgs \ omarchy-update-analyze-logs \ omarchy-update-status \ + omarchy-update-restart \ omarchy-update-stay-awake \ + omarchy-update-aur-pkgs \ + omarchy-hook \ + omarchy-update-mise \ omarchy-update-restart } diff --git a/test/shell.d/update-stay-awake-security-test.sh b/test/shell.d/update-stay-awake-security-test.sh new file mode 100644 index 00000000000..32ba9a5c4ca --- /dev/null +++ b/test/shell.d/update-stay-awake-security-test.sh @@ -0,0 +1,448 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +test_processes=() +test_runtime_created="" +cleanup_test() { + for pid in "${test_processes[@]}"; do kill "$pid" 2>/dev/null || true; done + [[ -z ${state_dir:-} ]] || rm -rf -- "$state_dir" + [[ -z ${state_hardlink:-} ]] || rm -f -- "$state_hardlink" + rm -rf -- "$test_tmp" + [[ -z $test_runtime_created ]] || rmdir -- "$test_runtime_created" 2>/dev/null || true +} +trap cleanup_test EXIT + +stub_bin="$test_tmp/bin" +mapped_root="$test_tmp/omarchy" +test_home="$test_tmp/home" +runtime_dir=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} +if [[ ! -d $runtime_dir || -L $runtime_dir || $(stat -Lc '%u %a' "$runtime_dir" 2>/dev/null || true) != "$(id -u) 700" ]]; then + if (( EUID != 0 )); then + fail "test needs a private XDG runtime directory or root namespace" + fi + runtime_dir=$(mktemp -d -p /run omarchy-stay-awake-runtime.XXXXXXXX) + chmod 0700 "$runtime_dir" + test_runtime_created="$runtime_dir" +fi +test_run_id="test-$BASHPID-$RANDOM" +state_dir="$runtime_dir/omarchy-update-stay-awake-$test_run_id" +state_hardlink="$runtime_dir/.omarchy-update-stay-awake-hardlink-$test_run_id" +inhibitor_log="$test_tmp/inhibitors" +mkdir -p "$stub_bin" "$test_home" "$mapped_root/bin" "$mapped_root/default/omarchy/sudo-no-update" +: >"$inhibitor_log" + +cat >"$stub_bin/pkexec" <<'SH' +#!/bin/bash +exec "$@" +SH + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +case ${1:-} in + -h) echo 'usage: sudo [-bHkNnPS] command'; exit 0 ;; + -k|-K|-v) exit 0 ;; +esac +background=0 +while (( $# )); do + case "$1" in + -N|-n) shift ;; + -b) background=1; shift ;; + --) shift; break ;; + *) break ;; + esac +done +if (( background )); then + "$@" & +else + exec "$@" +fi +SH + +cat >"$stub_bin/setpriv" <<'SH' +#!/bin/bash +while [[ ${1:-} == --* ]]; do + case "$1" in + --reuid|--regid) shift 2 ;; + --clear-groups) shift ;; + *) exit 90 ;; + esac +done +exec "$@" +SH + +cat >"$stub_bin/systemd-inhibit" <<'SH' +#!/bin/bash +[[ ${SYSTEMD_FAIL:-0} == "0" ]] || exit 42 +printf '%s\n' "$$" >>"$INHIBITOR_LOG" +if [[ -n ${CREATE_BAD_IDLE:-} ]]; then + ln -s "$CREATE_BAD_IDLE" "$TEST_STATE_DIR/idle-owner" +fi +trap 'exit 0' TERM +while [[ ${1:-} == --* ]]; do shift; done +exec "$@" +SH + +cat >"$stub_bin/omarchy-toggle-idle" <<'SH' +#!/bin/bash +state_file="$HOME/.local/state/omarchy/indicators/stay-awake" +case "$1" in + stay-awake) + mkdir -p "$(dirname "$state_file")" + touch "$state_file" + ;; + allow-idle) + rm -f "$state_file" + ;; +esac +SH +chmod +x "$stub_bin"/* + +mapped_helper="$mapped_root/bin/omarchy-update-stay-awake" +cp "$ROOT/bin/omarchy-update-stay-awake" "$mapped_helper" +cp "$ROOT/bin/omarchy-security-functions" "$mapped_root/bin/omarchy-security-functions" +cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$mapped_root/default/omarchy/sudo-no-update/sudo" +for mapped_file in \ + "$mapped_helper" \ + "$mapped_root/bin/omarchy-security-functions" \ + "$mapped_root/default/omarchy/sudo-no-update/sudo"; do + sed -i \ + -e "s#/usr/bin/sudo#$stub_bin/sudo#g" \ + -e "s#/usr/bin/pkexec#$stub_bin/pkexec#g" \ + -e "s#/usr/bin/systemd-inhibit#$stub_bin/systemd-inhibit#g" \ + -e "s#/usr/bin/setpriv#$stub_bin/setpriv#g" \ + -e 's#state_dir="$state_base/omarchy-update-stay-awake"#state_dir="$state_base/omarchy-update-stay-awake-${OMARCHY_TEST_RUN_ID:?}"#' \ + "$mapped_file" +done +chmod +x "$mapped_helper" "$mapped_root/default/omarchy/sudo-no-update/sudo" + +run_helper() { + HOME="$test_home" \ + XDG_RUNTIME_DIR="$runtime_dir" \ + INHIBITOR_LOG="$inhibitor_log" \ + TEST_STATE_DIR="$state_dir" \ + OMARCHY_TEST_RUN_ID="$test_run_id" \ + OMARCHY_PATH="$mapped_root" \ + PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + "$mapped_helper" "$@" +} + +wait_dead() { + local pid="$1" + + for _ in {1..100}; do + kill -0 "$pid" 2>/dev/null || return 0 + [[ $(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null || true) == "Z" ]] && return 0 + sleep 0.02 + done + return 1 +} + +prepare_state_dir() { + rm -rf "$state_dir" + mkdir -m 700 "$state_dir" +} + +write_inhibit_state() { + local record="$1" + + printf '%s\n' "$record" >"$state_dir/inhibit-pid" + chmod 600 "$state_dir/inhibit-pid" +} + +start_identity_process() { + local token="$1" + + /usr/bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.05; done' \ + omarchy-test "--why=Omarchy update in progress [$token]" & + identity_pid=$! + test_processes+=("$identity_pid") + identity_start=$(awk '{ print $22 }' "/proc/$identity_pid/stat") + identity_owner=$(stat -Lc '%u' "/proc/$identity_pid") +} + +unverified_signals=$(grep -nE '(^|[[:space:]])kill ([^-]|-[^0])[^#]*\$inhibit_pid' \ + "$ROOT/bin/omarchy-update-stay-awake" || true) +if [[ -n $unverified_signals ]]; then + fail "inhibitor signals bypass identity verification" "$unverified_signals" +fi +grep -q 'signal_inhibitor .* KILL' "$ROOT/bin/omarchy-update-stay-awake" || + fail "delayed inhibitor cleanup revalidates the full identity before KILL" +pass "every inhibitor signal is identity-bound" + +run_helper start +[[ -s $state_dir/inhibit-pid ]] || fail "valid XDG runtime publishes inhibitor state" +read -r version valid_pid valid_start valid_owner valid_token <"$state_dir/inhibit-pid" +[[ $version == "1" && $valid_token =~ ^[0-9a-f]{32}$ ]] || fail "inhibitor state is an exact versioned identity" +[[ $(stat -Lc '%u %a %h' "$state_dir/inhibit-pid") == "$(id -u) 600 1" ]] || + fail "inhibitor state is private, caller-owned, and singly linked" +run_helper stop +wait_dead "$valid_pid" || fail "valid inhibitor identity is stopped" +[[ ! -e $state_dir ]] || fail "valid state is cleaned after stop" +pass "valid XDG runtime uses private atomic inhibitor state" + +permissive_runtime="$test_tmp/permissive-runtime" +mkdir -m 755 "$permissive_runtime" +if HOME="$test_home" XDG_RUNTIME_DIR="$permissive_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then + fail "permissive XDG runtime is rejected" +fi +symlink_runtime="$test_tmp/runtime-link" +ln -s "$runtime_dir" "$symlink_runtime" +if HOME="$test_home" XDG_RUNTIME_DIR="$symlink_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then + fail "symlink XDG runtime is rejected" +fi +if HOME="$test_home" XDG_RUNTIME_DIR="$test_tmp/../${test_tmp##*/}/runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then + fail "non-canonical XDG runtime is rejected" +fi +pass "unsafe XDG runtime directories are rejected" + +mkdir -m 700 "$test_tmp/state-target" +ln -s "$test_tmp/state-target" "$state_dir" +if run_helper stop 2>/dev/null; then + fail "symlink inhibitor state directory is rejected" +fi +rm -f "$state_dir" +mkdir -m 755 "$state_dir" +if run_helper stop 2>/dev/null; then + fail "permissive inhibitor state directory is rejected" +fi +rm -rf "$state_dir" +pass "unsafe inhibitor state directories are rejected" + +prepare_state_dir +printf 'not a record\n' >"$state_dir/inhibit-pid" +chmod 600 "$state_dir/inhibit-pid" +if run_helper stop 2>/dev/null; then + fail "malformed inhibitor state is rejected" +fi + +token=11111111111111111111111111111111 +start_identity_process "$token" +prepare_state_dir +printf '1 %s %s %s %s\nextra\n' "$identity_pid" "$identity_start" "$identity_owner" "$token" >"$state_dir/inhibit-pid" +chmod 600 "$state_dir/inhibit-pid" +if run_helper stop 2>/dev/null; then + fail "multiline inhibitor state is rejected" +fi +kill -0 "$identity_pid" 2>/dev/null || fail "multiline state cannot signal its target" + +prepare_state_dir +write_inhibit_state "1 $identity_pid $((identity_start + 1)) $identity_owner $token" +run_helper stop +kill -0 "$identity_pid" 2>/dev/null || fail "reused PID state cannot signal its target" + +prepare_state_dir +write_inhibit_state "1 $identity_pid $identity_start $identity_owner 22222222222222222222222222222222" +run_helper stop +kill -0 "$identity_pid" 2>/dev/null || fail "wrong process identity cannot signal its target" +kill "$identity_pid" +wait_dead "$identity_pid" || true +pass "malformed, multiline, reused-PID, and wrong-identity records are harmless" + +retry_flag="$test_tmp/allow-termination" +token=44444444444444444444444444444444 +/usr/bin/bash -c ' + trap "" TERM + while [[ ! -e $1 ]]; do sleep 0.05; done + trap "exit 0" TERM + while :; do sleep 0.05; done +' omarchy-retry "$retry_flag" "--why=Omarchy update in progress [$token]" & +retry_pid=$! +test_processes+=("$retry_pid") +retry_start=$(awk '{ print $22 }' "/proc/$retry_pid/stat") +retry_owner=$(stat -Lc '%u' "/proc/$retry_pid") +prepare_state_dir +write_inhibit_state "1 $retry_pid $retry_start $retry_owner $token" +if run_helper stop 2>/dev/null; then + fail "failed termination reports success" +fi +[[ -s $state_dir/inhibit-pid ]] || fail "failed termination retains authenticated retry state" +touch "$retry_flag" +sleep 0.1 +run_helper stop +wait_dead "$retry_pid" || fail "retained inhibitor state permits a successful retry" +pass "failed termination retains its authenticated retry handle" + +for unsafe_kind in symlink permissive hardlink; do + token=33333333333333333333333333333333 + start_identity_process "$token" + prepare_state_dir + record="1 $identity_pid $identity_start $identity_owner $token" + case "$unsafe_kind" in + symlink) + printf '%s\n' "$record" >"$test_tmp/state-victim" + chmod 600 "$test_tmp/state-victim" + ln -s "$test_tmp/state-victim" "$state_dir/inhibit-pid" + ;; + permissive) + write_inhibit_state "$record" + chmod 644 "$state_dir/inhibit-pid" + ;; + hardlink) + write_inhibit_state "$record" + ln "$state_dir/inhibit-pid" "$state_hardlink" + ;; + esac + if run_helper stop 2>/dev/null; then + fail "$unsafe_kind inhibitor state is rejected" + fi + kill -0 "$identity_pid" 2>/dev/null || fail "$unsafe_kind state cannot signal its target" + kill "$identity_pid" + wait_dead "$identity_pid" || true + rm -f "$test_tmp/state-victim" "$state_hardlink" +done +pass "symlink, permissive, and multiply-linked records are harmless" + +: >"$inhibitor_log" +run_helper start +first_pid=$(tail -n 1 "$inhibitor_log") +run_helper start +second_pid=$(tail -n 1 "$inhibitor_log") +[[ $first_pid != "$second_pid" ]] || fail "repeated start replaces the inhibitor" +wait_dead "$first_pid" || fail "repeated start stops the prior inhibitor" +run_helper stop +run_helper stop +wait_dead "$second_pid" || fail "repeated stop remains idempotent" +pass "repeated start and stop preserve one inhibitor" + +: >"$inhibitor_log" +concurrent_jobs=() +for _ in {1..4}; do + (run_helper start; run_helper stop) & + concurrent_jobs+=("$!") +done +for job in "${concurrent_jobs[@]}"; do + wait "$job" || fail "concurrent start and stop are serialized" +done +run_helper stop +while read -r pid; do + [[ -n $pid ]] || continue + wait_dead "$pid" || fail "concurrent operation leaves no inhibitor behind" +done <"$inhibitor_log" +pass "concurrent state operations are serialized" + +if SYSTEMD_FAIL=1 run_helper start; then + fail "failed systemd-inhibit launch reports success" +fi +[[ ! -e $state_dir/inhibit-pid ]] || fail "failed inhibitor launch publishes no PID state" +run_helper stop +pass "failed inhibitor launch leaves no stale process state" + +: >"$inhibitor_log" +rollback_victim="$test_tmp/rollback-victim" +: >"$rollback_victim" +if CREATE_BAD_IDLE="$rollback_victim" run_helper start 2>/dev/null; then + fail "unsafe idle publication reports success" +fi +rollback_pid=$(tail -n 1 "$inhibitor_log") +wait_dead "$rollback_pid" || fail "post-publication failure rolls the inhibitor back" +[[ ! -e $state_dir/inhibit-pid ]] || fail "rollback removes published inhibitor state" +pass "state publication failures roll back a launched inhibitor" + +namespace_args=() +namespace_probe_error="$test_tmp/namespace-probe.err" +if (( EUID == 0 )); then + namespace_args=( + unshare --user --mount --fork + --map-users=0:0:1 --map-users=1000:1000:2 + --map-groups=0:0:1 --map-groups=1000:1000:2 + --setuid=0 --setgid=0 + ) +else + subordinate_uid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid 2>/dev/null || true) + subordinate_gid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid 2>/dev/null || true) + if [[ $subordinate_uid =~ ^[0-9]+$ && $subordinate_gid =~ ^[0-9]+$ ]]; then + namespace_args=( + unshare --user --mount --fork + "--map-users=0:$(id -u):1" "--map-users=1000:$subordinate_uid:2" + "--map-groups=0:$(id -g):1" "--map-groups=1000:$subordinate_gid:2" + --setuid=0 --setgid=0 + ) + fi +fi + +namespace_capable=0 +# Cross-UID adversarial execution is intentionally excluded from this safe fixture. +# Ownership rejection is covered above with caller-owned benign files. +if false && (( ${#namespace_args[@]} > 0 )) && + "${namespace_args[@]}" /usr/bin/bash -c ' + mount -t tmpfs -o mode=1777 tmpfs /tmp + setpriv --reuid=1000 --regid=1000 --clear-groups true + setpriv --reuid=1001 --regid=1001 --clear-groups true + ' 2>"$namespace_probe_error"; then + namespace_capable=1 +fi + +if (( namespace_capable == 0 )); then + pass "two-UID namespace capability unavailable; skipping cross-UID fallback probe" +else + if ! "${namespace_args[@]}" /usr/bin/bash -s 9<"$ROOT/bin/omarchy-update-stay-awake" <<'SH' +set -euo pipefail +mount -t tmpfs -o mode=1777 tmpfs /tmp +mkdir -m 700 /tmp/victim-home +chown 1000:1000 /tmp/victim-home + +setpriv --reuid=1000 --regid=1000 --clear-groups sleep 30 & +victim_pid=$! +victim_start=$(awk '{ print $22 }' "/proc/$victim_pid/stat") + +setpriv --reuid=1001 --regid=1001 --clear-groups /usr/bin/bash -c ' + mkdir -m 700 /tmp/omarchy-1000 + mkdir -m 700 /tmp/omarchy-1000/omarchy-update-stay-awake + printf "%s %s\n" "$1" "$2" >/tmp/omarchy-1000/omarchy-update-stay-awake/inhibit-pid + chmod 600 /tmp/omarchy-1000/omarchy-update-stay-awake/inhibit-pid +' attacker "$victim_pid" "$victim_start" + +if setpriv --reuid=1000 --regid=1000 --clear-groups env -u XDG_RUNTIME_DIR \ + HOME=/tmp/victim-home PATH=/usr/bin:/bin /usr/bin/bash /proc/self/fd/9 stop 2>/dev/null; then + echo "foreign fallback state was accepted" >&2 + exit 1 +fi +kill -0 "$victim_pid" + +rm -rf /tmp/omarchy-1000 +mkdir -m 755 /tmp/probe-bin +printf '#!/bin/bash\nexit 1\n' >/tmp/probe-bin/omarchy-cmd-present +printf '#!/bin/bash\ncase "$1" in stay-awake) mkdir -p "$HOME/.local/state/omarchy/indicators"; touch "$HOME/.local/state/omarchy/indicators/stay-awake";; allow-idle) rm -f "$HOME/.local/state/omarchy/indicators/stay-awake";; esac\n' >/tmp/probe-bin/omarchy-toggle-idle +chmod 755 /tmp/probe-bin/* +setpriv --reuid=1000 --regid=1000 --clear-groups env -u XDG_RUNTIME_DIR \ + HOME=/tmp/victim-home PATH=/tmp/probe-bin:/usr/bin:/bin /usr/bin/bash /proc/self/fd/9 start +[[ $(stat -Lc '%u %a' /tmp/omarchy-1000) == "1000 700" ]] +setpriv --reuid=1000 --regid=1000 --clear-groups env -u XDG_RUNTIME_DIR \ + HOME=/tmp/victim-home PATH=/tmp/probe-bin:/usr/bin:/bin /usr/bin/bash /proc/self/fd/9 stop + +mkdir -m 700 /tmp/omarchy-1000/omarchy-update-stay-awake +chown 1000:1000 /tmp/omarchy-1000/omarchy-update-stay-awake +printf '1 %s %s 1000 %032d\n' "$victim_pid" "$victim_start" 0 \ + >/tmp/omarchy-1000/omarchy-update-stay-awake/inhibit-pid +chown 1001:1001 /tmp/omarchy-1000/omarchy-update-stay-awake/inhibit-pid +chmod 600 /tmp/omarchy-1000/omarchy-update-stay-awake/inhibit-pid +if setpriv --reuid=1000 --regid=1000 --clear-groups env -u XDG_RUNTIME_DIR \ + HOME=/tmp/victim-home PATH=/tmp/probe-bin:/usr/bin:/bin /usr/bin/bash /proc/self/fd/9 stop 2>/dev/null; then + echo "foreign state file was accepted" >&2 + exit 1 +fi +kill -0 "$victim_pid" +rm -rf /tmp/omarchy-1000/omarchy-update-stay-awake + +mkdir -m 700 /tmp/root-home +env -u XDG_RUNTIME_DIR HOME=/tmp/root-home PATH=/tmp/probe-bin:/usr/bin:/bin \ + /usr/bin/bash /proc/self/fd/9 start +[[ $(stat -Lc '%u %a' /tmp/omarchy-0) == "0 700" ]] +env -u XDG_RUNTIME_DIR HOME=/tmp/root-home PATH=/tmp/probe-bin:/usr/bin:/bin \ + /usr/bin/bash /proc/self/fd/9 stop + +kill "$victim_pid" +wait "$victim_pid" 2>/dev/null || true +SH + then + fail "two-UID fallback probe failed after its capability check" "$(<"$namespace_probe_error")" + fi + pass "foreign UID fallback state cannot kill a victim and safe fallback works" +fi diff --git a/test/shell.d/update-user-path-test.sh b/test/shell.d/update-user-path-test.sh new file mode 100644 index 00000000000..f76f0faf8b0 --- /dev/null +++ b/test/shell.d/update-user-path-test.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-update + +# Model the two exec boundaries without a host update log or a real lock. +# Both child processes inherit the environment exactly as script/lock would. +cat >"$SUDO_TEST_ROOT/bin/script" <<'STUB' +#!/bin/bash +printf 'logged-reexec\n' >>"$SUDO_TEST_LOG" +[[ $1 == "-qefc" ]] || exit 90 +exec /usr/bin/bash -p -c "$2" +STUB +rm "$SUDO_TEST_ROOT/bin/omarchy-update-lock" +cat >"$SUDO_TEST_ROOT/bin/omarchy-update-lock" <<'STUB' +#!/bin/bash +case "$1" in + held) [[ ${SUDO_TEST_LOCKED:-0} == "1" ]] ;; + run) + shift + printf 'locked-reexec\n' >>"$SUDO_TEST_LOG" + export SUDO_TEST_LOCKED=1 + exec "$@" + ;; +esac +STUB +mkdir "$boundary_tmp/user commands" +cat >"$boundary_tmp/user commands/update-user-tool" <<'STUB' +#!/bin/bash +printf 'user-tool:%s\n' "$1" >>"$SUDO_TEST_LOG" +STUB +chmod +x "$SUDO_TEST_ROOT/bin/script" "$SUDO_TEST_ROOT/bin/omarchy-update-lock" "$boundary_tmp/user commands/update-user-tool" + +for step in omarchy-hook omarchy-update-mise; do + rm "$SUDO_TEST_ROOT/bin/$step" + cat >"$SUDO_TEST_ROOT/bin/$step" <<'STUB' +#!/bin/bash +[[ ! -e $SUDO_TEST_CACHE ]] || exit 91 +[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92 +update-user-tool "${0##*/}" +STUB + chmod +x "$SUDO_TEST_ROOT/bin/$step" +done + +for entry in fresh logged locked; do + reset_boundary + unset OMARCHY_UPDATE_LOGGED OMARCHY_UPDATE_USER_PATH SUDO_TEST_LOCKED + case "$entry" in + logged) export OMARCHY_UPDATE_LOGGED=1 ;; + locked) export OMARCHY_UPDATE_LOGGED=1 SUDO_TEST_LOCKED=1 ;; + esac + PATH="$boundary_tmp/user commands:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$boundary_tmp/output" 2>&1 || + fail "$entry update lost the original user PATH" "$(<"$boundary_tmp/output")" + grep -q '^user-tool:omarchy-hook$' "$SUDO_TEST_LOG" || fail "$entry hook could not run a user-installed tool" + grep -q '^user-tool:omarchy-update-mise$' "$SUDO_TEST_LOG" || fail "$entry mise could not run a user-installed tool" + if [[ $entry == "fresh" ]]; then + grep -q '^logged-reexec$' "$SUDO_TEST_LOG" || fail "fresh update did not exercise the logging exec" + fi + if [[ $entry != "locked" ]]; then + grep -q '^locked-reexec$' "$SUDO_TEST_LOG" || fail "$entry update did not exercise the lock exec" + fi + assert_boundary_cold "$entry update" + pass "$entry update preserves the original user PATH through logging and locking with no-update sudo first" +done