From 5bfc8b5cc3403971388e03c192e0afec9b570574 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Tue, 8 Sep 2026 19:47:11 +0200 Subject: [PATCH 1/4] Update packaging companion instructions for clarity Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/passwordless-sudo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 0e805e6a..9c58b291 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -12,7 +12,7 @@ Each new expiry callback carries its timer identity. A delayed predecessor canno ## 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. +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. From c46f321680744bc8a53c069e4571595fdabde067 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 10 Sep 2026 22:01:09 +0100 Subject: [PATCH 2/4] Simplify passwordless sudo grant lifecycle --- bin/omarchy-sudo-passwordless | 420 ++++--------- docs/passwordless-sudo.md | 10 +- migrations/1788163635.sh | 8 +- .../fixtures/passwordless-sudo-test.sh | 125 ++++ test/shell.d/nopasswd-sudo-expiry-test.sh | 579 +++++------------- .../passwordless-grant-lifecycle-test.sh | 327 ++++------ 6 files changed, 525 insertions(+), 944 deletions(-) create mode 100644 test/shell.d/fixtures/passwordless-sudo-test.sh 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" From 3eb314231358c4206d51a818df98ffb18136e7a4 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Wed, 16 Sep 2026 22:55:37 +0100 Subject: [PATCH 3/4] Recognize legacy sudo grants for any account name The legacy command wrote the caller's unvalidated name into both the sudoers filename and the rule. Cleanup applied the current lower-case account pattern to that suffix, so an exact legacy grant for an account such as Alice was classified as administrator policy, left active, and the machine-wide migration marker was written anyway. Match a legacy grant by its exact filename and rule relationship instead of the account policy, and cover it in the lifecycle suite through both the unit cleanup and the real migration runner. The account pattern itself stays lower-case: sudoers reads an upper-case word such as ALICE as a User_Alias reference, and ALL as every user, so such names must never reach the generated rule. Pin that with a test. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-sudo-passwordless | 31 +++++++++++-------- docs/passwordless-sudo.md | 2 +- test/shell.d/nopasswd-sudo-expiry-test.sh | 7 +++-- .../passwordless-grant-lifecycle-test.sh | 12 +++++-- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 91695c69..3b0af89d 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -56,9 +56,10 @@ resolve_account() { 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. + # Sudoers has metacharacters, and it reads an upper-case word such as ALICE + # as an alias reference rather than a user. Accounts use this portable + # lower-case subset; refusing anything else is safer than attempting to + # quote privileged policy syntax. valid_account_name "$ACCOUNT_NAME" || return 1 ACCOUNT_UID=$((10#$uid)) } @@ -109,6 +110,8 @@ read_grant() { valid_account_name "$GRANT_NAME" || return 2 } +# Returns 0 for a rule this command generated, 1 for anything else under the +# owned prefix (preserved as administrator policy), and 2 when unreadable. classify_generated_rule() { local file=$1 suffix contents name @@ -116,18 +119,20 @@ classify_generated_rule() { 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 + # The legacy command wrote the caller's unvalidated name into both the + # filename and the rule. That exact relationship is its fingerprint, so an + # account the current policy would reject still has its old grant removed. + if [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then return 0 - else - return 1 fi + + [[ $suffix =~ ^[0-9]+$ ]] || return 1 + name=${contents%' ALL=(ALL) NOPASSWD: ALL'} + if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then + return 0 + fi + name=${contents%%' ALL=(ALL) NOTAFTER='*} + valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]] } cleanup_uid_locked() { diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 39355504..1a846995 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -10,7 +10,7 @@ An internal status result is `0` for an active, validated grant and `3` for conf Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone. -Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted; generated legacy policy is removed conservatively and administrator-modified policy is preserved by the migration. +Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up; administrator-modified policy under the owned prefix is preserved by the migration. ## Package ownership diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh index 173ea1b0..d6b1a505 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -9,7 +9,8 @@ source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" 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 + # Upper-case words are sudoers alias references, so ALICE must never publish. + for name in 'a$b' '$' 'a b' 'a#b' Alice ALICE ALL aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done ! valid_uid 18446744073709551617 ) pass "duration and account validation retains bounded inputs and trailing-dollar usernames" @@ -159,5 +160,7 @@ cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/ /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null cleanup_uid_locked 1000 [[ ! -e $(rule_file 1000) ]] + TEST_ACCOUNT=ALICE assert_status 1 enable_locked 1000 15 + [[ ! -e $(rule_file 1000) ]] ) -pass "trailing-dollar accounts publish and revoke valid native policy" +pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish" diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index a0be9d4f..91a0ba89 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -8,14 +8,20 @@ source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" source "$library" printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" + # The legacy command never validated the account name, so a manual or NSS + # account outside the current policy still has its exact old grant removed. + printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" + printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" 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 ]] + [[ ! -e $(rule_file 1000) && ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice ]] + [[ -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten ]] ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*" + rm "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" ) -pass "legacy cleanup removes generated orphan rules and preserves custom policy" +pass "legacy cleanup removes generated orphan rules for any account and preserves custom policy" # Run the actual migration queue for separate temporary homes. Sudo only calls # the mapped helper and can be refused without requesting host authorization. @@ -31,10 +37,12 @@ marker="$test_tmp/var/lib/omarchy/migrations/1788163635" ( source "$library" printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" + printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" TEST_DELETE_FAIL=1 assert_status 1 run_migrations first [[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]] run_migrations first [[ -f $marker && -f $test_tmp/first/1788163636.sh ]] + [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice ]] enable_locked 1000 15 cp "$(rule_file 1000)" "$test_tmp/renewed" : >"$test_tmp/commands" From ea617b9126e9d3ae88544687080af0b6849d205a Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Wed, 16 Sep 2026 23:23:23 +0100 Subject: [PATCH 4/4] Quarantine unrecognized policy under the generated sudoers prefix Matching a legacy grant by its filename and rule relationship is not a complete fingerprint: the legacy writer took the filename from $USER but produced the rule with echo, and under BASH_ENV with xpg_echo a name such as ali\0143e yields an alice rule in a mismatched file. Preserving that as administrator policy let the migration certify success with an unrestricted grant still live until the next boot. The prefix is reserved anyway: boot cleanup and the package hook remove everything under it. Move any file the classifier does not recognize into a fresh root-only directory under /var/lib/omarchy/sudoers-quarantine/ as `policy`, with the original name stored beside it, so nothing there stays live, the administrator keeps the content, and a legacy filename already close to NAME_MAX still fits. An untrusted quarantine directory keeps the migration pending. Cover the mismatched and maximum-length legacy files in the unit cleanup and through the real migration runner. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-sudo-passwordless | 23 ++++++++++- docs/passwordless-sudo.md | 2 +- .../passwordless-grant-lifecycle-test.sh | 38 +++++++++++++++---- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 3b0af89d..58a9b15c 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -29,6 +29,7 @@ readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing readonly MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635 +readonly QUARANTINE_DIR=/var/lib/omarchy/sudoers-quarantine readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 @@ -143,6 +144,24 @@ cleanup_uid_locked() { /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]] } +# The generated prefix is reserved: boot cleanup and the package hook already +# remove everything in it, and the legacy writer could produce a rule whose +# body differs from its filename. Nothing unrecognized may stay live there, but +# its content is kept for the administrator instead of being deleted. +quarantine_foreign_rule() { + local file=$1 target + if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 -- /var/lib/omarchy || return 1 + fi + omarchy_security_prepare_private_root_directory "$QUARANTINE_DIR" /var/lib/omarchy || return 1 + # A legacy filename can already be close to NAME_MAX, so the destination + # name is fixed and the original name travels beside it. + target=$(/usr/bin/mktemp -d "$QUARANTINE_DIR/XXXXXXXXXX") || return 1 + /usr/bin/printf '%s\n' "${file##*/}" >"$target/name" || return 1 + /usr/bin/mv -fT -- "$file" "$target/policy" && [[ ! -e $file && ! -L $file ]] || return 1 + echo "Moved unrecognized sudoers policy $file to $target/policy" >&2 +} + cleanup_all_locked() { local file classification failed=0 verify_root_path /etc/sudoers.d || return 1 @@ -154,7 +173,9 @@ cleanup_all_locked() { fi else classification=$? - (( classification == 1 )) || failed=1 + if (( classification != 1 )) || ! quarantine_foreign_rule "$file"; then + failed=1 + fi fi done return "$failed" diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 1a846995..4aa6e31d 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -10,7 +10,7 @@ An internal status result is `0` for an active, validated grant and `3` for conf Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone. -Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up; administrator-modified policy under the owned prefix is preserved by the migration. +Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under `/var/lib/omarchy/sudoers-quarantine/`, as `policy` with the original name stored beside it, rather than leaving it live or deleting its content. ## Package ownership diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index 91a0ba89..71f34b24 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -4,6 +4,19 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" +quarantine="$test_tmp/var/lib/omarchy/sudoers-quarantine" +quarantined_policy() { + local entry + for entry in "$quarantine"/*/; do + if [[ $(cat "$entry/name") == "$1" ]]; then + cat "$entry/policy" + return 0 + fi + done + return 1 +} +# The old writer accepted any $USER, so a basename can sit just under NAME_MAX. +long_suffix=$(printf 'l%.0s' {1..223}) ( source "$library" printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" @@ -11,17 +24,23 @@ source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" # The legacy command never validated the account name, so a manual or NSS # account outside the current policy still has its exact old grant removed. printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" + # The legacy writer produced the body with echo. Under BASH_ENV with + # xpg_echo, USER='ali\0143e' yields this filename with an 'alice' rule, so + # a suffix/body mismatch does not prove administrator authorship. + printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-ali\\0143e" + printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-$long_suffix" printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" - printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" 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-Alice ]] - [[ -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten ]] - ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*" - rm "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" + ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*" + [[ $(stat -c '%a' "$quarantine") == 700 ]] + [[ $(quarantined_policy '99-omarchy-nopasswd-ali\0143e') == 'alice ALL=(ALL) NOPASSWD: ALL' ]] + [[ $(quarantined_policy "99-omarchy-nopasswd-$long_suffix") == 'alice ALL=(ALL) NOPASSWD: ALL' ]] + [[ $(quarantined_policy 99-omarchy-nopasswd-custom) == 'admin ALL=(ALL) NOPASSWD: /usr/bin/true' ]] + (( $(ls -A "$quarantine" | wc -l) == 3 )) ) -pass "legacy cleanup removes generated orphan rules for any account and preserves custom policy" +pass "legacy cleanup removes generated rules for any account and quarantines everything else in the prefix" # Run the actual migration queue for separate temporary homes. Sudo only calls # the mapped helper and can be refused without requesting host authorization. @@ -36,13 +55,18 @@ run_migrations() { marker="$test_tmp/var/lib/omarchy/migrations/1788163635" ( source "$library" + # A quarantine that cannot be trusted keeps the migration pending. + printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch" + TEST_BAD_PATH="$test_tmp/var/lib/omarchy" assert_status 1 run_migrations first + [[ ! -e $marker && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch ]] printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" TEST_DELETE_FAIL=1 assert_status 1 run_migrations first [[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]] run_migrations first [[ -f $marker && -f $test_tmp/first/1788163636.sh ]] - [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice ]] + ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*" + [[ $(quarantined_policy 99-omarchy-nopasswd-mismatch) == 'alice ALL=(ALL) NOPASSWD: ALL' ]] enable_locked 1000 15 cp "$(rule_file 1000)" "$test_tmp/renewed" : >"$test_tmp/commands"