diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 92d7ae16..91695c69 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -24,12 +24,11 @@ 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 MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635 readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 @@ -73,26 +72,6 @@ verify_sudo_caller() { 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: @@ -116,67 +95,23 @@ 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 +# The sudoers rule is the only grant record. A missing file is distinct from +# an unreadable, unsafe, or administrator-modified file. +read_grant() { + local file contents + file=$(rule_file "$1") + [[ -e $file || -L $file ]] || return "$STATUS_INACTIVE" + verify_root_path "$file" && [[ -f $file ]] || return 2 + contents=$(/usr/bin/cat -- "$file") || return 2 + [[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2 + GRANT_NAME=${BASH_REMATCH[1]} + GRANT_DEADLINE=${BASH_REMATCH[2]} + valid_account_name "$GRANT_NAME" || return 2 } 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-} @@ -189,81 +124,40 @@ classify_generated_rule() { 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}" + return 0 else return 1 fi } -remove_known_legacy_rules() { +cleanup_uid_locked() { + local file + file=$(rule_file "$1") + [[ -e $file || -L $file ]] || return 0 + verify_root_path "$file" && classify_generated_rule "$file" || return 1 + /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]] +} + +cleanup_all_locked() { local file classification failed=0 - shopt -s nullglob + verify_root_path /etc/sudoers.d || return 1 for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + [[ -e $file || -L $file ]] || continue if classify_generated_rule "$file"; then - # 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 + if ! /usr/bin/rm -f -- "$file" || [[ -e $file || -L $file ]]; then 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() { +verify_root_path() { local file=$1 owner mode canonical current - [[ -f $file && ! -L $file ]] || return 1 + [[ ( -f $file || -d $file ) && ! -L $file ]] || return 1 canonical=$(/usr/bin/realpath -e -- "$file") || return 1 [[ $canonical == "$file" ]] || return 1 owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1 @@ -286,10 +180,10 @@ verify_root_policy_file() { verify_boot_cleanup() { local active_rules hook [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 - verify_root_policy_file "$BOOT_CLEANUP_FILE" || return 1 + verify_root_path "$BOOT_CLEANUP_FILE" || return 1 active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1 [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1 - verify_root_policy_file "$PACKAGE_HOOK" || return 1 + verify_root_path "$PACKAGE_HOOK" || return 1 hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1 [[ $hook == '[Trigger] Operation = Upgrade @@ -313,166 +207,113 @@ package_removing_locked() { 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 +migration_complete() { + [[ -f $MIGRATION_MARKER && ! -s $MIGRATION_MARKER ]] && verify_root_path "$MIGRATION_MARKER" +} + +migrate_locked() { + local directory + if migration_complete; then + return 0 fi - printf '%s' "$tmp" + [[ ! -e $MIGRATION_MARKER && ! -L $MIGRATION_MARKER ]] || return 1 + verify_root_path /var/lib || return 1 + for directory in /var/lib/omarchy /var/lib/omarchy/migrations; do + if [[ ! -e $directory && ! -L $directory ]]; then + /usr/bin/install -d -o root -g root -m 0755 -- "$directory" || return 1 + fi + verify_root_path "$directory" || return 1 + done + cleanup_all_locked || return 1 + # The empty marker is written only after cleanup succeeds, under the same + # machine lock. Later accounts need no sudo and cannot revoke newer grants. + /usr/bin/install -o root -g root -m 0644 /dev/null "$MIGRATION_MARKER" } -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" -} - -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" +# Old callbacks only remove an expired current rule. Renewing a grant never +# needs a second state file or a stored timer generation to identify it. +expire_locked() { + local status now + if read_grant "$1"; then + now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 + [[ $now < $GRANT_DEADLINE ]] && return 0 + cleanup_uid_locked "$1" else - echo "Could not revoke passwordless sudo after a failed grant; expiry jobs remain armed. Administrator cleanup is required." >&2 + status=$? + if (( status == STATUS_INACTIVE )); then + return 0 + else + cleanup_uid_locked "$1" + fi fi - /usr/bin/rm -f -- "$pending_state" || true - return 1 -} - -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 - } - - old_timer=$(read_state_timer "$uid" 2>/dev/null || true) - token=$(/usr/bin/tr -d '-' = 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 + local status now + resolve_account "$1" || return 2 + if read_grant "$1"; then + [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2 + now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 + if [[ $now < $GRANT_DEADLINE ]]; then + return 0 + fi + cleanup_uid_locked "$1" || return 2 return "$STATUS_INACTIVE" - 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 status=$? - (( status == STATUS_INACTIVE )) + return "$status" fi } +finish_enable() { + local status=$? + trap - EXIT HUP INT TERM + if (( status != 0 )); then + if cleanup_uid_locked "$uid"; then + [[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true + else + echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2 + fi + fi + [[ -z $pending ]] || /usr/bin/rm -f -- "$pending" + exit "$status" +} + +enable_locked() ( + local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status + resolve_account "$uid" && valid_minutes "$minutes" || return 1 + verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1 + file=$(rule_file "$uid") + if read_grant "$uid"; then + [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1 + else + status=$? + (( status == STATUS_INACTIVE )) || return 1 + fi + trap finish_enable EXIT + omarchy_security_install_signal_exit_traps + now=$(/usr/bin/date +%s) || return 1 + expires=$((now + 10#$minutes * 60)) + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1 + /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1 + /usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1 + /usr/sbin/visudo -cf "$pending" >/dev/null || return 1 + token=$(/usr/bin/tr -d '-' /dev/null || true + wait "${children[@]}" 2>/dev/null || true + fi + rm -rf "$test_tmp" + exit "$status" +} +trap cleanup_grant_fixture EXIT +export TEST_GRANT_ROOT=$test_tmp +mkdir -p "$test_tmp/bin" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/var/lib" "$test_tmp/hooks" +cat >"$test_tmp/bin/mock" <<'STUB' +#!/bin/bash +set -euo pipefail +name=${0##*/} +printf '%s %s\n' "$name" "$*" >>"$TEST_GRANT_ROOT/commands" +case "$name" in + stat) + path=${@: -1} + owner=0 + mode=$(/usr/bin/stat -Lc '%a' -- "$path") + [[ $path != /tmp ]] || mode=755 + [[ $path != "${TEST_BAD_PATH:-}" ]] || owner=1000 + case $2 in + '%u') echo "$owner" ;; + '%a') echo "$mode" ;; + '%u %a') echo "$owner $mode" ;; + *) exec /usr/bin/stat "$@" ;; + esac + ;; + chown) exit 0 ;; + install) + args=() + while (($#)); do + case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac + done + exec /usr/bin/install "${args[@]}" + ;; + rm) + for path in "$@"; do + if [[ ${TEST_DELETE_FAIL:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/99-omarchy-nopasswd-1000" ]]; then exit 1; fi + done + exec /usr/bin/rm "$@" + ;; + mv) + [[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1 + /usr/bin/mv "$@" + [[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing" + ;; + systemd-run) + [[ ${TEST_TIMER_FAIL:-0} != 1 ]] || exit 1 + if [[ ${TEST_CANCEL_ENABLE:-0} == 1 ]]; then kill -TERM "$PPID"; fi + ;; + systemctl) + [[ $1 != "is-active" || ${TEST_INACTIVE_TIMER:-0} != 1 ]] + ;; + date) + if [[ ${TEST_EXPIRED:-0} == 1 && $* == '-u +%Y%m%d%H%M%SZ' ]]; then echo 99991231235959Z; else /usr/bin/date "$@"; fi + ;; + getent) printf '%s:x:1000:1000:Test:/nonexistent:/bin/bash\n' "${TEST_ACCOUNT:-audituser}" ;; + sudo) + if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi + if [[ ${1:-} == -k ]]; then exit 0; fi + if [[ ${1:-} == -N ]]; then shift; fi + if [[ ${1:-} == -- ]]; then shift; fi + if [[ ${TEST_MIGRATION:-0} == 1 ]]; then + [[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1 + TEST_EUID=0 /usr/bin/bash -p "$@" + else + [[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}" + fi + ;; + gum) exit 1 ;; + *) exit 99 ;; +esac +STUB +chmod +x "$test_tmp/bin/mock" +for name in stat chown install rm mv systemd-run systemctl date getent sudo gum; do + ln -s mock "$test_tmp/bin/$name" +done + +python3 - "$ROOT" "$test_tmp" <<'PY' +from pathlib import Path +import sys +root, temp = map(Path, sys.argv[1:]) +for name in ('omarchy-sudo-passwordless', 'omarchy-security-functions'): + text = (root/'bin'/name).read_text() + for path in ('/etc/', '/var/lib', '/run/', '/usr/share/libalpm/hooks'): + target = str(temp/'hooks') if path == '/usr/share/libalpm/hooks' else str(temp) + path + text = text.replace(path, target) + text = text.replace('((EUID == 0))', '((${TEST_EUID:-1} == 0))') + for command in ('stat', 'chown', 'install', 'rm', 'mv', 'systemd-run', 'systemctl', 'date', 'getent', 'sudo', 'gum'): + text = text.replace('/usr/bin/' + command, str(temp/'bin'/command)) + (temp/name).write_text(text) + (temp/name).chmod(0o755) +PY +library="$test_tmp/functions.sh" +{ + printf 'source %q\n' "$test_tmp/omarchy-security-functions" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$test_tmp/omarchy-sudo-passwordless" +} >"$library" +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" +sed "s|/etc/|$test_tmp/etc/|g" "$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +: >"$test_tmp/commands" + +# New subshell per case prevents one test's overrides and readonly constants +# from affecting the next. External commands log enough to verify ordering. +assert_status() { + local expected=$1 actual=0 + shift + "$@" || actual=$? + (( actual == expected )) || fail "expected status $expected, got $actual from $*" +} +reset_grant() { + rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing" + : >"$test_tmp/commands" +} diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh index 06965215..173ea1b0 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -1,458 +1,163 @@ #!/bin/bash set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" -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 - -function_prefix() { - printf 'source %q\n' "$security_library_path" - awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$command_path" -} - -# 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 + source "$library" + for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || exit 1; done + for minutes in 0 1441 -1 1m '' 18446744073709551617; do ! valid_minutes "$minutes" || exit 1; done + for name in audituser 'buildbot$'; do valid_account_name "$name" || exit 1; done + for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done + ! valid_uid 18446744073709551617 ) -pass "passwordless sudo validates a bounded positive duration" +pass "duration and account validation retains bounded inputs and trailing-dollar usernames" -# 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" +( + source "$library" + assert_status 2 root_dispatch __status 1000 + assert_status 2 env TEST_EUID=0 SUDO_UID=1001 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 + assert_status 3 env TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 +) +pass "internal actions reject missing root and mismatched sudo identity" -# 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" - -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 -if [[ ${1:-} == -h ]]; then - echo 'usage: sudo [-ABbEHkNnPS] command' - exit 0 -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 -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" +for status in 1 2 3; do + : >"$test_tmp/commands" + result=0 + TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 || result=$? + if (( status == 3 )); then + (( result == 0 )) && grep -q '^gum confirm ' "$test_tmp/commands" || fail "inactive status must allow confirmation" + else + (( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement" fi - [[ ! -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" + grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization" + [[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization" done +pass "public status distinguishes inactive from errors and revokes authorization on exit" -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" +printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\nunset BASH_ENV\n' >"$test_tmp/startup" +: >"$test_tmp/commands" +if TEST_STARTUP_MARKER="$test_tmp/startup-ran" BASH_ENV="$test_tmp/startup" bash "$test_tmp/omarchy-sudo-passwordless" -p >/dev/null 2>&1; then + fail "ordinary Bash with a decoy -p was accepted" fi -[[ -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" +[[ -f $test_tmp/startup-ran && ! -s $test_tmp/commands ]] || fail "startup rejection must precede sudo" +pass "startup validation rejects ordinary Bash before authorization" -# 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" - -# 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" - -# 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" - -# 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" -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 + source "$library" + enable_locked 1000 15 + read_grant 1000 + [[ $GRANT_NAME == audituser && $(stat -c '%a' "$(rule_file 1000)") == 440 ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + expiry=$(sed -n 's/^systemd-run .*--on-calendar=@\([0-9]*\).*$/\1/p' "$test_tmp/commands" | tail -1) + [[ $GRANT_DEADLINE == "$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ)" ]] + if [[ -n ${OMARCHY_TEST_SUDOERS:-} ]]; then + [[ -x $OMARCHY_TEST_SUDOERS ]] || fail "OMARCHY_TEST_SUDOERS must name an executable" + printf 'root:x:0:0:root:/root:/bin/bash\naudituser:x:1000:1000:Test:/nonexistent:/bin/bash\n' >"$test_tmp/passwd" + printf 'root:x:0:\naudituser:x:1000:\n' >"$test_tmp/group" + { printf 'audituser ALL=(ALL) ALL\n'; cat "$(rule_file 1000)"; } >"$test_tmp/policy" + for offset in -1 1; do + when=$(/usr/bin/date -u -d "@$((expiry + offset))" +%Y%m%d%H%M%SZ) + "$OMARCHY_TEST_SUDOERS" -p "$test_tmp/passwd" -P "$test_tmp/group" -T "$when" audituser /usr/bin/true <"$test_tmp/policy" >"$test_tmp/policy-result" + if (( offset < 0 )); then + ! grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy requires a password before expiry" + else + grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy remains passwordless after expiry" + fi + done + pass "native sudoers evaluation requires authentication after the generated deadline" fi -done -[[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" + assert_status 0 status_locked 1000 + TEST_INACTIVE_TIMER=1 assert_status 0 status_locked 1000 + [[ ! -e $test_tmp/var/lib/omarchy/sudo-passwordless ]] + ! compgen -G "$test_tmp/etc/sudoers.d/.omarchy-nopasswd.*" +) +pass "one complete mode-0440 sudoers rule holds the deadline with no separate grant state" -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 "$library" + before=$(cat "$(rule_file 1000)") + expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff + [[ $(cat "$(rule_file 1000)") == "$before" ]] + enable_locked 1000 30 + renewed=$(cat "$(rule_file 1000)") + [[ $renewed != "$before" ]] + expire_locked 1000 + [[ $(cat "$(rule_file 1000)") == "$renewed" ]] + TEST_EXPIRED=1 expire_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "legacy and current callbacks preserve renewed grants and remove expired ones" + +( + source "$library" + enable_locked 1000 1 + assert_status 2 env TEST_EXPIRED=1 TEST_DELETE_FAIL=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 + [[ -e $(rule_file 1000) ]] + TEST_EXPIRED=1 assert_status 3 status_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "expired status reports cleanup failure separately from confirmed inactivity" + +for failure in TEST_TIMER_FAIL TEST_INACTIVE_TIMER TEST_PUBLISH_FAIL TEST_POST_PUBLISH_FAIL TEST_CANCEL_ENABLE; do + reset_grant + expected=1 + if [[ $failure == "TEST_CANCEL_ENABLE" ]]; then expected=143; fi ( - source "$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" - - ( - 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" + source "$library" + enable_locked 1000 15 + assert_status "$expected" env "$failure=1" TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 30 + [[ ! -e $(rule_file 1000) ]] + ) done -pass "settings package transitions revoke grants and preserve unrelated configuration" +pass "timer, publication, post-publication and cancellation failures revoke renewed access" -# 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 -wait -[[ $(wc -c <"$test_tmp/results") == 8 ]] || fail "concurrent passwordless operations serialize" -pass "passwordless sudo serializes concurrent operations" +reset_grant +( + source "$library" + TEST_POST_PUBLISH_FAIL=1 TEST_DELETE_FAIL=1 assert_status 1 enable_locked 1000 15 + [[ -e $(rule_file 1000) ]] + ! grep -q '^systemctl stop ' "$test_tmp/commands" +) +pass "failed policy deletion retains the timer and reports failure" -# 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" +reset_grant +( + source "$library" + printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$(rule_file 1000)" + cp "$(rule_file 1000)" "$test_tmp/admin-rule" + assert_status 2 status_locked 1000 + assert_status 1 enable_locked 1000 15 + assert_status 1 cleanup_uid_locked 1000 + cmp "$(rule_file 1000)" "$test_tmp/admin-rule" + rm "$(rule_file 1000)" + ln -s "$test_tmp/admin-rule" "$(rule_file 1000)" + assert_status 2 status_locked 1000 + assert_status 1 cleanup_uid_locked 1000 + [[ -L $(rule_file 1000) ]] +) +pass "grant operations preserve administrator policies and reject symlinks" + +reset_grant +( + source "$library" + TEST_BAD_PATH="$test_tmp/etc/sudoers.d" assert_status 1 enable_locked 1000 15 + [[ ! -e $(rule_file 1000) ]] + rm "$PACKAGE_HOOK" + assert_status 1 enable_locked 1000 15 +) +pass "publication requires trusted paths and the packaged cleanup hook" + +reset_grant +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" +( + source "$library" + TEST_ACCOUNT='buildbot$' enable_locked 1000 15 + read_grant 1000 + [[ $GRANT_NAME == 'buildbot$' ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + cleanup_uid_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "trailing-dollar accounts publish and revoke valid native policy" diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index b3198405..a0be9d4f 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -2,234 +2,135 @@ 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 "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" ( 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 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" - 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" + printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" + TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked + [[ -e $(rule_file 1000) ]] + cleanup_all_locked + [[ ! -e $(rule_file 1000) && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom ]] + ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*" +) +pass "legacy cleanup removes generated orphan rules and preserves custom policy" -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"; } +# Run the actual migration queue for separate temporary homes. Sudo only calls +# the mapped helper and can be refused without requesting host authorization. +mkdir -p "$test_tmp/source/migrations" +sed "s|/usr/bin/omarchy-sudo-passwordless|$test_tmp/omarchy-sudo-passwordless|g" \ + "$ROOT/migrations/1788163635.sh" >"$test_tmp/source/migrations/1788163635.sh" +printf 'echo "later migration ran"\n' >"$test_tmp/source/migrations/1788163636.sh" +run_migrations() { + TEST_MIGRATION=1 OMARCHY_PATH="$test_tmp/source" OMARCHY_MIGRATION_STATE="$test_tmp/$1" \ + PATH="$test_tmp/bin:$PATH" /usr/bin/bash "$ROOT/bin/omarchy-migrate" >"$test_tmp/migrations.log" 2>&1 } +marker="$test_tmp/var/lib/omarchy/migrations/1788163635" +( + source "$library" + printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" + TEST_DELETE_FAIL=1 assert_status 1 run_migrations first + [[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]] + run_migrations first + [[ -f $marker && -f $test_tmp/first/1788163636.sh ]] + enable_locked 1000 15 + cp "$(rule_file 1000)" "$test_tmp/renewed" + : >"$test_tmp/commands" + TEST_NO_SUDO=1 run_migrations second + [[ -f $test_tmp/second/1788163636.sh ]] + ! grep -q '^sudo ' "$test_tmp/commands" + cmp "$(rule_file 1000)" "$test_tmp/renewed" +) +pass "migration completion is machine-wide, retryable, and needs no sudo for later users" ( source "$library" - 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" + TEST_BAD_PATH="$marker" assert_status 1 migration_complete + rm "$marker" + ln -s "$test_tmp/renewed" "$marker" + assert_status 1 migration_complete + assert_status 1 migrate_locked + [[ -L $marker ]] + rm "$marker" +) +pass "migration checks marker ownership and rejects symlinks" +# Keep real package scripts in the contract: source and packaging share the +# same lock and blocker, including the legacy scriptlet fallback. pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs} [[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds -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" +for name in omarchy-settings omarchy-settings-dev; do + script="$pkgs_path/$name/$name.install" + [[ -f $script ]] || fail "set OMARCHY_PKGS_PATH to the companion package checkout" + sed -e "s|/etc/|$test_tmp/etc/|g" -e "s|/run|$test_tmp/run|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \ + "$script" >"$test_tmp/$name.install" + reset_grant + ( + source "$library" + source "$test_tmp/$name.install" + _etc_overrides_apply() { :; } + enable_locked 1000 15 + TEST_DELETE_FAIL=1 assert_status 1 pre_remove + [[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]] + assert_status 1 enable_locked 1000 15 + pre_remove && post_remove + [[ ! -e $(rule_file 1000) ]] + post_install + [[ ! -e $REMOVAL_BLOCKER ]] + enable_locked 1000 15 + pre_upgrade && post_upgrade + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + ) +done +pass "both settings packages revoke grants, block publication, and recover on installation" -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")" +reset_grant +# Hold the source lock, then start package removal. A native flock on the +# mapped file must serialize both implementations. +cat >"$test_tmp/worker" <<'WORKER' +#!/bin/bash +set -euo pipefail +source "$TEST_LIBRARY" +critical() { + touch "$TEST_GRANT_ROOT/entered" + for (( attempt=0; attempt<500; attempt++ )); do + [[ ! -e $TEST_GRANT_ROOT/release ]] || break + sleep 0.01 + done + [[ -e $TEST_GRANT_ROOT/release ]] || return 1 + enable_locked 1000 15 } -with_root_lock enable_locked 1000 15 +with_root_lock critical 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 +TEST_LIBRARY="$library" /usr/bin/bash "$test_tmp/worker" >"$test_tmp/publisher.log" 2>&1 & +publisher=$! +children+=("$publisher") +for (( attempt=0; attempt<200; attempt++ )); do + [[ ! -e $test_tmp/entered ]] || break + sleep 0.01 done -[[ -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 +[[ -f $test_tmp/entered ]] || fail "publisher failed to acquire the lock" +/usr/bin/bash -euo pipefail -c 'source "$1"; pre_remove; post_remove' bash "$test_tmp/omarchy-settings.install" >"$test_tmp/removal.log" 2>&1 & +removal=$! +children+=("$removal") +touch "$test_tmp/release" +wait "$publisher" || fail "publisher failed" "$(cat "$test_tmp/publisher.log")" +wait "$removal" || fail "removal failed" "$(cat "$test_tmp/removal.log")" 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" +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] +[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] +pass "native lock serializes grant publication with package removal" -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" +# systemd-tmpfiles operates on an explicit disposable root, never the host. +reset_grant +: >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" +: >"$test_tmp/etc/sudoers.d/unrelated" +rule='r! /etc/sudoers.d/99-omarchy-nopasswd-*' +/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --inline "$rule" +[[ -f $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "routine tmpfiles shortened a live grant" +/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline "$rule" +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 && -f $test_tmp/etc/sudoers.d/unrelated ]] || fail "boot cleanup boundary" +pass "native boot cleanup removes grants while routine tmpfiles preserves them"