Merge pull request #9467 from AFOliveira/codex/om-sec-12-update-inhibitor-identity
[codex] OM-SEC-12: Bind update inhibitor cleanup to process identity Reported-by: Afonso "AFOliveira" Oliveira
This commit is contained in:
38 files changed
+3548
-422
No files matched your search
Executable
+200
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
copy_boundary_file bin/omarchy-channel-set
|
||||
copy_boundary_file bin/omarchy-refresh-pacman
|
||||
copy_boundary_file bin/omarchy-update
|
||||
export OMARCHY_UPDATE_LOGGED=1
|
||||
|
||||
# Relocate the package root into the fixture, including the explicit handoff
|
||||
# from the development checkout. All privileged operations remain stand-ins.
|
||||
python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$SUDO_TEST_ROOT" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
p = Path(sys.argv[1])
|
||||
p.write_text(p.read_text().replace('/usr/share/omarchy', sys.argv[2]))
|
||||
PY
|
||||
|
||||
for command in omarchy-dev-link omarchy-dev-unlink omarchy-state gum git; do
|
||||
cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
step=${0##*/}
|
||||
printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG"
|
||||
case "$step" in
|
||||
omarchy-dev-link|omarchy-dev-unlink) sudo /usr/bin/true ;;
|
||||
git)
|
||||
[[ $1 == "clone" ]] || exit 90
|
||||
/usr/bin/cp -a "$SUDO_TEST_ROOT" "${@: -1}"
|
||||
mkdir -p "${@: -1}/.git" "${@: -1}/shell"
|
||||
;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/$command"
|
||||
done
|
||||
|
||||
assert_scoped_channel() {
|
||||
local label=$1
|
||||
assert_boundary_cold "$label"
|
||||
python3 - "$SUDO_TEST_LOG" <<'PY'
|
||||
import sys
|
||||
events = open(sys.argv[1]).read().splitlines()
|
||||
assert events[0] == 'sudo -k', events
|
||||
sudo = [event for event in events if event.startswith('sudo ')]
|
||||
assert all(event in ('sudo -h', 'sudo -k') or event.startswith('sudo -N ') for event in sudo), events
|
||||
hooks = [i for i, event in enumerate(events) if event.startswith('step:omarchy-hook ')]
|
||||
assert len(hooks) == 2, events
|
||||
assert events[hooks[0]] == 'step:omarchy-hook pre-refresh-pacman', events
|
||||
assert events[hooks[1]] == 'step:omarchy-hook post-update', events
|
||||
assert events[hooks[0] - 1] == 'sudo -k' and events[hooks[0] + 1] == 'sudo -k', events
|
||||
transaction = next(i for i, event in enumerate(events) if event.startswith('step:pacman '))
|
||||
assert hooks[0] < transaction, events
|
||||
assert not any(event.startswith('sudo -N ') for event in events[hooks[1]:]), events
|
||||
PY
|
||||
}
|
||||
|
||||
run_channel() {
|
||||
"$OMARCHY_PATH/bin/omarchy-channel-set" "$@" >"$boundary_tmp/output" 2>&1
|
||||
}
|
||||
for channel in stable rc edge dev; do
|
||||
reset_boundary
|
||||
run_channel "$channel" || fail "$channel failed" "$(<"$boundary_tmp/output")"
|
||||
assert_scoped_channel "$channel"
|
||||
pass "$channel starts cold, authorizes only individual commands, runs the refresh hook cold before its transaction and exits cold"
|
||||
done
|
||||
|
||||
reset_boundary
|
||||
wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo"
|
||||
mv "$wrapper" "$boundary_tmp/saved-wrapper"
|
||||
if run_channel dev; then fail "an old checkout without the wrapper was accepted"; fi
|
||||
if grep -Eq '^step:omarchy-(dev-link|state)|^sudo -N ' "$SUDO_TEST_LOG"; then
|
||||
fail "an incompatible dev checkout changed the system before rejection"
|
||||
fi
|
||||
grep -q 'Update the checkout before switching to dev' "$boundary_tmp/output" || fail "stale checkout rejection lacks recovery guidance"
|
||||
assert_boundary_cold "stale checkout"
|
||||
mv "$boundary_tmp/saved-wrapper" "$wrapper"
|
||||
pass "a stale dev checkout is rejected before linking or privileged work"
|
||||
|
||||
reset_boundary
|
||||
OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")"
|
||||
assert_scoped_channel "dev to stable"
|
||||
pass "leaving dev preserves no-update sudo through unlink and the packaged update"
|
||||
|
||||
# A packaged destination that predates the wrapper cannot be checked before its
|
||||
# package is installed. Its updater authenticates without --no-update, so the
|
||||
# switch must not launch it: it stops at a consistent point with instructions.
|
||||
reset_boundary
|
||||
mv "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper"
|
||||
mv "$SUDO_TEST_ROOT/bin/omarchy-update" "$boundary_tmp/saved-package-update"
|
||||
ln -s test-step "$SUDO_TEST_ROOT/bin/omarchy-update"
|
||||
if OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable; then fail "an older packaged destination was updated with ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "an older packaged destination was not reported" "$(<"$boundary_tmp/output")"
|
||||
grep -q "Run 'omarchy update' from a new terminal to finish" "$boundary_tmp/output" || fail "an older packaged destination lacks recovery guidance" "$(<"$boundary_tmp/output")"
|
||||
grep -Fxq 'step:omarchy-dev-unlink --no-reboot' "$SUDO_TEST_LOG" || fail "the package switch was not completed before stopping" "$(<"$SUDO_TEST_LOG")"
|
||||
grep -Fxq 'step:omarchy-state set reboot-required' "$SUDO_TEST_LOG" || fail "leaving dev for an older release did not mark the reboot" "$(<"$SUDO_TEST_LOG")"
|
||||
if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "an older packaged updater was launched from the hardened switch" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
grep -q 'The channel switch did not complete' "$boundary_tmp/output" && fail "the stop was reported as an error needing a rerun" "$(<"$boundary_tmp/output")"
|
||||
assert_boundary_cold "older packaged destination"
|
||||
rm "$SUDO_TEST_ROOT/bin/omarchy-update"
|
||||
mv "$boundary_tmp/saved-package-update" "$SUDO_TEST_ROOT/bin/omarchy-update"
|
||||
mv "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo"
|
||||
pass "an older packaged destination stops the switch cold with instructions instead of running its updater"
|
||||
|
||||
# A package-backed source can be downgraded by its own transaction to a release
|
||||
# without the wrapper. From then on a bare sudo would be the real one, so no
|
||||
# privileged step may follow either transaction without checking first. A decoy
|
||||
# sudo in the package bin catches any such call instead of reaching the host.
|
||||
cat >"$SUDO_TEST_ROOT/bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'unwrapped-sudo %s\n' "$*" >>"$SUDO_TEST_LOG"
|
||||
exit 97
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/sudo"
|
||||
cp "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper"
|
||||
for pattern in 'pacman -Syyuu*' 'pacman -S --needed*'; do
|
||||
reset_boundary
|
||||
export SUDO_TEST_REMOVE_WRAPPER_STEP=$pattern
|
||||
if run_channel rc; then fail "a downgrade during '$pattern' was not detected" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
unset SUDO_TEST_REMOVE_WRAPPER_STEP
|
||||
grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "downgrade during '$pattern' was not reported" "$(<"$boundary_tmp/output")"
|
||||
if grep -q '^unwrapped-sudo ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' reached ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
if grep -q '^step:omarchy-dev-unlink' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still unlinked" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still updated" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
python3 - "$SUDO_TEST_LOG" "$pattern" <<'PY'
|
||||
import sys
|
||||
events = open(sys.argv[1]).read().splitlines()
|
||||
transactions = [e for e in events if e.startswith('step:pacman ')]
|
||||
assert len(transactions) == (1 if sys.argv[2].startswith('pacman -Syyuu') else 2), events
|
||||
assert all(e in ('sudo -h', 'sudo -k') or e.startswith('sudo -N ') for e in events if e.startswith('sudo ')), events
|
||||
PY
|
||||
assert_boundary_cold "downgrade during $pattern"
|
||||
cp "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo"
|
||||
pass "a transaction that removes the wrapper stops the switch before any further sudo ($pattern)"
|
||||
done
|
||||
rm "$SUDO_TEST_ROOT/bin/sudo"
|
||||
|
||||
mkdir "$boundary_tmp/user tools"
|
||||
cat >"$boundary_tmp/user tools/channel-user-tool" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'user-tool:%s\n' "$*" >>"$SUDO_TEST_LOG"
|
||||
STUB
|
||||
chmod +x "$boundary_tmp/user tools/channel-user-tool"
|
||||
for command in omarchy-hook omarchy-update-mise; do
|
||||
rm "$SUDO_TEST_ROOT/bin/$command"
|
||||
cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
|
||||
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
|
||||
channel-user-tool "${0##*/}" "$@"
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/$command"
|
||||
done
|
||||
reset_boundary
|
||||
PATH="$boundary_tmp/user tools:$PATH" run_channel stable || fail "channel hooks lost the user's PATH" "$(<"$boundary_tmp/output")"
|
||||
for event in 'omarchy-hook post-update' 'omarchy-update-mise' 'omarchy-hook pre-refresh-pacman'; do
|
||||
grep -Fxq "user-tool:$event" "$SUDO_TEST_LOG" || fail "user PATH was not preserved for $event"
|
||||
done
|
||||
assert_boundary_cold "channel user PATH"
|
||||
for command in omarchy-hook omarchy-update-mise; do
|
||||
ln -sfn test-step "$SUDO_TEST_ROOT/bin/$command"
|
||||
done
|
||||
pass "channel switching preserves user tools behind the wrapper for both hooks and mise"
|
||||
|
||||
for step in pacman omarchy-update-system-pkgs omarchy-hook; do
|
||||
reset_boundary
|
||||
if SUDO_TEST_FAIL_STEP="$step" run_channel stable; then fail "$step failure was ignored"; fi
|
||||
assert_boundary_cold "$step failure"
|
||||
if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$step failure reached the post-update hook"; fi
|
||||
pass "$step failure exits cold without the post-update hook"
|
||||
done
|
||||
|
||||
for signal in HUP INT TERM; do
|
||||
reset_boundary
|
||||
cat >"$SUDO_TEST_ROOT/bin/omarchy-dev-unlink" <<'STUB'
|
||||
#!/bin/bash
|
||||
sudo /usr/bin/true || exit 1
|
||||
kill -s "$SUDO_TEST_CHANNEL_SIGNAL" "$PPID"
|
||||
STUB
|
||||
if SUDO_TEST_CHANNEL_SIGNAL="$signal" run_channel stable; then fail "$signal was ignored"; fi
|
||||
assert_boundary_cold "$signal"
|
||||
if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$signal reached the post-update hook"; fi
|
||||
pass "$signal stops the channel transition and revokes authorization"
|
||||
done
|
||||
|
||||
for refusal in unsupported-sudo failed-revocation ordinary-bash; do
|
||||
reset_boundary
|
||||
case "$refusal" in
|
||||
unsupported-sudo) export SUDO_TEST_UNSUPPORTED=1 ;;
|
||||
failed-revocation) export SUDO_TEST_REVOKE_FAIL=1 ;;
|
||||
esac
|
||||
if [[ $refusal == "ordinary-bash" ]]; then
|
||||
if /usr/bin/bash "$SUDO_TEST_ROOT/bin/omarchy-channel-set" -p >"$boundary_tmp/output" 2>&1; then fail "$refusal was accepted"; fi
|
||||
elif run_channel stable; then
|
||||
fail "$refusal was accepted"
|
||||
fi
|
||||
if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "$refusal reached channel work"; fi
|
||||
pass "$refusal is rejected before channel work"
|
||||
done
|
||||
@@ -4,10 +4,18 @@ set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
test_tmp="$boundary_tmp"
|
||||
package_root="$SUDO_TEST_ROOT"
|
||||
copy_boundary_file bin/omarchy-channel-set
|
||||
python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$package_root" <<'PYTHON'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
p = Path(sys.argv[1])
|
||||
p.write_text(p.read_text().replace("/usr/share/omarchy", sys.argv[2]))
|
||||
PYTHON
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
stub_bin="$SUDO_TEST_ROOT/bin"
|
||||
log_file="$test_tmp/channel.log"
|
||||
mkdir -p "$stub_bin" "$test_tmp/home"
|
||||
|
||||
@@ -15,6 +23,7 @@ write_stub() {
|
||||
local name="$1"
|
||||
local body="$2"
|
||||
|
||||
rm -f "$stub_bin/$name"
|
||||
cat >"$stub_bin/$name" <<<"$body"
|
||||
chmod +x "$stub_bin/$name"
|
||||
}
|
||||
@@ -26,11 +35,17 @@ printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
'
|
||||
|
||||
write_stub sudo '#!/bin/bash
|
||||
case "${1:-}" in
|
||||
-h) echo "usage: sudo [-ABbEHkNnPS] command"; exit 0 ;;
|
||||
-k|-K) exit 0 ;;
|
||||
esac
|
||||
printf "sudo" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
|
||||
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
'
|
||||
|
||||
cp "$stub_bin/sudo" "$SUDO_TEST_ROOT/mock/sudo"
|
||||
|
||||
write_stub omarchy-update-pacman '#!/bin/bash
|
||||
printf "update-pacman" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
|
||||
@@ -69,7 +84,8 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
|
||||
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
if [[ $1 == "clone" ]]; then
|
||||
dest="${@: -1}"
|
||||
mkdir -p "$dest/.git" "$dest/bin" "$dest/default" "$dest/shell"
|
||||
/usr/bin/cp -a "$SUDO_TEST_ROOT" "$dest"
|
||||
mkdir -p "$dest/.git" "$dest/shell"
|
||||
fi
|
||||
'
|
||||
|
||||
@@ -96,10 +112,10 @@ esac
|
||||
run_channel() {
|
||||
: >"$log_file"
|
||||
OMARCHY_CHANNEL_TEST_LOG="$log_file" \
|
||||
OMARCHY_PATH="${OMARCHY_TEST_PATH:-/usr/share/omarchy}" \
|
||||
OMARCHY_PATH="${OMARCHY_TEST_PATH:-$package_root}" \
|
||||
HOME="$test_tmp/home" \
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-channel-set" "$@"
|
||||
"${OMARCHY_TEST_PATH:-$package_root}/bin/omarchy-channel-set" "$@"
|
||||
}
|
||||
|
||||
assert_log_line() {
|
||||
@@ -114,7 +130,7 @@ run_channel stable
|
||||
assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel"
|
||||
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages"
|
||||
assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "stable runs the normal update pipeline from the package-backed path"
|
||||
if grep -q $'^state\tset\treboot-required$' "$log_file"; then
|
||||
fail "stable does not require reboot when already package-backed" "$(cat "$log_file")"
|
||||
fi
|
||||
@@ -124,15 +140,17 @@ run_channel rc
|
||||
assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel"
|
||||
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages"
|
||||
assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "rc runs the normal update pipeline from the package-backed path"
|
||||
|
||||
OMARCHY_TEST_PATH="$ROOT" run_channel edge
|
||||
active_checkout="$test_tmp/active-checkout"
|
||||
cp -a "$package_root" "$active_checkout"
|
||||
OMARCHY_TEST_PATH="$active_checkout" run_channel edge
|
||||
assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel"
|
||||
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages"
|
||||
assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt"
|
||||
assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path"
|
||||
[[ $(grep -E $'^(unlink|state|update)\t' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] ||
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "edge runs the normal update pipeline from the package-backed path"
|
||||
[[ $(grep -E $'^(unlink|state|update)\t' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH='"$package_root" ]] ||
|
||||
fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")"
|
||||
pass "edge defers the reboot prompt until the update restart stage"
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exercise complete production functions with private paths and harmless
|
||||
# command stand-ins. Never install sudo policy or start a host timer.
|
||||
test_tmp=$(mktemp -d)
|
||||
children=()
|
||||
cleanup_grant_fixture() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if (( ${#children[@]} )); then
|
||||
kill "${children[@]}" 2>/dev/null || true
|
||||
wait "${children[@]}" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$test_tmp"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_grant_fixture EXIT
|
||||
export TEST_GRANT_ROOT=$test_tmp
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/var/lib" "$test_tmp/hooks"
|
||||
cat >"$test_tmp/bin/mock" <<'STUB'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
name=${0##*/}
|
||||
printf '%s %s\n' "$name" "$*" >>"$TEST_GRANT_ROOT/commands"
|
||||
case "$name" in
|
||||
stat)
|
||||
path=${@: -1}
|
||||
owner=0
|
||||
mode=$(/usr/bin/stat -Lc '%a' -- "$path")
|
||||
[[ $path != /tmp ]] || mode=755
|
||||
[[ $path != "${TEST_BAD_PATH:-}" ]] || owner=1000
|
||||
case $2 in
|
||||
'%u') echo "$owner" ;;
|
||||
'%a') echo "$mode" ;;
|
||||
'%u %a') echo "$owner $mode" ;;
|
||||
*) exec /usr/bin/stat "$@" ;;
|
||||
esac
|
||||
;;
|
||||
chown) exit 0 ;;
|
||||
install)
|
||||
args=()
|
||||
while (($#)); do
|
||||
case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac
|
||||
done
|
||||
exec /usr/bin/install "${args[@]}"
|
||||
;;
|
||||
rm)
|
||||
for path in "$@"; do
|
||||
if [[ ${TEST_DELETE_FAIL:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/99-omarchy-nopasswd-1000" ]]; then exit 1; fi
|
||||
done
|
||||
exec /usr/bin/rm "$@"
|
||||
;;
|
||||
mv)
|
||||
[[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1
|
||||
/usr/bin/mv "$@"
|
||||
[[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing"
|
||||
;;
|
||||
systemd-run)
|
||||
[[ ${TEST_TIMER_FAIL:-0} != 1 ]] || exit 1
|
||||
if [[ ${TEST_CANCEL_ENABLE:-0} == 1 ]]; then kill -TERM "$PPID"; fi
|
||||
;;
|
||||
systemctl)
|
||||
[[ $1 != "is-active" || ${TEST_INACTIVE_TIMER:-0} != 1 ]]
|
||||
;;
|
||||
date)
|
||||
if [[ ${TEST_EXPIRED:-0} == 1 && $* == '-u +%Y%m%d%H%M%SZ' ]]; then echo 99991231235959Z; else /usr/bin/date "$@"; fi
|
||||
;;
|
||||
getent) printf '%s:x:1000:1000:Test:/nonexistent:/bin/bash\n' "${TEST_ACCOUNT:-audituser}" ;;
|
||||
sudo)
|
||||
if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi
|
||||
if [[ ${1:-} == -k ]]; then exit 0; fi
|
||||
if [[ ${1:-} == -N ]]; then shift; fi
|
||||
if [[ ${1:-} == -- ]]; then shift; fi
|
||||
if [[ ${TEST_MIGRATION:-0} == 1 ]]; then
|
||||
[[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1
|
||||
TEST_EUID=0 /usr/bin/bash -p "$@"
|
||||
else
|
||||
[[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}"
|
||||
fi
|
||||
;;
|
||||
gum) exit 1 ;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$test_tmp/bin/mock"
|
||||
for name in stat chown install rm mv systemd-run systemctl date getent sudo gum; do
|
||||
ln -s mock "$test_tmp/bin/$name"
|
||||
done
|
||||
|
||||
python3 - "$ROOT" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
root, temp = map(Path, sys.argv[1:])
|
||||
for name in ('omarchy-sudo-passwordless', 'omarchy-security-functions'):
|
||||
text = (root/'bin'/name).read_text()
|
||||
for path in ('/etc/', '/var/lib', '/run/', '/usr/share/libalpm/hooks'):
|
||||
target = str(temp/'hooks') if path == '/usr/share/libalpm/hooks' else str(temp) + path
|
||||
text = text.replace(path, target)
|
||||
text = text.replace('((EUID == 0))', '((${TEST_EUID:-1} == 0))')
|
||||
for command in ('stat', 'chown', 'install', 'rm', 'mv', 'systemd-run', 'systemctl', 'date', 'getent', 'sudo', 'gum'):
|
||||
text = text.replace('/usr/bin/' + command, str(temp/'bin'/command))
|
||||
(temp/name).write_text(text)
|
||||
(temp/name).chmod(0o755)
|
||||
PY
|
||||
library="$test_tmp/functions.sh"
|
||||
{
|
||||
printf 'source %q\n' "$test_tmp/omarchy-security-functions"
|
||||
awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$test_tmp/omarchy-sudo-passwordless"
|
||||
} >"$library"
|
||||
cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/"
|
||||
sed "s|/etc/|$test_tmp/etc/|g" "$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
: >"$test_tmp/commands"
|
||||
|
||||
# New subshell per case prevents one test's overrides and readonly constants
|
||||
# from affecting the next. External commands log enough to verify ordering.
|
||||
assert_status() {
|
||||
local expected=$1 actual=0
|
||||
shift
|
||||
"$@" || actual=$?
|
||||
(( actual == expected )) || fail "expected status $expected, got $actual from $*"
|
||||
}
|
||||
reset_grant() {
|
||||
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
|
||||
: >"$test_tmp/commands"
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test the real orchestration with fixed privileged paths redirected to harmless
|
||||
# stand-ins. No host sudo, package transaction, namespace root, or exploit runs.
|
||||
boundary_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$boundary_tmp"' EXIT
|
||||
export SUDO_TEST_ROOT="$boundary_tmp/omarchy"
|
||||
export SUDO_TEST_LOG="$boundary_tmp/events"
|
||||
export SUDO_TEST_CACHE="$boundary_tmp/cache"
|
||||
export OMARCHY_PATH="$SUDO_TEST_ROOT"
|
||||
export SUDO_TEST_HOME="$boundary_tmp/home"
|
||||
mkdir -p "$SUDO_TEST_HOME"
|
||||
mkdir -p "$SUDO_TEST_ROOT/bin" "$SUDO_TEST_ROOT/mock" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update"
|
||||
: >"$SUDO_TEST_LOG"
|
||||
|
||||
copy_boundary_file() {
|
||||
python3 - "$ROOT" "$SUDO_TEST_ROOT" "$1" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
source, target, name = map(Path,sys.argv[1:])
|
||||
p=target/name
|
||||
p.parent.mkdir(parents=True,exist_ok=True)
|
||||
s=(source/name).read_text().replace('$HOME', '$SUDO_TEST_HOME')
|
||||
for command in ['sudo','pkexec','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']:
|
||||
s=s.replace('/usr/bin/'+command, str(target/'mock'/command))
|
||||
s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"')
|
||||
p.write_text(s)
|
||||
p.chmod((source/name).stat().st_mode & 0o777)
|
||||
PY
|
||||
}
|
||||
|
||||
copy_boundary_file bin/omarchy-security-functions
|
||||
copy_boundary_file bin/omarchy-update-pacman
|
||||
copy_boundary_file default/omarchy/sudo-no-update/sudo
|
||||
|
||||
cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'sudo' >>"$SUDO_TEST_LOG"
|
||||
printf ' %q' "$@" >>"$SUDO_TEST_LOG"
|
||||
printf '\n' >>"$SUDO_TEST_LOG"
|
||||
if [[ ${1:-} == "-h" ]]; then
|
||||
if [[ ${SUDO_TEST_UNSUPPORTED:-0} == "1" ]]; then
|
||||
echo 'usage: sudo [-ABbEHknPS] command'
|
||||
else
|
||||
echo 'usage: sudo [-ABbEHkNnPS] command'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [[ ${1:-} == "-k" || ${1:-} == "-K" ]]; then
|
||||
[[ ${SUDO_TEST_REVOKE_FAIL:-0} != "1" ]] || exit 1
|
||||
/usr/bin/rm -f "$SUDO_TEST_CACHE"
|
||||
exit 0
|
||||
fi
|
||||
if [[ ${1:-} == "-N" ]]; then
|
||||
shift
|
||||
else
|
||||
touch "$SUDO_TEST_CACHE"
|
||||
fi
|
||||
[[ ${SUDO_TEST_SUDO_FAIL:-0} != "1" ]] || exit 1
|
||||
background=0
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
-N|-n) shift ;;
|
||||
-b) background=1; shift ;;
|
||||
-v) exit 0 ;;
|
||||
-u|--user) shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
(( $# )) || exit 0
|
||||
if (( background )); then
|
||||
"$@" &
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/mock/sudo"
|
||||
|
||||
cat >"$SUDO_TEST_ROOT/bin/test-step" <<'STUB'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
step=${0##*/}
|
||||
printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG"
|
||||
if [[ $step == "systemd-run" ]]; then
|
||||
# omarchy-update-pacman registers the transaction as a PID 1 scope on booted
|
||||
# hosts. Run the wrapped command in place so the pacman step still executes.
|
||||
while (( $# )) && [[ $1 == -* ]]; do shift; done
|
||||
exec "$@"
|
||||
fi
|
||||
if [[ $step == "omarchy-hook" || $step == "omarchy-update-mise" ]]; then
|
||||
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
|
||||
fi
|
||||
if [[ -n ${SUDO_TEST_REMOVE_WRAPPER_STEP:-} && "$step $*" == $SUDO_TEST_REMOVE_WRAPPER_STEP ]]; then
|
||||
# Model a package transaction replacing the running tree with a release
|
||||
# that predates the wrapper.
|
||||
/usr/bin/rm -f "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"
|
||||
fi
|
||||
if [[ ${SUDO_TEST_FAIL_STEP:-} == "$step" ]]; then
|
||||
# Model a misbehaving child leaving state behind, then failing. Cleanup must
|
||||
# still revoke it. This never invokes real sudo or exercises a privilege flaw.
|
||||
touch "$SUDO_TEST_CACHE"
|
||||
exit 17
|
||||
fi
|
||||
if [[ ${SUDO_TEST_SIGNAL_STEP:-} == "$step" ]]; then
|
||||
touch "$SUDO_TEST_CACHE"
|
||||
kill -TERM "$PPID"
|
||||
exit 0
|
||||
fi
|
||||
case "$step" in
|
||||
omarchy-update-system-pkgs|omarchy-update-keyring|omarchy-snapshot)
|
||||
sudo /usr/bin/true
|
||||
;;
|
||||
pacman) exit 0 ;;
|
||||
yay)
|
||||
[[ $* == *"--sudo $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"* ]] || exit 92
|
||||
[[ $* == *"--sudoloop=false"* ]] || exit 93
|
||||
;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/test-step"
|
||||
for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do
|
||||
ln -s test-step "$SUDO_TEST_ROOT/bin/$step"
|
||||
done
|
||||
ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman"
|
||||
|
||||
reset_boundary() {
|
||||
: >"$SUDO_TEST_LOG"
|
||||
/usr/bin/rm -f "$SUDO_TEST_CACHE"
|
||||
unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP
|
||||
}
|
||||
assert_boundary_cold() {
|
||||
[[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization"
|
||||
[[ $(tail -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "$1 did not revoke at exit" "$(<"$SUDO_TEST_LOG")"
|
||||
}
|
||||
Regular → Executable
+156
-113
@@ -1,123 +1,166 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh"
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
(
|
||||
source "$library"
|
||||
for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || exit 1; done
|
||||
for minutes in 0 1441 -1 1m '' 18446744073709551617; do ! valid_minutes "$minutes" || exit 1; done
|
||||
for name in audituser 'buildbot$'; do valid_account_name "$name" || exit 1; done
|
||||
# Upper-case words are sudoers alias references, so ALICE must never publish.
|
||||
for name in 'a$b' '$' 'a b' 'a#b' Alice ALICE ALL aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done
|
||||
! valid_uid 18446744073709551617
|
||||
)
|
||||
pass "duration and account validation retains bounded inputs and trailing-dollar usernames"
|
||||
|
||||
script="$ROOT/bin/omarchy-sudo-passwordless"
|
||||
tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
(
|
||||
source "$library"
|
||||
assert_status 2 root_dispatch __status 1000
|
||||
assert_status 2 env TEST_EUID=0 SUDO_UID=1001 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
|
||||
assert_status 3 env TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
|
||||
)
|
||||
pass "internal actions reject missing root and mismatched sudo identity"
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
grant="$test_tmp/grant"
|
||||
calls="$test_tmp/calls"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/gum" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'systemctl %s\n' "$*" >>"$TEST_CALLS"
|
||||
[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'sudo %s\n' "$*" >>"$TEST_CALLS"
|
||||
|
||||
case ${1:-} in
|
||||
test)
|
||||
[[ ${2:-} == "-f" && -f $TEST_GRANT ]]
|
||||
;;
|
||||
tee)
|
||||
/usr/bin/tee "$TEST_GRANT"
|
||||
;;
|
||||
chmod)
|
||||
/usr/bin/chmod "$2" "$TEST_GRANT"
|
||||
;;
|
||||
systemd-run)
|
||||
[[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]]
|
||||
;;
|
||||
rm)
|
||||
/usr/bin/rm -f -- "$TEST_GRANT"
|
||||
;;
|
||||
systemctl)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unexpected sudo command: $*" >&2
|
||||
exit 90
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl"
|
||||
|
||||
run_command() {
|
||||
TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \
|
||||
"$script" "$@"
|
||||
}
|
||||
|
||||
: >"$calls"
|
||||
enable_output=$(run_command 15)
|
||||
[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled"
|
||||
[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] ||
|
||||
fail "the enabled grant belongs to the current user" "$(cat "$grant")"
|
||||
grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" ||
|
||||
fail "enabling arms the expiry timer" "$(cat "$calls")"
|
||||
[[ $enable_output == *"automatically disable in 15 minutes"* ]] ||
|
||||
fail "success is reported after the timer is armed" "$enable_output"
|
||||
pass "enabling arms expiry before reporting success"
|
||||
|
||||
: >"$calls"
|
||||
rm -f "$grant"
|
||||
if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then
|
||||
fail "enabling fails when the expiry timer cannot be armed"
|
||||
fi
|
||||
[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant"
|
||||
[[ $failure_output == *"Revoking access now"* ]] ||
|
||||
fail "timer setup failure explains the fail-closed revocation" "$failure_output"
|
||||
[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] ||
|
||||
fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output"
|
||||
pass "timer setup failure revokes a new grant"
|
||||
|
||||
: >"$calls"
|
||||
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant"
|
||||
if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then
|
||||
fail "updating fails when the replacement expiry timer cannot be armed"
|
||||
fi
|
||||
[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant"
|
||||
[[ $update_output != *"timer updated"* ]] ||
|
||||
fail "timer update failure does not report success" "$update_output"
|
||||
pass "timer update failure revokes the existing grant"
|
||||
|
||||
mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file")
|
||||
(( ${#tmpfiles_rules[@]} == 1 )) ||
|
||||
fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}"
|
||||
|
||||
fake_root="$test_tmp/root"
|
||||
sudoers_dir="$fake_root/etc/sudoers.d"
|
||||
mkdir -p "$sudoers_dir"
|
||||
grant_names=(alice buildbot-2 user.123 'service$')
|
||||
for grant_name in "${grant_names[@]}"; do
|
||||
touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name"
|
||||
for status in 1 2 3; do
|
||||
: >"$test_tmp/commands"
|
||||
result=0
|
||||
TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 || result=$?
|
||||
if (( status == 3 )); then
|
||||
(( result == 0 )) && grep -q '^gum confirm ' "$test_tmp/commands" || fail "inactive status must allow confirmation"
|
||||
else
|
||||
(( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement"
|
||||
fi
|
||||
grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization"
|
||||
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization"
|
||||
done
|
||||
touch "$sudoers_dir/omarchy-dns"
|
||||
pass "public status distinguishes inactive from errors and revokes authorization on exit"
|
||||
|
||||
systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}"
|
||||
[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] ||
|
||||
fail "boot-only cleanup leaves a live grant alone outside boot"
|
||||
printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\nunset BASH_ENV\n' >"$test_tmp/startup"
|
||||
: >"$test_tmp/commands"
|
||||
if TEST_STARTUP_MARKER="$test_tmp/startup-ran" BASH_ENV="$test_tmp/startup" bash "$test_tmp/omarchy-sudo-passwordless" -p >/dev/null 2>&1; then
|
||||
fail "ordinary Bash with a decoy -p was accepted"
|
||||
fi
|
||||
[[ -f $test_tmp/startup-ran && ! -s $test_tmp/commands ]] || fail "startup rejection must precede sudo"
|
||||
pass "startup validation rejects ordinary Bash before authorization"
|
||||
|
||||
systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}"
|
||||
for grant_name in "${grant_names[@]}"; do
|
||||
stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name"
|
||||
[[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant"
|
||||
(
|
||||
source "$library"
|
||||
enable_locked 1000 15
|
||||
read_grant 1000
|
||||
[[ $GRANT_NAME == audituser && $(stat -c '%a' "$(rule_file 1000)") == 440 ]]
|
||||
/usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null
|
||||
expiry=$(sed -n 's/^systemd-run .*--on-calendar=@\([0-9]*\).*$/\1/p' "$test_tmp/commands" | tail -1)
|
||||
[[ $GRANT_DEADLINE == "$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ)" ]]
|
||||
if [[ -n ${OMARCHY_TEST_SUDOERS:-} ]]; then
|
||||
[[ -x $OMARCHY_TEST_SUDOERS ]] || fail "OMARCHY_TEST_SUDOERS must name an executable"
|
||||
printf 'root:x:0:0:root:/root:/bin/bash\naudituser:x:1000:1000:Test:/nonexistent:/bin/bash\n' >"$test_tmp/passwd"
|
||||
printf 'root:x:0:\naudituser:x:1000:\n' >"$test_tmp/group"
|
||||
{ printf 'audituser ALL=(ALL) ALL\n'; cat "$(rule_file 1000)"; } >"$test_tmp/policy"
|
||||
for offset in -1 1; do
|
||||
when=$(/usr/bin/date -u -d "@$((expiry + offset))" +%Y%m%d%H%M%SZ)
|
||||
"$OMARCHY_TEST_SUDOERS" -p "$test_tmp/passwd" -P "$test_tmp/group" -T "$when" audituser /usr/bin/true <"$test_tmp/policy" >"$test_tmp/policy-result"
|
||||
if (( offset < 0 )); then
|
||||
! grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy requires a password before expiry"
|
||||
else
|
||||
grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy remains passwordless after expiry"
|
||||
fi
|
||||
done
|
||||
pass "native sudoers evaluation requires authentication after the generated deadline"
|
||||
fi
|
||||
assert_status 0 status_locked 1000
|
||||
TEST_INACTIVE_TIMER=1 assert_status 0 status_locked 1000
|
||||
[[ ! -e $test_tmp/var/lib/omarchy/sudo-passwordless ]]
|
||||
! compgen -G "$test_tmp/etc/sudoers.d/.omarchy-nopasswd.*"
|
||||
)
|
||||
pass "one complete mode-0440 sudoers rule holds the deadline with no separate grant state"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
before=$(cat "$(rule_file 1000)")
|
||||
expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff
|
||||
[[ $(cat "$(rule_file 1000)") == "$before" ]]
|
||||
enable_locked 1000 30
|
||||
renewed=$(cat "$(rule_file 1000)")
|
||||
[[ $renewed != "$before" ]]
|
||||
expire_locked 1000
|
||||
[[ $(cat "$(rule_file 1000)") == "$renewed" ]]
|
||||
TEST_EXPIRED=1 expire_locked 1000
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
pass "legacy and current callbacks preserve renewed grants and remove expired ones"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
enable_locked 1000 1
|
||||
assert_status 2 env TEST_EXPIRED=1 TEST_DELETE_FAIL=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
|
||||
[[ -e $(rule_file 1000) ]]
|
||||
TEST_EXPIRED=1 assert_status 3 status_locked 1000
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
pass "expired status reports cleanup failure separately from confirmed inactivity"
|
||||
|
||||
for failure in TEST_TIMER_FAIL TEST_INACTIVE_TIMER TEST_PUBLISH_FAIL TEST_POST_PUBLISH_FAIL TEST_CANCEL_ENABLE; do
|
||||
reset_grant
|
||||
expected=1
|
||||
if [[ $failure == "TEST_CANCEL_ENABLE" ]]; then expected=143; fi
|
||||
(
|
||||
source "$library"
|
||||
enable_locked 1000 15
|
||||
assert_status "$expected" env "$failure=1" TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 30
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
done
|
||||
[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules"
|
||||
pass "systemd-tmpfiles removes generated grants only during boot"
|
||||
pass "timer, publication, post-publication and cancellation failures revoke renewed access"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
TEST_POST_PUBLISH_FAIL=1 TEST_DELETE_FAIL=1 assert_status 1 enable_locked 1000 15
|
||||
[[ -e $(rule_file 1000) ]]
|
||||
! grep -q '^systemctl stop ' "$test_tmp/commands"
|
||||
)
|
||||
pass "failed policy deletion retains the timer and reports failure"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$(rule_file 1000)"
|
||||
cp "$(rule_file 1000)" "$test_tmp/admin-rule"
|
||||
assert_status 2 status_locked 1000
|
||||
assert_status 1 enable_locked 1000 15
|
||||
assert_status 1 cleanup_uid_locked 1000
|
||||
cmp "$(rule_file 1000)" "$test_tmp/admin-rule"
|
||||
rm "$(rule_file 1000)"
|
||||
ln -s "$test_tmp/admin-rule" "$(rule_file 1000)"
|
||||
assert_status 2 status_locked 1000
|
||||
assert_status 1 cleanup_uid_locked 1000
|
||||
[[ -L $(rule_file 1000) ]]
|
||||
)
|
||||
pass "grant operations preserve administrator policies and reject symlinks"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
TEST_BAD_PATH="$test_tmp/etc/sudoers.d" assert_status 1 enable_locked 1000 15
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
rm "$PACKAGE_HOOK"
|
||||
assert_status 1 enable_locked 1000 15
|
||||
)
|
||||
pass "publication requires trusted paths and the packaged cleanup hook"
|
||||
|
||||
reset_grant
|
||||
cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/"
|
||||
(
|
||||
source "$library"
|
||||
TEST_ACCOUNT='buildbot$' enable_locked 1000 15
|
||||
read_grant 1000
|
||||
[[ $GRANT_NAME == 'buildbot$' ]]
|
||||
/usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null
|
||||
cleanup_uid_locked 1000
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
TEST_ACCOUNT=ALICE assert_status 1 enable_locked 1000 15
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish"
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh"
|
||||
|
||||
quarantine="$test_tmp/var/lib/omarchy/sudoers-quarantine"
|
||||
quarantined_policy() {
|
||||
local entry
|
||||
for entry in "$quarantine"/*/; do
|
||||
if [[ $(cat "$entry/name") == "$1" ]]; then
|
||||
cat "$entry/policy"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# The old writer accepted any $USER, so a basename can sit just under NAME_MAX.
|
||||
long_suffix=$(printf 'l%.0s' {1..223})
|
||||
(
|
||||
source "$library"
|
||||
printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)"
|
||||
printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$"
|
||||
# The legacy command never validated the account name, so a manual or NSS
|
||||
# account outside the current policy still has its exact old grant removed.
|
||||
printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice"
|
||||
# The legacy writer produced the body with echo. Under BASH_ENV with
|
||||
# xpg_echo, USER='ali\0143e' yields this filename with an 'alice' rule, so
|
||||
# a suffix/body mismatch does not prove administrator authorship.
|
||||
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-ali\\0143e"
|
||||
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-$long_suffix"
|
||||
printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom"
|
||||
TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked
|
||||
[[ -e $(rule_file 1000) ]]
|
||||
cleanup_all_locked
|
||||
! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*"
|
||||
[[ $(stat -c '%a' "$quarantine") == 700 ]]
|
||||
[[ $(quarantined_policy '99-omarchy-nopasswd-ali\0143e') == 'alice ALL=(ALL) NOPASSWD: ALL' ]]
|
||||
[[ $(quarantined_policy "99-omarchy-nopasswd-$long_suffix") == 'alice ALL=(ALL) NOPASSWD: ALL' ]]
|
||||
[[ $(quarantined_policy 99-omarchy-nopasswd-custom) == 'admin ALL=(ALL) NOPASSWD: /usr/bin/true' ]]
|
||||
(( $(ls -A "$quarantine" | wc -l) == 3 ))
|
||||
)
|
||||
pass "legacy cleanup removes generated rules for any account and quarantines everything else in the prefix"
|
||||
|
||||
# Run the actual migration queue for separate temporary homes. Sudo only calls
|
||||
# the mapped helper and can be refused without requesting host authorization.
|
||||
mkdir -p "$test_tmp/source/migrations"
|
||||
sed "s|/usr/bin/omarchy-sudo-passwordless|$test_tmp/omarchy-sudo-passwordless|g" \
|
||||
"$ROOT/migrations/1788163635.sh" >"$test_tmp/source/migrations/1788163635.sh"
|
||||
printf 'echo "later migration ran"\n' >"$test_tmp/source/migrations/1788163636.sh"
|
||||
run_migrations() {
|
||||
TEST_MIGRATION=1 OMARCHY_PATH="$test_tmp/source" OMARCHY_MIGRATION_STATE="$test_tmp/$1" \
|
||||
PATH="$test_tmp/bin:$PATH" /usr/bin/bash "$ROOT/bin/omarchy-migrate" >"$test_tmp/migrations.log" 2>&1
|
||||
}
|
||||
marker="$test_tmp/var/lib/omarchy/migrations/1788163635"
|
||||
(
|
||||
source "$library"
|
||||
# A quarantine that cannot be trusted keeps the migration pending.
|
||||
printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch"
|
||||
TEST_BAD_PATH="$test_tmp/var/lib/omarchy" assert_status 1 run_migrations first
|
||||
[[ ! -e $marker && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch ]]
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)"
|
||||
printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice"
|
||||
TEST_DELETE_FAIL=1 assert_status 1 run_migrations first
|
||||
[[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]]
|
||||
run_migrations first
|
||||
[[ -f $marker && -f $test_tmp/first/1788163636.sh ]]
|
||||
! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*"
|
||||
[[ $(quarantined_policy 99-omarchy-nopasswd-mismatch) == 'alice ALL=(ALL) NOPASSWD: ALL' ]]
|
||||
enable_locked 1000 15
|
||||
cp "$(rule_file 1000)" "$test_tmp/renewed"
|
||||
: >"$test_tmp/commands"
|
||||
TEST_NO_SUDO=1 run_migrations second
|
||||
[[ -f $test_tmp/second/1788163636.sh ]]
|
||||
! grep -q '^sudo ' "$test_tmp/commands"
|
||||
cmp "$(rule_file 1000)" "$test_tmp/renewed"
|
||||
)
|
||||
pass "migration completion is machine-wide, retryable, and needs no sudo for later users"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
TEST_BAD_PATH="$marker" assert_status 1 migration_complete
|
||||
rm "$marker"
|
||||
ln -s "$test_tmp/renewed" "$marker"
|
||||
assert_status 1 migration_complete
|
||||
assert_status 1 migrate_locked
|
||||
[[ -L $marker ]]
|
||||
rm "$marker"
|
||||
)
|
||||
pass "migration checks marker ownership and rejects symlinks"
|
||||
|
||||
# Keep real package scripts in the contract: source and packaging share the
|
||||
# same lock and blocker, including the legacy scriptlet fallback.
|
||||
pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs}
|
||||
[[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds
|
||||
for name in omarchy-settings omarchy-settings-dev; do
|
||||
script="$pkgs_path/$name/$name.install"
|
||||
[[ -f $script ]] || fail "set OMARCHY_PKGS_PATH to the companion package checkout"
|
||||
sed -e "s|/etc/|$test_tmp/etc/|g" -e "s|/run|$test_tmp/run|g" \
|
||||
-e "s|/usr/bin/stat|$test_tmp/bin/stat|g" -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \
|
||||
"$script" >"$test_tmp/$name.install"
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
source "$test_tmp/$name.install"
|
||||
_etc_overrides_apply() { :; }
|
||||
enable_locked 1000 15
|
||||
TEST_DELETE_FAIL=1 assert_status 1 pre_remove
|
||||
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
|
||||
assert_status 1 enable_locked 1000 15
|
||||
pre_remove && post_remove
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
post_install
|
||||
[[ ! -e $REMOVAL_BLOCKER ]]
|
||||
enable_locked 1000 15
|
||||
pre_upgrade && post_upgrade
|
||||
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
|
||||
# pacman does not stop a transaction on a failed scriptlet, so a failed
|
||||
# pre_upgrade can be followed directly by post_upgrade. Completion must
|
||||
# not clear the blocker while a rule remains, and a clean retry recovers.
|
||||
enable_locked 1000 15
|
||||
TEST_DELETE_FAIL=1 assert_status 1 pre_upgrade
|
||||
TEST_DELETE_FAIL=1 assert_status 1 post_upgrade
|
||||
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
|
||||
assert_status 1 enable_locked 1000 15
|
||||
post_upgrade
|
||||
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
|
||||
# A stranded blocker plus a live rule from an interrupted removal is
|
||||
# cleaned by the next completed installation, not merely unblocked.
|
||||
enable_locked 1000 15
|
||||
: >"$REMOVAL_BLOCKER"
|
||||
post_install
|
||||
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
|
||||
# A fresh install has no earlier step, so completion must hold the blocker
|
||||
# itself while it sweeps: a leftover rule it cannot remove leaves
|
||||
# publication refused rather than merely reporting an error.
|
||||
enable_locked 1000 15
|
||||
rm -f "$REMOVAL_BLOCKER"
|
||||
TEST_DELETE_FAIL=1 assert_status 1 post_install
|
||||
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
|
||||
assert_status 1 enable_locked 1000 15
|
||||
post_install
|
||||
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
|
||||
# The sweep must not depend on the glob state pacman's shell inherits.
|
||||
enable_locked 1000 15
|
||||
( set -f; GLOBIGNORE='*' post_upgrade )
|
||||
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
|
||||
# A failure before the lock is even taken, such as an untrusted lock
|
||||
# directory, must still leave publication refused.
|
||||
enable_locked 1000 15
|
||||
rm -f "$REMOVAL_BLOCKER"
|
||||
TEST_BAD_PATH="$test_tmp/run/lock" assert_status 1 post_install
|
||||
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
|
||||
TEST_BAD_PATH="$test_tmp/run/lock" assert_status 1 pre_upgrade
|
||||
[[ -e $REMOVAL_BLOCKER ]]
|
||||
post_install
|
||||
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
|
||||
)
|
||||
done
|
||||
pass "both settings packages revoke grants, block publication, and recover on installation only with the namespace empty"
|
||||
|
||||
reset_grant
|
||||
# Hold the source lock, then start package removal. A native flock on the
|
||||
# mapped file must serialize both implementations.
|
||||
cat >"$test_tmp/worker" <<'WORKER'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$TEST_LIBRARY"
|
||||
critical() {
|
||||
touch "$TEST_GRANT_ROOT/entered"
|
||||
for (( attempt=0; attempt<500; attempt++ )); do
|
||||
[[ ! -e $TEST_GRANT_ROOT/release ]] || break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ -e $TEST_GRANT_ROOT/release ]] || return 1
|
||||
enable_locked 1000 15
|
||||
}
|
||||
with_root_lock critical
|
||||
WORKER
|
||||
TEST_LIBRARY="$library" /usr/bin/bash "$test_tmp/worker" >"$test_tmp/publisher.log" 2>&1 &
|
||||
publisher=$!
|
||||
children+=("$publisher")
|
||||
for (( attempt=0; attempt<200; attempt++ )); do
|
||||
[[ ! -e $test_tmp/entered ]] || break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ -f $test_tmp/entered ]] || fail "publisher failed to acquire the lock"
|
||||
/usr/bin/bash -euo pipefail -c 'source "$1"; pre_remove; post_remove' bash "$test_tmp/omarchy-settings.install" >"$test_tmp/removal.log" 2>&1 &
|
||||
removal=$!
|
||||
children+=("$removal")
|
||||
# The removal announces itself before waiting for the lock, so a publisher
|
||||
# still holding it is refused rather than allowed to publish a rule that the
|
||||
# removal would delete a moment later.
|
||||
for (( attempt=0; attempt<200; attempt++ )); do
|
||||
[[ ! -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || fail "removal did not announce itself before waiting for the lock"
|
||||
touch "$test_tmp/release"
|
||||
if wait "$publisher"; then fail "publisher was allowed to publish after removal announced itself" "$(cat "$test_tmp/publisher.log")"; fi
|
||||
wait "$removal" || fail "removal failed" "$(cat "$test_tmp/removal.log")"
|
||||
children=()
|
||||
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]]
|
||||
[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]]
|
||||
pass "an announced package removal refuses a waiting publisher and clears the namespace"
|
||||
|
||||
# systemd-tmpfiles operates on an explicit disposable root, never the host.
|
||||
reset_grant
|
||||
: >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000"
|
||||
: >"$test_tmp/etc/sudoers.d/unrelated"
|
||||
rule='r! /etc/sudoers.d/99-omarchy-nopasswd-*'
|
||||
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --inline "$rule"
|
||||
[[ -f $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "routine tmpfiles shortened a live grant"
|
||||
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline "$rule"
|
||||
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 && -f $test_tmp/etc/sudoers.d/unrelated ]] || fail "boot cleanup boundary"
|
||||
pass "native boot cleanup removes grants while routine tmpfiles preserves them"
|
||||
@@ -172,7 +172,30 @@ else
|
||||
fi
|
||||
SH
|
||||
|
||||
chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl"
|
||||
cat >"$restart_bin/busctl" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ -z ${OMARCHY_TEST_NOTIFICATION_CHECKS:-} ]]; then
|
||||
echo 'b false'
|
||||
else
|
||||
checks=0
|
||||
[[ ! -f $OMARCHY_TEST_NOTIFICATION_CHECKS ]] || read -r checks <"$OMARCHY_TEST_NOTIFICATION_CHECKS"
|
||||
(( checks += 1 ))
|
||||
printf '%s\n' "$checks" >"$OMARCHY_TEST_NOTIFICATION_CHECKS"
|
||||
# The service was running before the restart and, when asked to, never
|
||||
# comes back afterwards.
|
||||
if [[ ${OMARCHY_TEST_NOTIFICATIONS_DIE:-0} == 1 ]]; then
|
||||
(( checks == 1 )) && echo 'b true' || echo 'b false'
|
||||
exit 0
|
||||
fi
|
||||
if (( checks == 1 || checks >= 4 )); then
|
||||
echo 'b true'
|
||||
else
|
||||
echo 'b false'
|
||||
fi
|
||||
fi
|
||||
SH
|
||||
|
||||
chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl" "$restart_bin/busctl"
|
||||
|
||||
sleep 30 &
|
||||
restart_pid_one=$!
|
||||
@@ -194,6 +217,7 @@ OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \
|
||||
OMARCHY_TEST_IPC_LOG="$ipc_log" \
|
||||
OMARCHY_TEST_SESSION_PATH="$restart_root" \
|
||||
OMARCHY_TEST_TRANSIENT_ENV=leaked \
|
||||
OMARCHY_TEST_NOTIFICATION_CHECKS="$test_tmp/notification-checks" \
|
||||
timeout 5 "$ROOT/bin/omarchy-restart-shell"
|
||||
|
||||
if kill -0 "$restart_pid_one" 2>/dev/null; then
|
||||
@@ -213,6 +237,8 @@ grep -F "kill -p $restart_root/shell --any-display" "$restart_log" >/dev/null ||
|
||||
grep -F 'hl.dsp.exec_cmd("omarchy-launch-shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland"
|
||||
grep -F "ipc -n -p $restart_root/shell call -- shell ping" "$ipc_log" >/dev/null || fail "restart checks readiness in the session checkout"
|
||||
pass "restart replaces duplicate shell instances from the session checkout"
|
||||
[[ $(<"$test_tmp/notification-checks") == 4 ]] || fail "restart waits for the existing notification service after core IPC is ready"
|
||||
pass "restart waits for notification readiness before one-time update hooks"
|
||||
|
||||
: >"$restart_log"
|
||||
printf '303\n' >"$restart_state"
|
||||
@@ -265,3 +291,35 @@ restart_pid_one=""
|
||||
grep -F "ipc -n -p $restart_root/shell call -- lock lock" "$ipc_log" >/dev/null || fail "dead-lock recovery re-acquires the session lock"
|
||||
grep -F "ipc -n -p $restart_root/shell call -- lock status" "$ipc_log" >/dev/null || fail "dead-lock recovery waits for the lock to become secure"
|
||||
pass "restart recovers a locked session whose lock client died"
|
||||
|
||||
# Lock recovery must not wait on the notification plugin: a stranded user gets
|
||||
# the lock back even when notifications never return, and the restart then
|
||||
# reports the missing service rather than claiming success.
|
||||
sleep 30 &
|
||||
restart_pid_one=$!
|
||||
printf '%s\n' "$restart_pid_one" >"$restart_state"
|
||||
rm -f "$restart_state.locked" "$test_tmp/notification-checks"
|
||||
: >"$restart_log"
|
||||
: >"$ipc_log"
|
||||
|
||||
if PATH="$restart_bin:$PATH" \
|
||||
OMARCHY_PATH="$restart_root" \
|
||||
XDG_RUNTIME_DIR="$runtime_dir" \
|
||||
OMARCHY_TEST_SESSION_LOCKED=1 \
|
||||
OMARCHY_TEST_QS_STATE="$restart_state" \
|
||||
OMARCHY_TEST_QS_LOG="$restart_log" \
|
||||
OMARCHY_TEST_QS_ENV_LOG="$restart_env_log" \
|
||||
OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \
|
||||
OMARCHY_TEST_IPC_LOG="$ipc_log" \
|
||||
OMARCHY_TEST_SESSION_PATH="$restart_root" \
|
||||
OMARCHY_TEST_NOTIFICATION_CHECKS="$test_tmp/notification-checks" \
|
||||
OMARCHY_TEST_NOTIFICATIONS_DIE=1 \
|
||||
timeout 10 "$ROOT/bin/omarchy-restart-shell" >"$test_tmp/dead-notifications.out" 2>&1; then
|
||||
fail "a restart whose notification service never returns must not report success"
|
||||
fi
|
||||
wait "$restart_pid_one" 2>/dev/null || true
|
||||
restart_pid_one=""
|
||||
grep -F "ipc -n -p $restart_root/shell call -- lock lock" "$ipc_log" >/dev/null || fail "lock recovery waited on the notification service" "$(cat "$ipc_log")"
|
||||
[[ -f $restart_state.locked ]] || fail "lock recovery did not re-secure the session without notifications"
|
||||
grep -q "notification service did not become ready" "$test_tmp/dead-notifications.out" || fail "a missing notification service is not reported" "$(cat "$test_tmp/dead-notifications.out")"
|
||||
pass "restart recovers the lock even when the notification service never returns"
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
export OMARCHY_UPDATE_LOGGED=1
|
||||
|
||||
# The real fixed entrypoints run only fixture operations. The sibling library
|
||||
# is a harmless sentinel: invoking a command through another directory must
|
||||
# source the library beside the resolved command instead of this file.
|
||||
mkdir "$boundary_tmp/links"
|
||||
printf '%s\n' 'touch "$SUDO_TEST_HOME/wrong-library"' >"$boundary_tmp/links/omarchy-security-functions"
|
||||
for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set; do
|
||||
rm -f "$SUDO_TEST_ROOT/bin/$command"
|
||||
copy_boundary_file "bin/$command"
|
||||
ln -s "$SUDO_TEST_ROOT/bin/$command" "$boundary_tmp/links/$command"
|
||||
reset_boundary
|
||||
args=(unexpected)
|
||||
[[ $command != "omarchy-update" ]] || args=(-y)
|
||||
status=0
|
||||
"$boundary_tmp/links/$command" "${args[@]}" >"$boundary_tmp/output" 2>&1 || status=$?
|
||||
[[ ! -e $SUDO_TEST_HOME/wrong-library ]] || fail "$command sourced a library beside its symlink"
|
||||
(( status != 126 )) || fail "$command failed to locate its actual library" "$(<"$boundary_tmp/output")"
|
||||
[[ -s $SUDO_TEST_LOG ]] || fail "$command did not reach the protected fixture boundary"
|
||||
assert_boundary_cold "$command symlink"
|
||||
pass "$command resolves its own library when invoked through a symlink"
|
||||
done
|
||||
|
||||
ln -s "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/links/sudo"
|
||||
reset_boundary
|
||||
"$boundary_tmp/links/sudo" -k || fail "symlinked sudo wrapper lost its source library"
|
||||
[[ $(<"$SUDO_TEST_LOG") == "sudo -k" ]] || fail "symlinked wrapper did not reach the fixed sudo stand-in"
|
||||
pass "the sudo wrapper resolves its source library independently of its invocation link"
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
source "$SUDO_TEST_ROOT/bin/omarchy-security-functions"
|
||||
|
||||
rm -f "$SUDO_TEST_ROOT/bin/omarchy-update"
|
||||
copy_boundary_file bin/omarchy-update
|
||||
omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" || fail "matching checkout root was rejected"
|
||||
pass "a canonical checkout matches its own entrypoint"
|
||||
|
||||
mkdir "$boundary_tmp/other-root"
|
||||
ln -s "$SUDO_TEST_ROOT" "$boundary_tmp/root-link"
|
||||
for root in "$boundary_tmp/other-root" "$boundary_tmp/root-link" .; do
|
||||
if OMARCHY_PATH="$root" omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then
|
||||
fail "a different or noncanonical source root was accepted"
|
||||
fi
|
||||
done
|
||||
pass "different, symlink and relative roots are rejected"
|
||||
|
||||
# Redirect only the two package-layout literals into the fixture. Resolution
|
||||
# still uses real readlink/realpath; no host /usr/bin file is changed or run.
|
||||
package_root="$boundary_tmp/usr/share/omarchy"
|
||||
package_bin="$boundary_tmp/usr/bin"
|
||||
mkdir -p "$package_root/bin" "$package_bin"
|
||||
cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/omarchy-update"
|
||||
cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/different-command"
|
||||
ln -s "$package_bin/omarchy-update" "$package_root/bin/omarchy-update"
|
||||
python3 - "$SUDO_TEST_ROOT/bin/omarchy-security-functions" "$boundary_tmp/package-library" "$package_root" "$package_bin" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
source, output, root, binaries = sys.argv[1:]
|
||||
text = Path(source).read_text()
|
||||
text = text.replace('"/usr/share/omarchy"', f'"{root}"')
|
||||
text = text.replace('"/usr/bin/$command_name"', f'"{binaries}/$command_name"')
|
||||
Path(output).write_text(text)
|
||||
PY
|
||||
source "$boundary_tmp/package-library"
|
||||
OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_bin/omarchy-update" || fail "package binary was rejected"
|
||||
OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" || fail "package link was rejected"
|
||||
pass "the package binary and its matching source-tree link are accepted"
|
||||
|
||||
ln -sfn "$package_bin/different-command" "$package_root/bin/omarchy-update"
|
||||
if OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then
|
||||
fail "a package link to a different command was accepted"
|
||||
fi
|
||||
pass "a package link must resolve to its named command"
|
||||
|
||||
# Run the protected entrypoints themselves with a mismatched root. These must
|
||||
# stop before any sudo or operational fixture command, not merely validate in
|
||||
# an isolated library test.
|
||||
for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set; do
|
||||
rm -f "$SUDO_TEST_ROOT/bin/$command"
|
||||
copy_boundary_file "bin/$command"
|
||||
for root in "$boundary_tmp/other-root" .; do
|
||||
reset_boundary
|
||||
if OMARCHY_PATH="$root" "$SUDO_TEST_ROOT/bin/$command" >"$boundary_tmp/output" 2>&1; then
|
||||
fail "$command accepted a mismatched root"
|
||||
fi
|
||||
[[ ! -s $SUDO_TEST_LOG ]] || fail "$command ran work before rejecting its root"
|
||||
done
|
||||
pass "$command rejects mismatched and relative roots before work"
|
||||
done
|
||||
@@ -9,18 +9,21 @@ unset OMARCHY_UPDATE_FORCE
|
||||
unset TEST_AVAILABLE_BYTES
|
||||
unset TEST_DF_INVALID
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
test_tmp="$boundary_tmp"
|
||||
stub_bin="$SUDO_TEST_ROOT/bin"
|
||||
test_home="$SUDO_TEST_HOME"
|
||||
runtime_dir="$test_tmp/runtime"
|
||||
snapshot_marker="$test_tmp/snapshot"
|
||||
gum_marker="$test_tmp/gum"
|
||||
mkdir -p "$stub_bin" "$test_home" "$runtime_dir"
|
||||
mkdir -p "$runtime_dir"
|
||||
for command in omarchy-update omarchy-update-requires-free-space omarchy-update-confirm; do
|
||||
rm -f "$stub_bin/$command"
|
||||
copy_boundary_file "bin/$command"
|
||||
done
|
||||
|
||||
run_update() {
|
||||
HOME="$test_home" \
|
||||
SUDO_TEST_HOME="$test_home" \
|
||||
XDG_RUNTIME_DIR="$runtime_dir" \
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||
LC_ALL=C \
|
||||
@@ -30,13 +33,14 @@ run_update() {
|
||||
SNAPSHOT_MARKER="$snapshot_marker" \
|
||||
GUM_MARKER="$gum_marker" \
|
||||
GUM_STATUS=${GUM_STATUS:-1} \
|
||||
"$ROOT/bin/omarchy-update" "$@"
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update" "$@"
|
||||
}
|
||||
|
||||
write_stub() {
|
||||
local name="$1"
|
||||
local body="$2"
|
||||
|
||||
rm -f "$stub_bin/$name"
|
||||
cat >"$stub_bin/$name" <<SH
|
||||
#!/bin/bash
|
||||
$body
|
||||
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
copy_boundary_file bin/omarchy-update
|
||||
copy_boundary_file bin/omarchy-refresh-pacman
|
||||
# Replace the step symlink, preserving the real fixture dispatcher.
|
||||
rm "$SUDO_TEST_ROOT/bin/omarchy-update-aur-pkgs"
|
||||
copy_boundary_file bin/omarchy-update-aur-pkgs
|
||||
export OMARCHY_UPDATE_LOGGED=1
|
||||
|
||||
run_update() {
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$boundary_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
for args in '-y' ''; do
|
||||
reset_boundary
|
||||
touch "$SUDO_TEST_CACHE"
|
||||
run_update $args || fail "update failed" "$(<"$boundary_tmp/output")"
|
||||
assert_boundary_cold "successful update"
|
||||
grep -q '^sudo -N /usr/bin/true$' "$SUDO_TEST_LOG" || fail "update package helpers must use no-update sudo"
|
||||
python3 - "$SUDO_TEST_LOG" <<'PY'
|
||||
import sys
|
||||
s=open(sys.argv[1]).read().splitlines()
|
||||
positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-restart --services-only','step:yay','step:omarchy-hook post-update','step:omarchy-update-mise','step:omarchy-update-stay-awake stop','step:omarchy-update-restart --reboot-only']]
|
||||
assert positions==sorted(positions), s
|
||||
assert not any(line.startswith('sudo -N ') for line in s[positions[2]:]), s
|
||||
PY
|
||||
pass "update $args runs privileged phases before hooks and exits cold"
|
||||
done
|
||||
|
||||
for step in omarchy-update-system-pkgs yay omarchy-hook omarchy-update-mise; do
|
||||
reset_boundary
|
||||
export SUDO_TEST_FAIL_STEP=$step
|
||||
if run_update -y; then fail "$step failure must fail the update"; fi
|
||||
assert_boundary_cold "failed $step"
|
||||
python3 - "$SUDO_TEST_LOG" <<'PY'
|
||||
import sys
|
||||
s=open(sys.argv[1]).read().splitlines()
|
||||
for i,line in enumerate(s):
|
||||
if line=='step:omarchy-update-stay-awake stop': assert i>0 and s[i-1]=='sudo -k',s
|
||||
PY
|
||||
pass "update revokes credentials after $step fails"
|
||||
done
|
||||
|
||||
reset_boundary
|
||||
export SUDO_TEST_SIGNAL_STEP=omarchy-hook
|
||||
if run_update -y; then fail "interrupted update must fail"; fi
|
||||
assert_boundary_cold "interrupted update"
|
||||
pass "update revokes credentials on TERM"
|
||||
|
||||
reset_boundary
|
||||
export SUDO_TEST_REVOKE_FAIL=1
|
||||
if run_update -y; then fail "failed initial revocation must fail the update"; fi
|
||||
if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "failed revocation must precede update work"; fi
|
||||
pass "a failed cold start prevents update work"
|
||||
|
||||
reset_boundary
|
||||
export SUDO_TEST_UNSUPPORTED=1
|
||||
if run_update -y; then fail "unsupported sudo must prevent mixed-trust work"; fi
|
||||
assert_boundary_cold "unsupported sudo"
|
||||
pass "unsupported sudo fails without running update steps"
|
||||
|
||||
reset_boundary
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable >"$boundary_tmp/output" 2>&1 || fail "refresh failed" "$(<"$boundary_tmp/output")"
|
||||
assert_boundary_cold "refresh"
|
||||
python3 - "$SUDO_TEST_LOG" <<'PY'
|
||||
import sys
|
||||
s=open(sys.argv[1]).read().splitlines()
|
||||
hook=next(i for i,l in enumerate(s) if l=='step:omarchy-hook pre-refresh-pacman')
|
||||
copies=[i for i,l in enumerate(s) if l.startswith('sudo -N cp ')]
|
||||
transaction=next(i for i,l in enumerate(s) if l.startswith('step:pacman '))
|
||||
assert len(copies)==4 and max(copies) < hook < transaction, s
|
||||
assert s[hook-1]=='sudo -k' and s[hook+1]=='sudo -k', s
|
||||
assert all(l in ('sudo -h','sudo -k') or l.startswith('sudo -N ') for l in s if l.startswith('sudo ')), s
|
||||
PY
|
||||
pass "refresh runs the hook cold between the config re-sync and the transaction"
|
||||
|
||||
for step in pacman omarchy-hook; do
|
||||
reset_boundary
|
||||
export SUDO_TEST_FAIL_STEP=$step
|
||||
if "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable >"$boundary_tmp/output" 2>&1; then fail "refresh must propagate $step failure"; fi
|
||||
assert_boundary_cold "failed refresh $step"
|
||||
pass "refresh revokes after $step failure"
|
||||
done
|
||||
|
||||
# The wrapper must preserve sudo's own option parser, including validation and
|
||||
# explicit --, while standalone timestamp maintenance cannot be combined with N.
|
||||
for args in '-v' '-n /usr/bin/true' '--user test -- /usr/bin/true' '-- /usr/bin/true' '-k' '-K'; do
|
||||
reset_boundary
|
||||
"$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" $args
|
||||
case "$args" in
|
||||
-k|-K) expected="sudo $args" ;;
|
||||
*) expected="sudo -N $args" ;;
|
||||
esac
|
||||
[[ $(<"$SUDO_TEST_LOG") == "$expected" ]] || fail "wrapper changed options: $args" "$(<"$SUDO_TEST_LOG")"
|
||||
[[ ! -e $SUDO_TEST_CACHE ]] || fail "wrapper refreshed credentials"
|
||||
pass "sudo wrapper preserves $args"
|
||||
done
|
||||
|
||||
for script in bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
|
||||
reset_boundary
|
||||
if /usr/bin/bash "$SUDO_TEST_ROOT/$script" -p >"$boundary_tmp/output" 2>&1; then fail "$script accepted an ordinary Bash launch"; fi
|
||||
[[ ! -s $SUDO_TEST_LOG ]] || fail "$script reached sudo through an invalid interpreter"
|
||||
pass "$script rejects a decoy privileged-mode argument"
|
||||
done
|
||||
|
||||
reset_boundary
|
||||
printf '%s\n' 'printf startup-ran >>"$SUDO_TEST_ROOT/startup-marker"' >"$boundary_tmp/startup"
|
||||
BASH_ENV="$boundary_tmp/startup" ENV="$boundary_tmp/startup" run_update -y || fail "sanitized update failed" "$(<"$boundary_tmp/output")"
|
||||
[[ ! -e $SUDO_TEST_ROOT/startup-marker ]] || fail "startup code leaked into an update helper"
|
||||
pass "inherited startup files do not run in the updater or its child scripts"
|
||||
|
||||
reset_boundary
|
||||
function printf() { /usr/bin/touch "$SUDO_TEST_ROOT/function-marker"; }
|
||||
export -f printf
|
||||
run_update -y || fail "update failed with inherited function" "$(<"$boundary_tmp/output")"
|
||||
unset -f printf
|
||||
[[ ! -e $SUDO_TEST_ROOT/function-marker ]] || fail "an inherited function reached an update helper"
|
||||
pass "exported functions do not reach update helper interpreters"
|
||||
@@ -4,16 +4,40 @@ set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
runtime_dir="$test_tmp/runtime"
|
||||
mkdir -p "$stub_bin" "$test_home" "$runtime_dir"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
test_tmp="$boundary_tmp"
|
||||
stub_bin="$SUDO_TEST_ROOT/bin"
|
||||
test_home="$SUDO_TEST_HOME"
|
||||
runtime_dir="/run/user/$(id -u)"
|
||||
test_run_id="test-$BASHPID-$RANDOM"
|
||||
update_lock_name="omarchy-update-$test_run_id.lock"
|
||||
stay_awake_dir_name="omarchy-update-stay-awake-$test_run_id"
|
||||
trap 'rm -rf -- "$runtime_dir/$stay_awake_dir_name"; rm -f -- "$runtime_dir/$update_lock_name"; rm -rf -- "$boundary_tmp"' EXIT
|
||||
for command in omarchy-update omarchy-update-lock omarchy-update-stay-awake; do
|
||||
rm -f "$SUDO_TEST_ROOT/bin/$command"
|
||||
copy_boundary_file "bin/$command"
|
||||
done
|
||||
sed -i \
|
||||
-e "s/omarchy-update\.lock/$update_lock_name/g" \
|
||||
-e "s#state_dir=\"\$state_base/omarchy-update-stay-awake\"#state_dir=\"\$state_base/$stay_awake_dir_name\"#" \
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update" \
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update-lock" \
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake"
|
||||
cat >"$SUDO_TEST_ROOT/mock/setpriv" <<'STUB'
|
||||
#!/bin/bash
|
||||
while [[ ${1:-} == --* ]]; do
|
||||
case "$1" in
|
||||
--reuid|--regid) shift 2 ;;
|
||||
--clear-groups) shift ;;
|
||||
*) exit 90 ;;
|
||||
esac
|
||||
done
|
||||
exec "$@"
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/mock/setpriv"
|
||||
|
||||
run_with_lock_env() {
|
||||
HOME="$test_home" \
|
||||
SUDO_TEST_HOME="$test_home" \
|
||||
XDG_RUNTIME_DIR="$runtime_dir" \
|
||||
XDG_STATE_HOME="$test_tmp/state" \
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||
@@ -24,6 +48,7 @@ write_stub() {
|
||||
local name="$1"
|
||||
local body="$2"
|
||||
|
||||
rm -f "$stub_bin/$name"
|
||||
cat >"$stub_bin/$name" <<SH
|
||||
#!/bin/bash
|
||||
$body
|
||||
@@ -51,13 +76,16 @@ for command in \
|
||||
done
|
||||
write_stub omarchy-update-available 'exit 1'
|
||||
write_stub pkexec 'exec "$@"'
|
||||
ln -s ../bin/pkexec "$SUDO_TEST_ROOT/mock/pkexec"
|
||||
write_stub systemd-inhibit 'while [[ $1 == --* ]]; do shift; done; exec "$@"'
|
||||
ln -s ../bin/systemd-inhibit "$SUDO_TEST_ROOT/mock/systemd-inhibit"
|
||||
|
||||
# omarchy-update should hold the lock before snapshotting, so a second update
|
||||
# cannot even enter its pre-update snapshot.
|
||||
update_snapshot_marker="$test_tmp/update-snapshot-started"
|
||||
write_stub omarchy-snapshot 'echo started >"$TEST_MARKER"; sleep 2; exit 0'
|
||||
|
||||
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 &
|
||||
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 &
|
||||
update_pid=$!
|
||||
|
||||
for _ in {1..50}; do
|
||||
@@ -67,7 +95,7 @@ done
|
||||
[[ -f $update_snapshot_marker ]] || fail "first omarchy-update reached snapshot under lock"
|
||||
|
||||
set +e
|
||||
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1
|
||||
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1
|
||||
update_second_status=$?
|
||||
set -e
|
||||
|
||||
@@ -85,11 +113,11 @@ pass "omarchy-update prevents overlapping top-level updates"
|
||||
inhibit_pid_file="$test_tmp/inhibit-pid"
|
||||
keyring_marker="$test_tmp/keyring-started"
|
||||
write_stub omarchy-snapshot 'exit 0'
|
||||
write_stub systemd-inhibit 'echo "$$" >"$INHIBIT_PID_FILE"; exec sleep 30'
|
||||
write_stub systemd-inhibit '[[ -z ${INHIBIT_PID_FILE:-} ]] || echo "$$" >"$INHIBIT_PID_FILE"; while [[ $1 == --* ]]; do shift; done; exec "$@"'
|
||||
write_stub omarchy-update-keyring 'echo started >"$TEST_MARKER"; sleep 3; exit 0'
|
||||
|
||||
OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$keyring_marker" INHIBIT_PID_FILE="$inhibit_pid_file" \
|
||||
run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 &
|
||||
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 &
|
||||
inhibit_update_pid=$!
|
||||
|
||||
for _ in {1..100}; do
|
||||
@@ -101,7 +129,7 @@ done
|
||||
inhibitor_pid=$(<"$inhibit_pid_file")
|
||||
kill -0 "$inhibitor_pid" 2>/dev/null || fail "sleep inhibitor is still running when its descriptors are inspected"
|
||||
|
||||
lock_target=$(readlink -f "$runtime_dir/omarchy-update.lock")
|
||||
lock_target=$(readlink -f "$runtime_dir/$update_lock_name")
|
||||
inhibitor_holds_lock=0
|
||||
for fd in /proc/"$inhibitor_pid"/fd/*; do
|
||||
[[ -e $fd ]] || continue
|
||||
@@ -118,39 +146,104 @@ kill -0 "$inhibitor_pid" 2>/dev/null &&
|
||||
pass "omarchy-update waits for its sleep inhibitor to stop"
|
||||
|
||||
if (( EUID != 0 )); then
|
||||
sudo_log="$test_tmp/sudo.log"
|
||||
sudo_log="$SUDO_TEST_LOG"
|
||||
: >"$sudo_log"
|
||||
pkexec_marker="$test_tmp/pkexec-used"
|
||||
terminal_inhibit_pid_file="$test_tmp/terminal-inhibit-pid"
|
||||
write_stub sudo '
|
||||
printf "%s\n" "$*" >>"$SUDO_LOG"
|
||||
if [[ $1 == "-v" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exec "$@"'
|
||||
write_stub pkexec 'touch "$PKEXEC_MARKER"; exec "$@"'
|
||||
write_stub pkexec '[[ -z ${PKEXEC_MARKER:-} ]] || touch "$PKEXEC_MARKER"; exec "$@"'
|
||||
write_stub systemd-inhibit 'sleep 0.2; while [[ $1 == --* ]]; do shift; done; exec "$@"'
|
||||
|
||||
# start leaves the inhibitor running on purpose, but script tears the pty down
|
||||
# the moment its command returns, which SIGHUPs that inhibitor before it can
|
||||
# exec. Keep the session open from the inside until the stub has logged.
|
||||
# sudo -b returns before its child is ready. Require start to wait for the
|
||||
# delayed child and succeed, then stop it before script tears down the PTY.
|
||||
terminal_driver="$test_tmp/terminal-stay-awake"
|
||||
cat >"$terminal_driver" <<'SH'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
omarchy-update-stay-awake start
|
||||
for _ in {1..200}; do
|
||||
grep -q '^systemd-inhibit ' "$SUDO_LOG" && break
|
||||
sleep 0.05
|
||||
done
|
||||
[[ -s $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]]
|
||||
omarchy-update-stay-awake stop
|
||||
[[ ! -e $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]]
|
||||
SH
|
||||
sed -i "s/REPLACE_STAY_AWAKE_DIR/$stay_awake_dir_name/g" "$terminal_driver"
|
||||
chmod +x "$terminal_driver"
|
||||
|
||||
SUDO_LOG="$sudo_log" PKEXEC_MARKER="$pkexec_marker" INHIBIT_PID_FILE="$terminal_inhibit_pid_file" \
|
||||
run_with_lock_env script -qefc "$terminal_driver" /dev/null >/dev/null
|
||||
|
||||
grep -qx -- '-v' "$sudo_log" || fail "terminal sleep inhibition validates sudo in the foreground"
|
||||
grep -q '^systemd-inhibit ' "$sudo_log" || fail "terminal sleep inhibition runs through sudo"
|
||||
grep -q -- '^sudo -N -b -- ' "$sudo_log" || fail "terminal inhibition authenticates its background command without a reusable timestamp"
|
||||
[[ ! -e $pkexec_marker ]] || fail "terminal sleep inhibition does not use pkexec"
|
||||
run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop
|
||||
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop
|
||||
pass "terminal updates use sudo instead of Polkit for sleep inhibition"
|
||||
|
||||
wait_for_process_exit() {
|
||||
local process_pid="$1"
|
||||
|
||||
for _ in {1..100}; do
|
||||
kill -0 "$process_pid" 2>/dev/null || return 0
|
||||
[[ $(awk '{ print $3 }' "/proc/$process_pid/stat" 2>/dev/null || true) == "Z" ]] && return 0
|
||||
sleep 0.02
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
delayed_marker="$test_tmp/delayed-inhibitor"
|
||||
delayed_helper_pid_file="$test_tmp/delayed-helper-pid"
|
||||
write_stub systemd-inhibit 'echo "$$" >"$DELAYED_MARKER"; sleep 0.4; while [[ $1 == --* ]]; do shift; done; exec "$@"'
|
||||
|
||||
# Keep the start helper's stdin attached to the private PTY so it takes the
|
||||
# sudo -b branch, then signal only that helper before the held child publishes.
|
||||
delayed_terminal_driver="$test_tmp/delayed-terminal-stay-awake"
|
||||
cat >"$delayed_terminal_driver" <<'SH'
|
||||
#!/bin/bash
|
||||
set +e
|
||||
omarchy-update-stay-awake start </dev/tty &
|
||||
helper_pid=$!
|
||||
echo "$helper_pid" >"$DELAYED_HELPER_PID_FILE"
|
||||
wait "$helper_pid"
|
||||
exit $?
|
||||
SH
|
||||
chmod +x "$delayed_terminal_driver"
|
||||
DELAYED_MARKER="$delayed_marker" DELAYED_HELPER_PID_FILE="$delayed_helper_pid_file" \
|
||||
run_with_lock_env script -qefc "$delayed_terminal_driver" /dev/null >"$test_tmp/delayed-terminal.out" 2>&1 &
|
||||
delayed_terminal_driver_pid=$!
|
||||
for _ in {1..100}; do
|
||||
[[ -s $delayed_marker && -s $delayed_helper_pid_file ]] && break
|
||||
sleep 0.02
|
||||
done
|
||||
[[ -s $delayed_marker && -s $delayed_helper_pid_file ]] || fail "terminal cancellation reaches the delayed launch window"
|
||||
kill -TERM "$(<"$delayed_helper_pid_file")"
|
||||
wait "$delayed_terminal_driver_pid" || true
|
||||
delayed_inhibitor_pid=$(<"$delayed_marker")
|
||||
wait_for_process_exit "$delayed_inhibitor_pid" || fail "terminal cancellation leaves no delayed inhibitor"
|
||||
[[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "terminal cancellation leaves no launch state"
|
||||
pass "terminal cancellation rolls back delayed publication"
|
||||
|
||||
# With redirected stdin the same helper takes the graphical pkexec branch.
|
||||
: >"$delayed_marker"
|
||||
delayed_graphical_helper_pid_file="$test_tmp/delayed-graphical-helper-pid"
|
||||
delayed_graphical_driver="$test_tmp/delayed-graphical-stay-awake"
|
||||
cat >"$delayed_graphical_driver" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "$$" >"$DELAYED_HELPER_PID_FILE"
|
||||
exec omarchy-update-stay-awake start </dev/null
|
||||
SH
|
||||
chmod +x "$delayed_graphical_driver"
|
||||
DELAYED_MARKER="$delayed_marker" DELAYED_HELPER_PID_FILE="$delayed_graphical_helper_pid_file" \
|
||||
run_with_lock_env "$delayed_graphical_driver" >"$test_tmp/delayed-graphical.out" 2>&1 &
|
||||
delayed_graphical_driver_pid=$!
|
||||
for _ in {1..100}; do
|
||||
[[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] && break
|
||||
sleep 0.02
|
||||
done
|
||||
[[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] || fail "graphical cancellation reaches the delayed launch window"
|
||||
delayed_graphical_helper_pid=$(<"$delayed_graphical_helper_pid_file")
|
||||
kill -TERM "$delayed_graphical_helper_pid"
|
||||
wait "$delayed_graphical_driver_pid" || true
|
||||
delayed_inhibitor_pid=$(<"$delayed_marker")
|
||||
wait_for_process_exit "$delayed_inhibitor_pid" || fail "graphical cancellation leaves no delayed inhibitor"
|
||||
[[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "graphical cancellation leaves no launch state"
|
||||
pass "graphical cancellation rolls back delayed publication"
|
||||
write_stub systemd-inhibit 'while [[ $1 == --* ]]; do shift; done; exec "$@"'
|
||||
fi
|
||||
|
||||
# Update-owned Stay Awake state must be cleared before the restart helper can
|
||||
@@ -158,7 +251,7 @@ fi
|
||||
write_stub omarchy-snapshot 'exit 0'
|
||||
write_stub omarchy-update-keyring 'exit 0'
|
||||
write_stub omarchy-toggle-idle '
|
||||
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
|
||||
state_file="$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake"
|
||||
case "$1" in
|
||||
stay-awake)
|
||||
mkdir -p "$(dirname "$state_file")"
|
||||
@@ -169,32 +262,65 @@ case "$1" in
|
||||
;;
|
||||
esac'
|
||||
write_stub omarchy-update-restart '
|
||||
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
|
||||
if [[ ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then
|
||||
state_file="$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake"
|
||||
if [[ ${1:-} == "--services-only" || ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then
|
||||
[[ -f $state_file ]]
|
||||
else
|
||||
[[ ! -f $state_file ]]
|
||||
fi'
|
||||
|
||||
rm -f "$test_home/.local/state/omarchy/indicators/stay-awake"
|
||||
OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y
|
||||
OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y
|
||||
[[ ! -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update clears its Stay Awake state before restart handling"
|
||||
|
||||
mkdir -p "$test_home/.local/state/omarchy/indicators"
|
||||
touch "$test_home/.local/state/omarchy/indicators/stay-awake"
|
||||
OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y
|
||||
OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y
|
||||
[[ -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update preserves pre-existing Stay Awake state"
|
||||
pass "omarchy-update restores only its own Stay Awake state before restart handling"
|
||||
|
||||
# Model package replacement while the existing updater is still running:
|
||||
# start writes the old two-field state; the transaction installs the real new
|
||||
# helper, whose stop must clean that state before reboot handling.
|
||||
cp "$stub_bin/omarchy-update-stay-awake" "$test_tmp/inhibitor-after-upgrade"
|
||||
write_stub omarchy-update-stay-awake '
|
||||
set -e
|
||||
[[ $1 == "start" ]]
|
||||
umask 022
|
||||
state="$XDG_RUNTIME_DIR/$LEGACY_STATE_NAME"
|
||||
mkdir -p "$state" "$SUDO_TEST_HOME/.local/state/omarchy/indicators"
|
||||
( exec {OMARCHY_UPDATE_LOCK_FD}>&-; exec sleep infinity ) &
|
||||
pid=$!
|
||||
printf "%s %s\n" "$pid" "$(awk '\''{ print $22 }'\'' /proc/$pid/stat)" >"$state/inhibit-pid"
|
||||
printf "%s:1:1\n" "$$" >"$state/idle-owner"
|
||||
/usr/bin/cp "$state/idle-owner" "$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake"'
|
||||
write_stub omarchy-update-system-pkgs '
|
||||
/usr/bin/cp "$INHIBITOR_AFTER_UPGRADE" "$OMARCHY_PATH/bin/omarchy-update-stay-awake"'
|
||||
write_stub omarchy-update-restart '
|
||||
if [[ $1 == "--reboot-only" ]]; then
|
||||
[[ ! -e $SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake ]] || exit 91
|
||||
[[ ! -e $XDG_RUNTIME_DIR/$LEGACY_STATE_NAME ]] || exit 92
|
||||
touch "$UPGRADE_RESTARTED"
|
||||
fi'
|
||||
rm -f "$test_home/.local/state/omarchy/indicators/stay-awake"
|
||||
OMARCHY_UPDATE_LOGGED=1 LEGACY_STATE_NAME="$stay_awake_dir_name" \
|
||||
INHIBITOR_AFTER_UPGRADE="$test_tmp/inhibitor-after-upgrade" \
|
||||
UPGRADE_RESTARTED="$test_tmp/upgrade-restarted" \
|
||||
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y
|
||||
[[ -e $test_tmp/upgrade-restarted ]] || fail "first upgrade did not reach reboot handling"
|
||||
pass "first upgrade cleans old inhibitor state with the newly installed helper"
|
||||
|
||||
# Stale cleanup state from a killed update must not override a Stay Awake choice
|
||||
# the user made afterward.
|
||||
stay_awake_helper_state="$runtime_dir/omarchy-update-stay-awake"
|
||||
stay_awake_helper_state="$runtime_dir/$stay_awake_dir_name"
|
||||
stay_awake_state="$test_home/.local/state/omarchy/indicators/stay-awake"
|
||||
mkdir -p "$stay_awake_helper_state" "$(dirname "$stay_awake_state")"
|
||||
printf '%s\n' "old-update-owner" >"$stay_awake_helper_state/idle-owner"
|
||||
mkdir -m 700 -p "$stay_awake_helper_state"
|
||||
mkdir -p "$(dirname "$stay_awake_state")"
|
||||
printf '%s\n' "123:456:789" >"$stay_awake_helper_state/idle-owner"
|
||||
chmod 600 "$stay_awake_helper_state/idle-owner"
|
||||
printf '%s\n' "user-choice" >"$stay_awake_state"
|
||||
|
||||
run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop
|
||||
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop
|
||||
[[ $(<"$stay_awake_state") == "user-choice" ]] ||
|
||||
fail "stale update ownership does not remove a newer Stay Awake choice"
|
||||
pass "stale update ownership preserves a newer Stay Awake choice"
|
||||
@@ -203,12 +329,29 @@ pass "stale update ownership preserves a newer Stay Awake choice"
|
||||
sleep 30 >/dev/null &
|
||||
unrelated_pid=$!
|
||||
unrelated_start_time=$(awk '{ print $22 }' "/proc/$unrelated_pid/stat")
|
||||
mkdir -p "$stay_awake_helper_state"
|
||||
printf '%s %s\n' "$unrelated_pid" "$((unrelated_start_time + 1))" >"$stay_awake_helper_state/inhibit-pid"
|
||||
mkdir -m 700 -p "$stay_awake_helper_state"
|
||||
printf '1 %s %s %s %032x\n' "$unrelated_pid" "$((unrelated_start_time + 1))" "$(id -u)" 1 >"$stay_awake_helper_state/inhibit-pid"
|
||||
chmod 600 "$stay_awake_helper_state/inhibit-pid"
|
||||
|
||||
run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop
|
||||
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop
|
||||
kill -0 "$unrelated_pid" 2>/dev/null ||
|
||||
fail "stale inhibitor state does not terminate a reused PID"
|
||||
kill "$unrelated_pid"
|
||||
wait "$unrelated_pid" 2>/dev/null || true
|
||||
pass "stale inhibitor state does not terminate a reused PID"
|
||||
|
||||
# The hidden helper also establishes its own boundary when invoked directly.
|
||||
reset_boundary
|
||||
touch "$SUDO_TEST_CACHE"
|
||||
run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop
|
||||
[[ $(head -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "standalone inhibitor cleanup did not start cold"
|
||||
assert_boundary_cold "standalone inhibitor cleanup"
|
||||
pass "standalone inhibitor cleanup revokes before and after session work"
|
||||
|
||||
reset_boundary
|
||||
export SUDO_TEST_REVOKE_FAIL=1
|
||||
if run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" start; then
|
||||
fail "inhibitor started after failed initial revocation"
|
||||
fi
|
||||
[[ ! -e $stay_awake_helper_state/inhibit-pid ]] || fail "failed revocation started an inhibitor"
|
||||
pass "failed initial revocation prevents standalone inhibition"
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
rm "$SUDO_TEST_ROOT/bin/omarchy-update-restart"
|
||||
copy_boundary_file bin/omarchy-update-restart
|
||||
for step in omarchy-state omarchy-restart-sshd omarchy-restart-shell omarchy-system-reboot; do
|
||||
ln -s test-step "$SUDO_TEST_ROOT/bin/$step"
|
||||
done
|
||||
cat >"$SUDO_TEST_ROOT/bin/gum" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'prompt:%s\n' "$*" >>"$SUDO_TEST_LOG"
|
||||
exit 1
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/gum"
|
||||
mkdir -p "$SUDO_TEST_HOME/.local/state/omarchy"
|
||||
touch "$SUDO_TEST_HOME/.local/state/omarchy/reboot-required" "$SUDO_TEST_HOME/.local/state/omarchy/restart-sshd-required"
|
||||
|
||||
for mode in --services-only --reboot-only; do
|
||||
reset_boundary
|
||||
PATH="$SUDO_TEST_ROOT/bin:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update-restart" "$mode" >"$boundary_tmp/output" 2>&1
|
||||
if [[ $mode == "--services-only" ]]; then
|
||||
grep -q '^step:omarchy-restart-sshd ' "$SUDO_TEST_LOG" || fail "service phase did not restart a marked service"
|
||||
grep -q '^step:omarchy-restart-shell ' "$SUDO_TEST_LOG" || fail "service phase did not restart the shell"
|
||||
if grep -q '^prompt:' "$SUDO_TEST_LOG"; then fail "service phase offered a reboot before update cleanup"; fi
|
||||
else
|
||||
grep -q '^prompt:' "$SUDO_TEST_LOG" || fail "reboot phase did not offer the required reboot"
|
||||
if grep -q '^step:omarchy-restart-' "$SUDO_TEST_LOG"; then fail "reboot phase performed later service work"; fi
|
||||
fi
|
||||
pass "restart $mode performs only its selected phase"
|
||||
done
|
||||
reset_boundary
|
||||
OMARCHY_UPDATE_UNATTENDED=1 PATH="$SUDO_TEST_ROOT/bin:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update-restart" --reboot-only >"$boundary_tmp/output" 2>&1
|
||||
if grep -Eq "^(prompt:|step:omarchy-restart-|step:omarchy-system-reboot)" "$SUDO_TEST_LOG"; then
|
||||
fail "unattended reboot phase prompted or performed service work"
|
||||
fi
|
||||
pass "unattended reboot phase reports a required reboot without prompting"
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
copy_boundary_file bin/omarchy-update
|
||||
test_tmp="$boundary_tmp"
|
||||
stub_bin="$SUDO_TEST_ROOT/bin"
|
||||
|
||||
# Every step omarchy-update runs, recorded in order with the unattended flag it
|
||||
# was handed. One of them can be told to fail.
|
||||
@@ -33,6 +31,7 @@ steps=(
|
||||
)
|
||||
|
||||
for step in "${steps[@]}"; do
|
||||
rm -f "$stub_bin/$step"
|
||||
cat >"$stub_bin/$step" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG"
|
||||
@@ -49,7 +48,7 @@ run_update() {
|
||||
FAILING_STEP="${FAILING_STEP:-}" \
|
||||
OMARCHY_UPDATE_LOGGED=1 \
|
||||
PATH="$stub_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err"
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err"
|
||||
}
|
||||
|
||||
steps_run() {
|
||||
@@ -70,12 +69,13 @@ expected_steps() {
|
||||
omarchy-update-keyring \
|
||||
omarchy-update-system-pkgs \
|
||||
omarchy-migrate \
|
||||
omarchy-hook \
|
||||
omarchy-update-aur-pkgs \
|
||||
omarchy-update-mise \
|
||||
omarchy-update-orphan-pkgs \
|
||||
omarchy-update-analyze-logs \
|
||||
omarchy-update-status \
|
||||
omarchy-update-restart \
|
||||
omarchy-update-aur-pkgs \
|
||||
omarchy-hook \
|
||||
omarchy-update-mise \
|
||||
omarchy-update-stay-awake \
|
||||
omarchy-update-restart
|
||||
}
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
test_processes=()
|
||||
test_runtime_created=""
|
||||
cleanup_test() {
|
||||
for pid in "${test_processes[@]}"; do kill "$pid" 2>/dev/null || true; done
|
||||
[[ -z ${state_dir:-} ]] || rm -rf -- "$state_dir"
|
||||
[[ -z ${state_hardlink:-} ]] || rm -f -- "$state_hardlink"
|
||||
rm -rf -- "$test_tmp"
|
||||
[[ -z $test_runtime_created ]] || rmdir -- "$test_runtime_created" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup_test EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
mapped_root="$test_tmp/omarchy"
|
||||
test_home="$test_tmp/home"
|
||||
runtime_dir=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
|
||||
if [[ ! -d $runtime_dir || -L $runtime_dir || $(stat -Lc '%u %a' "$runtime_dir" 2>/dev/null || true) != "$(id -u) 700" ]]; then
|
||||
if (( EUID != 0 )); then
|
||||
fail "test needs a private XDG runtime directory or root namespace"
|
||||
fi
|
||||
runtime_dir=$(mktemp -d -p /run omarchy-stay-awake-runtime.XXXXXXXX)
|
||||
chmod 0700 "$runtime_dir"
|
||||
test_runtime_created="$runtime_dir"
|
||||
fi
|
||||
test_run_id="test-$BASHPID-$RANDOM"
|
||||
state_dir="$runtime_dir/omarchy-update-stay-awake-$test_run_id"
|
||||
state_hardlink="$runtime_dir/.omarchy-update-stay-awake-hardlink-$test_run_id"
|
||||
inhibitor_log="$test_tmp/inhibitors"
|
||||
mkdir -p "$stub_bin" "$test_home" "$mapped_root/bin" "$mapped_root/default/omarchy/sudo-no-update"
|
||||
: >"$inhibitor_log"
|
||||
|
||||
cat >"$stub_bin/pkexec" <<'SH'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
case ${1:-} in
|
||||
-h) echo 'usage: sudo [-bHkNnPS] command'; exit 0 ;;
|
||||
-k|-K|-v) exit 0 ;;
|
||||
esac
|
||||
background=0
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
-N|-n) shift ;;
|
||||
-b) background=1; shift ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
if (( background )); then
|
||||
"$@" &
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/setpriv" <<'SH'
|
||||
#!/bin/bash
|
||||
while [[ ${1:-} == --* ]]; do
|
||||
case "$1" in
|
||||
--reuid|--regid) shift 2 ;;
|
||||
--clear-groups) shift ;;
|
||||
*) exit 90 ;;
|
||||
esac
|
||||
done
|
||||
exec "$@"
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/systemd-inhibit" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${SYSTEMD_FAIL:-0} == "0" ]] || exit 42
|
||||
printf '%s\n' "$$" >>"$INHIBITOR_LOG"
|
||||
if [[ -n ${CREATE_BAD_IDLE:-} ]]; then
|
||||
ln -s "$CREATE_BAD_IDLE" "$TEST_STATE_DIR/idle-owner"
|
||||
fi
|
||||
trap 'exit 0' TERM
|
||||
while [[ ${1:-} == --* ]]; do shift; done
|
||||
exec "$@"
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/omarchy-toggle-idle" <<'SH'
|
||||
#!/bin/bash
|
||||
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
|
||||
case "$1" in
|
||||
stay-awake)
|
||||
mkdir -p "$(dirname "$state_file")"
|
||||
touch "$state_file"
|
||||
;;
|
||||
allow-idle)
|
||||
rm -f "$state_file"
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$stub_bin"/*
|
||||
ln -s "$stub_bin/omarchy-toggle-idle" "$mapped_root/bin/omarchy-toggle-idle"
|
||||
|
||||
mapped_helper="$mapped_root/bin/omarchy-update-stay-awake"
|
||||
cp "$ROOT/bin/omarchy-update-stay-awake" "$mapped_helper"
|
||||
cp "$ROOT/bin/omarchy-security-functions" "$mapped_root/bin/omarchy-security-functions"
|
||||
cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$mapped_root/default/omarchy/sudo-no-update/sudo"
|
||||
for mapped_file in \
|
||||
"$mapped_helper" \
|
||||
"$mapped_root/bin/omarchy-security-functions" \
|
||||
"$mapped_root/default/omarchy/sudo-no-update/sudo"; do
|
||||
sed -i \
|
||||
-e "s#/usr/bin/sudo#$stub_bin/sudo#g" \
|
||||
-e "s#/usr/bin/pkexec#$stub_bin/pkexec#g" \
|
||||
-e "s#/usr/bin/systemd-inhibit#$stub_bin/systemd-inhibit#g" \
|
||||
-e "s#/usr/bin/setpriv#$stub_bin/setpriv#g" \
|
||||
-e 's#state_dir="$state_base/omarchy-update-stay-awake"#state_dir="$state_base/omarchy-update-stay-awake-${OMARCHY_TEST_RUN_ID:?}"#' \
|
||||
"$mapped_file"
|
||||
done
|
||||
chmod +x "$mapped_helper" "$mapped_root/default/omarchy/sudo-no-update/sudo"
|
||||
|
||||
run_helper() {
|
||||
HOME="$test_home" \
|
||||
XDG_RUNTIME_DIR="$runtime_dir" \
|
||||
INHIBITOR_LOG="$inhibitor_log" \
|
||||
TEST_STATE_DIR="$state_dir" \
|
||||
OMARCHY_TEST_RUN_ID="$test_run_id" \
|
||||
OMARCHY_PATH="$mapped_root" \
|
||||
PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
|
||||
"$mapped_helper" "$@"
|
||||
}
|
||||
|
||||
wait_dead() {
|
||||
local pid="$1"
|
||||
|
||||
for _ in {1..100}; do
|
||||
kill -0 "$pid" 2>/dev/null || return 0
|
||||
[[ $(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null || true) == "Z" ]] && return 0
|
||||
sleep 0.02
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
prepare_state_dir() {
|
||||
rm -rf "$state_dir"
|
||||
mkdir -m 700 "$state_dir"
|
||||
}
|
||||
|
||||
write_inhibit_state() {
|
||||
local record="$1"
|
||||
|
||||
printf '%s\n' "$record" >"$state_dir/inhibit-pid"
|
||||
chmod 600 "$state_dir/inhibit-pid"
|
||||
}
|
||||
|
||||
start_identity_process() {
|
||||
local token="$1"
|
||||
|
||||
/usr/bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.05; done' \
|
||||
omarchy-test "--why=Omarchy update in progress [$token]" &
|
||||
identity_pid=$!
|
||||
test_processes+=("$identity_pid")
|
||||
identity_start=$(awk '{ print $22 }' "/proc/$identity_pid/stat")
|
||||
identity_owner=$(stat -Lc '%u' "/proc/$identity_pid")
|
||||
}
|
||||
|
||||
run_helper start
|
||||
[[ -s $state_dir/inhibit-pid ]] || fail "valid XDG runtime publishes inhibitor state"
|
||||
read -r version valid_pid valid_start valid_owner valid_token <"$state_dir/inhibit-pid"
|
||||
[[ $version == "1" && $valid_token =~ ^[0-9a-f]{32}$ ]] || fail "inhibitor state is an exact versioned identity"
|
||||
[[ $(stat -Lc '%u %a %h' "$state_dir/inhibit-pid") == "$(id -u) 600 1" ]] ||
|
||||
fail "inhibitor state is private, caller-owned, and singly linked"
|
||||
run_helper stop
|
||||
wait_dead "$valid_pid" || fail "valid inhibitor identity is stopped"
|
||||
[[ ! -e $state_dir ]] || fail "valid state is cleaned after stop"
|
||||
pass "valid XDG runtime uses private atomic inhibitor state"
|
||||
|
||||
permissive_runtime="$test_tmp/permissive-runtime"
|
||||
mkdir -m 755 "$permissive_runtime"
|
||||
if HOME="$test_home" XDG_RUNTIME_DIR="$permissive_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
|
||||
OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then
|
||||
fail "permissive XDG runtime is rejected"
|
||||
fi
|
||||
symlink_runtime="$test_tmp/runtime-link"
|
||||
ln -s "$runtime_dir" "$symlink_runtime"
|
||||
if HOME="$test_home" XDG_RUNTIME_DIR="$symlink_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
|
||||
OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then
|
||||
fail "symlink XDG runtime is rejected"
|
||||
fi
|
||||
if HOME="$test_home" XDG_RUNTIME_DIR="$test_tmp/../${test_tmp##*/}/runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
|
||||
OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then
|
||||
fail "non-canonical XDG runtime is rejected"
|
||||
fi
|
||||
pass "unsafe XDG runtime directories are rejected"
|
||||
|
||||
mkdir -m 700 "$test_tmp/state-target"
|
||||
ln -s "$test_tmp/state-target" "$state_dir"
|
||||
if run_helper stop 2>/dev/null; then
|
||||
fail "symlink inhibitor state directory is rejected"
|
||||
fi
|
||||
rm -f "$state_dir"
|
||||
mkdir -m 777 "$state_dir"
|
||||
chmod 777 "$state_dir"
|
||||
if run_helper stop 2>/dev/null; then
|
||||
fail "writable-by-others inhibitor state directory is rejected"
|
||||
fi
|
||||
rm -rf "$state_dir"
|
||||
pass "unsafe inhibitor state directories are rejected"
|
||||
|
||||
# Package replacement leaves the preceding helper's PID/start pair and its
|
||||
# umask-derived modes for the newly installed stop command to consume.
|
||||
idle_marker="$test_home/.local/state/omarchy/indicators/stay-awake"
|
||||
for modes in '755 644' '750 640' '700 600'; do
|
||||
read -r directory_mode file_mode <<<"$modes"
|
||||
for idle_choice in update user; do
|
||||
prepare_state_dir
|
||||
chmod "$directory_mode" "$state_dir"
|
||||
sleep infinity &
|
||||
legacy_pid=$!
|
||||
test_processes+=("$legacy_pid")
|
||||
legacy_start=$(awk '{ print $22 }' "/proc/$legacy_pid/stat")
|
||||
printf '%s %s\n' "$legacy_pid" "$legacy_start" >"$state_dir/inhibit-pid"
|
||||
printf '123:456:789\n' >"$state_dir/idle-owner"
|
||||
chmod "$file_mode" "$state_dir/"{inhibit-pid,idle-owner}
|
||||
mkdir -p "${idle_marker%/*}"
|
||||
if [[ $idle_choice == "update" ]]; then
|
||||
cp "$state_dir/idle-owner" "$idle_marker"
|
||||
else
|
||||
printf 'user-choice\n' >"$idle_marker"
|
||||
fi
|
||||
run_helper stop || fail "legacy $modes cleanup fails after helper replacement"
|
||||
wait_dead "$legacy_pid" || fail "legacy $modes inhibitor survives cleanup"
|
||||
[[ ! -e $state_dir ]] || fail "legacy $modes state survives cleanup"
|
||||
if [[ $idle_choice == "update" ]]; then
|
||||
[[ ! -e $idle_marker ]] || fail "legacy cleanup leaves update-owned Stay Awake enabled"
|
||||
else
|
||||
[[ $(<"$idle_marker") == "user-choice" ]] || fail "legacy cleanup changes the user's Stay Awake choice"
|
||||
rm -f "$idle_marker"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
for legacy_record in stale multiline nul; do
|
||||
prepare_state_dir
|
||||
chmod 755 "$state_dir"
|
||||
sleep infinity &
|
||||
legacy_pid=$!
|
||||
test_processes+=("$legacy_pid")
|
||||
legacy_start=$(awk '{ print $22 }' "/proc/$legacy_pid/stat")
|
||||
case "$legacy_record" in
|
||||
stale) printf '%s %s\n' "$legacy_pid" "$((legacy_start + 1))" ;;
|
||||
multiline) printf '%s %s\nextra\n' "$legacy_pid" "$legacy_start" ;;
|
||||
nul) printf '%s\0 %s\n' "$legacy_pid" "$legacy_start" ;;
|
||||
esac >"$state_dir/inhibit-pid"
|
||||
chmod 644 "$state_dir/inhibit-pid"
|
||||
if [[ $legacy_record == "stale" ]]; then
|
||||
run_helper stop
|
||||
elif run_helper stop 2>/dev/null; then
|
||||
fail "malformed legacy $legacy_record record was accepted"
|
||||
fi
|
||||
kill -0 "$legacy_pid" || fail "legacy $legacy_record record signaled an unrelated process"
|
||||
kill "$legacy_pid"
|
||||
wait "$legacy_pid" 2>/dev/null || true
|
||||
done
|
||||
pass "legacy upgrade cleanup preserves process identity and the user's idle choice"
|
||||
|
||||
prepare_state_dir
|
||||
printf 'not a record\n' >"$state_dir/inhibit-pid"
|
||||
chmod 600 "$state_dir/inhibit-pid"
|
||||
if run_helper stop 2>/dev/null; then
|
||||
fail "malformed inhibitor state is rejected"
|
||||
fi
|
||||
|
||||
token=11111111111111111111111111111111
|
||||
start_identity_process "$token"
|
||||
prepare_state_dir
|
||||
printf '1 %s %s %s %s\nextra\n' "$identity_pid" "$identity_start" "$identity_owner" "$token" >"$state_dir/inhibit-pid"
|
||||
chmod 600 "$state_dir/inhibit-pid"
|
||||
if run_helper stop 2>/dev/null; then
|
||||
fail "multiline inhibitor state is rejected"
|
||||
fi
|
||||
kill -0 "$identity_pid" 2>/dev/null || fail "multiline state cannot signal its target"
|
||||
|
||||
prepare_state_dir
|
||||
write_inhibit_state "1 $identity_pid $((identity_start + 1)) $identity_owner $token"
|
||||
run_helper stop
|
||||
kill -0 "$identity_pid" 2>/dev/null || fail "reused PID state cannot signal its target"
|
||||
|
||||
prepare_state_dir
|
||||
write_inhibit_state "1 $identity_pid $identity_start $identity_owner 22222222222222222222222222222222"
|
||||
run_helper stop
|
||||
kill -0 "$identity_pid" 2>/dev/null || fail "wrong process identity cannot signal its target"
|
||||
kill "$identity_pid"
|
||||
wait_dead "$identity_pid" || true
|
||||
pass "malformed, multiline, reused-PID, and wrong-identity records are harmless"
|
||||
|
||||
retry_flag="$test_tmp/allow-termination"
|
||||
token=44444444444444444444444444444444
|
||||
/usr/bin/bash -c '
|
||||
trap "" TERM
|
||||
while [[ ! -e $1 ]]; do sleep 0.05; done
|
||||
trap "exit 0" TERM
|
||||
while :; do sleep 0.05; done
|
||||
' omarchy-retry "$retry_flag" "--why=Omarchy update in progress [$token]" &
|
||||
retry_pid=$!
|
||||
test_processes+=("$retry_pid")
|
||||
retry_start=$(awk '{ print $22 }' "/proc/$retry_pid/stat")
|
||||
retry_owner=$(stat -Lc '%u' "/proc/$retry_pid")
|
||||
prepare_state_dir
|
||||
write_inhibit_state "1 $retry_pid $retry_start $retry_owner $token"
|
||||
if run_helper stop 2>/dev/null; then
|
||||
fail "failed termination reports success"
|
||||
fi
|
||||
[[ -s $state_dir/inhibit-pid ]] || fail "failed termination retains authenticated retry state"
|
||||
touch "$retry_flag"
|
||||
sleep 0.1
|
||||
run_helper stop
|
||||
wait_dead "$retry_pid" || fail "retained inhibitor state permits a successful retry"
|
||||
pass "failed termination retains its authenticated retry handle"
|
||||
|
||||
for unsafe_kind in symlink permissive hardlink legacy-symlink legacy-permissive legacy-hardlink; do
|
||||
token=33333333333333333333333333333333
|
||||
start_identity_process "$token"
|
||||
prepare_state_dir
|
||||
record="1 $identity_pid $identity_start $identity_owner $token"
|
||||
[[ $unsafe_kind != legacy-* ]] || record="$identity_pid $identity_start"
|
||||
case "${unsafe_kind#legacy-}" in
|
||||
symlink)
|
||||
printf '%s\n' "$record" >"$test_tmp/state-victim"
|
||||
chmod 600 "$test_tmp/state-victim"
|
||||
ln -s "$test_tmp/state-victim" "$state_dir/inhibit-pid"
|
||||
;;
|
||||
permissive)
|
||||
write_inhibit_state "$record"
|
||||
if [[ $unsafe_kind == "legacy-permissive" ]]; then
|
||||
chmod 666 "$state_dir/inhibit-pid"
|
||||
else
|
||||
chmod 644 "$state_dir/inhibit-pid"
|
||||
fi
|
||||
;;
|
||||
hardlink)
|
||||
write_inhibit_state "$record"
|
||||
ln "$state_dir/inhibit-pid" "$state_hardlink"
|
||||
;;
|
||||
esac
|
||||
if run_helper stop 2>/dev/null; then
|
||||
fail "$unsafe_kind inhibitor state is rejected"
|
||||
fi
|
||||
kill -0 "$identity_pid" 2>/dev/null || fail "$unsafe_kind state cannot signal its target"
|
||||
kill "$identity_pid"
|
||||
wait_dead "$identity_pid" || true
|
||||
rm -f "$test_tmp/state-victim" "$state_hardlink"
|
||||
done
|
||||
pass "symlink, permissive, and multiply-linked records are harmless"
|
||||
|
||||
: >"$inhibitor_log"
|
||||
run_helper start
|
||||
first_pid=$(tail -n 1 "$inhibitor_log")
|
||||
run_helper start
|
||||
second_pid=$(tail -n 1 "$inhibitor_log")
|
||||
[[ $first_pid != "$second_pid" ]] || fail "repeated start replaces the inhibitor"
|
||||
wait_dead "$first_pid" || fail "repeated start stops the prior inhibitor"
|
||||
run_helper stop
|
||||
run_helper stop
|
||||
wait_dead "$second_pid" || fail "repeated stop remains idempotent"
|
||||
pass "repeated start and stop preserve one inhibitor"
|
||||
|
||||
: >"$inhibitor_log"
|
||||
concurrent_jobs=()
|
||||
for _ in {1..4}; do
|
||||
(run_helper start; run_helper stop) &
|
||||
concurrent_jobs+=("$!")
|
||||
done
|
||||
for job in "${concurrent_jobs[@]}"; do
|
||||
wait "$job" || fail "concurrent start and stop are serialized"
|
||||
done
|
||||
run_helper stop
|
||||
while read -r pid; do
|
||||
[[ -n $pid ]] || continue
|
||||
wait_dead "$pid" || fail "concurrent operation leaves no inhibitor behind"
|
||||
done <"$inhibitor_log"
|
||||
pass "concurrent state operations are serialized"
|
||||
|
||||
if SYSTEMD_FAIL=1 run_helper start; then
|
||||
fail "failed systemd-inhibit launch reports success"
|
||||
fi
|
||||
[[ ! -e $state_dir/inhibit-pid ]] || fail "failed inhibitor launch publishes no PID state"
|
||||
run_helper stop
|
||||
pass "failed inhibitor launch leaves no stale process state"
|
||||
|
||||
: >"$inhibitor_log"
|
||||
rollback_victim="$test_tmp/rollback-victim"
|
||||
: >"$rollback_victim"
|
||||
if CREATE_BAD_IDLE="$rollback_victim" run_helper start 2>/dev/null; then
|
||||
fail "unsafe idle publication reports success"
|
||||
fi
|
||||
rollback_pid=$(tail -n 1 "$inhibitor_log")
|
||||
wait_dead "$rollback_pid" || fail "post-publication failure rolls the inhibitor back"
|
||||
[[ ! -e $state_dir ]] || fail "rollback removes published inhibitor and idle ownership state"
|
||||
pass "state publication failures roll back a launched inhibitor"
|
||||
|
||||
# Hold each cancellation window open, including publication before child exec,
|
||||
# readiness before idle setup, and publication of the update-owned idle marker.
|
||||
cp "$mapped_helper" "$test_tmp/helper-before-pause"
|
||||
idle_marker="$test_home/.local/state/omarchy/indicators/stay-awake"
|
||||
for cancel_phase in published ready idle-temporary idle user-idle; do
|
||||
rm -f "$test_tmp/cancel-ready" "$test_tmp/release-child"
|
||||
if [[ $cancel_phase == "user-idle" ]]; then
|
||||
mkdir -p "${idle_marker%/*}"
|
||||
printf 'user-choice\n' >"$idle_marker"
|
||||
fi
|
||||
python3 - "$mapped_helper" "$cancel_phase" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
p = Path(sys.argv[1])
|
||||
s = p.read_text()
|
||||
pause = ': >"$TEST_CANCEL_READY"; while :; do /usr/bin/sleep 0.02; done'
|
||||
if sys.argv[2] == 'published':
|
||||
anchor = ' while :; do\n inhibit_record='
|
||||
edits = [(anchor, ' ' + pause + '\n' + anchor),
|
||||
(' exec -a "$expected"',
|
||||
' while [[ ! -e $TEST_RELEASE_CHILD ]]; do /usr/bin/sleep 0.02; done\n exec -a "$expected"')]
|
||||
elif sys.argv[2] == 'idle-temporary':
|
||||
anchor = ' temporary=$(mktemp "$state_dir/.${state_file##*/}.XXXXXXXX") || return 1'
|
||||
edits = [(anchor, anchor + '\n if [[ $state_file == "$idle_owner_file" ]]; then ' + pause + '; fi')]
|
||||
elif sys.argv[2] == 'idle':
|
||||
anchor = '''printf '%s\\n' "$idle_owner" >"$stay_awake_state"'''
|
||||
edits = [(anchor, '{ ' + anchor + ' && { ' + pause + '; }; }')]
|
||||
else:
|
||||
anchor = ' if [[ ! -f $stay_awake_state ]]; then'
|
||||
edits = [(anchor, ' ' + pause + '\n' + anchor)]
|
||||
for old, new in edits:
|
||||
assert s.count(old) == 1, old
|
||||
s = s.replace(old, new)
|
||||
p.write_text(s)
|
||||
PY
|
||||
(
|
||||
export HOME="$test_home" XDG_RUNTIME_DIR="$runtime_dir" OMARCHY_PATH="$mapped_root"
|
||||
export INHIBITOR_LOG="$inhibitor_log" OMARCHY_TEST_RUN_ID="$test_run_id"
|
||||
export TEST_CANCEL_READY="$test_tmp/cancel-ready" TEST_RELEASE_CHILD="$test_tmp/release-child"
|
||||
export PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin"
|
||||
exec "$mapped_helper" start
|
||||
) >"$test_tmp/$cancel_phase-cancel.log" 2>&1 &
|
||||
cancelled_launcher=$!
|
||||
test_processes+=("$cancelled_launcher")
|
||||
for _ in {1..100}; do
|
||||
[[ -e $test_tmp/cancel-ready && -s $state_dir/inhibit-pid ]] && break
|
||||
sleep 0.02
|
||||
done
|
||||
[[ -e $test_tmp/cancel-ready && -s $state_dir/inhibit-pid ]] || fail "$cancel_phase cancellation reached its barrier"
|
||||
read -r _ published_pid _ <"$state_dir/inhibit-pid"
|
||||
test_processes+=("$published_pid")
|
||||
kill -TERM "$cancelled_launcher"
|
||||
cancelled_status=0
|
||||
wait "$cancelled_launcher" || cancelled_status=$?
|
||||
(( cancelled_status == 143 )) || fail "$cancel_phase launch preserves cancellation status"
|
||||
touch "$test_tmp/release-child"
|
||||
wait_dead "$published_pid" || fail "$cancel_phase cancellation leaked the held child"
|
||||
[[ ! -e $state_dir ]] || fail "$cancel_phase cancellation left launch state"
|
||||
if [[ $cancel_phase == "user-idle" ]]; then
|
||||
[[ $(<"$idle_marker") == "user-choice" ]] || fail "cancellation changed the user's Stay Awake choice"
|
||||
rm -f "$idle_marker"
|
||||
else
|
||||
[[ ! -e $idle_marker ]] || fail "$cancel_phase cancellation left Stay Awake enabled"
|
||||
fi
|
||||
cp "$test_tmp/helper-before-pause" "$mapped_helper"
|
||||
done
|
||||
pass "cancellation through idle setup stops the child and restores only update-owned idle state"
|
||||
|
||||
namespace_args=()
|
||||
namespace_probe_error="$test_tmp/namespace-probe.err"
|
||||
if (( EUID == 0 )); then
|
||||
namespace_args=(
|
||||
unshare --user --mount --fork
|
||||
--map-users=0:0:1 --map-users=1000:1000:2
|
||||
--map-groups=0:0:1 --map-groups=1000:1000:2
|
||||
--setuid=0 --setgid=0
|
||||
)
|
||||
else
|
||||
subordinate_uid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid 2>/dev/null || true)
|
||||
subordinate_gid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid 2>/dev/null || true)
|
||||
if [[ $subordinate_uid =~ ^[0-9]+$ && $subordinate_gid =~ ^[0-9]+$ ]]; then
|
||||
namespace_args=(
|
||||
unshare --user --mount --fork
|
||||
"--map-users=0:$(id -u):1" "--map-users=1000:$subordinate_uid:2"
|
||||
"--map-groups=0:$(id -g):1" "--map-groups=1000:$subordinate_gid:2"
|
||||
--setuid=0 --setgid=0
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
namespace_capable=0
|
||||
if (( ${#namespace_args[@]} > 0 )) &&
|
||||
"${namespace_args[@]}" /usr/bin/bash -c '
|
||||
set -e
|
||||
mount -t tmpfs -o mode=1777 tmpfs /tmp
|
||||
setpriv --reuid=1000 --regid=1000 --clear-groups true
|
||||
setpriv --reuid=1001 --regid=1001 --clear-groups true
|
||||
' 2>"$namespace_probe_error"; then
|
||||
namespace_capable=1
|
||||
fi
|
||||
|
||||
if (( namespace_capable == 0 )); then
|
||||
skip "cross-UID fallback probe: two-UID mount namespace unavailable"
|
||||
else
|
||||
# Keep the protected entrypoint, shared helper and harmless command stubs
|
||||
# together, even when tmpfs hides a checkout or fixture under /tmp.
|
||||
tar -C "$test_tmp" -cf "$test_tmp/namespace-fixture.tar" bin omarchy
|
||||
if ! "${namespace_args[@]}" /usr/bin/bash -s -- "$test_tmp" "$test_run_id" \
|
||||
9<"$test_tmp/namespace-fixture.tar" <<'SH'
|
||||
set -euo pipefail
|
||||
fixture=$1
|
||||
run_id=$2
|
||||
mount -t tmpfs -o mode=1777 tmpfs /tmp
|
||||
mkdir -m 755 "$fixture"
|
||||
tar -C "$fixture" -xf /proc/self/fd/9
|
||||
exec 9<&-
|
||||
mkdir -m 700 /tmp/victim-home
|
||||
chown 1000:1000 /tmp/victim-home
|
||||
|
||||
setpriv --reuid=1000 --regid=1000 --clear-groups sleep 30 &
|
||||
victim_pid=$!
|
||||
trap 'kill "$victim_pid" "${legacy_sudo_pid:-$victim_pid}" 2>/dev/null || true; wait "$victim_pid" 2>/dev/null || true' EXIT
|
||||
victim_start=$(awk '{ print $22 }' "/proc/$victim_pid/stat")
|
||||
state=/tmp/omarchy-1000/omarchy-update-stay-awake-$run_id
|
||||
|
||||
run_fallback() {
|
||||
local uid=$1 home=$2
|
||||
shift 2
|
||||
setpriv --reuid="$uid" --regid="$uid" --clear-groups /usr/bin/env -i \
|
||||
HOME="$home" OMARCHY_PATH="$fixture/omarchy" OMARCHY_TEST_RUN_ID="$run_id" \
|
||||
INHIBITOR_LOG="$home/inhibitors" PATH="$fixture/bin:/usr/bin:/bin" \
|
||||
"$fixture/omarchy/bin/omarchy-update-stay-awake" "$@"
|
||||
}
|
||||
|
||||
setpriv --reuid=1001 --regid=1001 --clear-groups /usr/bin/bash -c '
|
||||
mkdir -m 755 /tmp/omarchy-1000 "$3"
|
||||
printf "%s %s\n" "$1" "$2" >"$3/inhibit-pid"
|
||||
chmod 644 "$3/inhibit-pid"
|
||||
' attacker "$victim_pid" "$victim_start" "$state"
|
||||
|
||||
if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then
|
||||
echo "foreign fallback state was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'unsafe Omarchy update inhibitor state path' /tmp/refusal
|
||||
kill -0 "$victim_pid"
|
||||
|
||||
rm -rf /tmp/omarchy-1000
|
||||
# The old helper also left a readable fallback parent after a successful stop.
|
||||
setpriv --reuid=1000 --regid=1000 --clear-groups mkdir -m 755 /tmp/omarchy-1000
|
||||
run_fallback 1000 /tmp/victim-home start
|
||||
[[ $(stat -Lc '%u %a' /tmp/omarchy-1000) == "1000 700" ]]
|
||||
run_fallback 1000 /tmp/victim-home stop
|
||||
[[ ! -e $state ]]
|
||||
|
||||
mkdir -m 700 "$state"
|
||||
chown 1000:1000 "$state"
|
||||
printf '1 %s %s 1000 %032d\n' "$victim_pid" "$victim_start" 0 \
|
||||
>"$state/inhibit-pid"
|
||||
chown 1001:1001 "$state/inhibit-pid"
|
||||
chmod 600 "$state/inhibit-pid"
|
||||
if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then
|
||||
echo "foreign state file was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'unsafe Omarchy update sleep inhibitor state' /tmp/refusal
|
||||
kill -0 "$victim_pid"
|
||||
|
||||
# A legacy-looking record owned by another account is still untrusted.
|
||||
mkdir -m 700 "$state"
|
||||
chown 1000:1000 "$state"
|
||||
printf '%s %s\n' "$victim_pid" "$victim_start" >"$state/inhibit-pid"
|
||||
chown 1001:1001 "$state/inhibit-pid"
|
||||
chmod 644 "$state/inhibit-pid"
|
||||
if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then
|
||||
echo "foreign legacy state file was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
kill -0 "$victim_pid"
|
||||
|
||||
# The old terminal helper recorded sudo, with the caller's real UID but an
|
||||
# effective root UID. Its /proc owner is root; the caller can still signal it.
|
||||
setpriv --ruid=1000 --euid=0 --regid=1000 --clear-groups sleep 30 &
|
||||
legacy_sudo_pid=$!
|
||||
for _ in {1..50}; do
|
||||
[[ $(awk '/^Uid:/ { print $2, $3 }' "/proc/$legacy_sudo_pid/status") == "1000 0" ]] && break
|
||||
sleep .02
|
||||
done
|
||||
[[ $(awk '/^Uid:/ { print $2, $3 }' "/proc/$legacy_sudo_pid/status") == "1000 0" ]]
|
||||
mkdir -m 755 "$state"
|
||||
chown 1000:1000 "$state"
|
||||
printf '%s %s\n' "$legacy_sudo_pid" "$(awk '{ print $22 }' "/proc/$legacy_sudo_pid/stat")" >"$state/inhibit-pid"
|
||||
chown 1000:1000 "$state/inhibit-pid"
|
||||
chmod 644 "$state/inhibit-pid"
|
||||
run_fallback 1000 /tmp/victim-home stop
|
||||
[[ ! -e /proc/$legacy_sudo_pid || $(awk '{ print $3 }' "/proc/$legacy_sudo_pid/stat") == "Z" ]]
|
||||
wait "$legacy_sudo_pid" 2>/dev/null || true
|
||||
|
||||
# Root may read the record, but must not signal a live legacy target whose
|
||||
# real UID differs. Preserve the retry handle instead of treating it as dead.
|
||||
mkdir -m 700 /tmp/root-home
|
||||
root_state=/tmp/omarchy-0/omarchy-update-stay-awake-$run_id
|
||||
mkdir -p "$root_state"
|
||||
printf '%s %s\n' "$victim_pid" "$victim_start" >"$root_state/inhibit-pid"
|
||||
chmod 644 "$root_state/inhibit-pid"
|
||||
if run_fallback 0 /tmp/root-home stop 2>/tmp/refusal; then
|
||||
echo "legacy cleanup accepted another account's live process" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'retained its state for recovery' /tmp/refusal
|
||||
[[ -s $root_state/inhibit-pid ]]
|
||||
kill -0 "$victim_pid"
|
||||
rm "$root_state/inhibit-pid"
|
||||
|
||||
run_fallback 0 /tmp/root-home start
|
||||
[[ $(stat -Lc '%u %a' /tmp/omarchy-0) == "0 700" ]]
|
||||
run_fallback 0 /tmp/root-home stop
|
||||
[[ ! -e /tmp/omarchy-0/omarchy-update-stay-awake-$run_id ]]
|
||||
SH
|
||||
then
|
||||
fail "two-UID fallback probe failed after its capability check" "$(<"$namespace_probe_error")"
|
||||
fi
|
||||
pass "foreign UID fallback state cannot kill a victim and safe fallback works"
|
||||
fi
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
copy_boundary_file bin/omarchy-update
|
||||
|
||||
# Model the two exec boundaries without a host update log or a real lock.
|
||||
# Both child processes inherit the environment exactly as script/lock would.
|
||||
cat >"$SUDO_TEST_ROOT/bin/script" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'logged-reexec\n' >>"$SUDO_TEST_LOG"
|
||||
[[ $1 == "-qefc" ]] || exit 90
|
||||
exec /usr/bin/bash -p -c "$2"
|
||||
STUB
|
||||
rm "$SUDO_TEST_ROOT/bin/omarchy-update-lock"
|
||||
cat >"$SUDO_TEST_ROOT/bin/omarchy-update-lock" <<'STUB'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
held) [[ ${SUDO_TEST_LOCKED:-0} == "1" ]] ;;
|
||||
run)
|
||||
shift
|
||||
printf 'locked-reexec\n' >>"$SUDO_TEST_LOG"
|
||||
export SUDO_TEST_LOCKED=1
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
STUB
|
||||
mkdir "$boundary_tmp/user commands"
|
||||
cat >"$boundary_tmp/user commands/update-user-tool" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'user-tool:%s\n' "$1" >>"$SUDO_TEST_LOG"
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/script" "$SUDO_TEST_ROOT/bin/omarchy-update-lock" "$boundary_tmp/user commands/update-user-tool"
|
||||
|
||||
for step in omarchy-hook omarchy-update-mise; do
|
||||
rm "$SUDO_TEST_ROOT/bin/$step"
|
||||
cat >"$SUDO_TEST_ROOT/bin/$step" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
|
||||
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
|
||||
update-user-tool "${0##*/}"
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/$step"
|
||||
done
|
||||
|
||||
for entry in fresh logged locked; do
|
||||
reset_boundary
|
||||
unset OMARCHY_UPDATE_LOGGED OMARCHY_UPDATE_USER_PATH SUDO_TEST_LOCKED
|
||||
case "$entry" in
|
||||
logged) export OMARCHY_UPDATE_LOGGED=1 ;;
|
||||
locked) export OMARCHY_UPDATE_LOGGED=1 SUDO_TEST_LOCKED=1 ;;
|
||||
esac
|
||||
PATH="$boundary_tmp/user commands:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$boundary_tmp/output" 2>&1 ||
|
||||
fail "$entry update lost the original user PATH" "$(<"$boundary_tmp/output")"
|
||||
grep -q '^user-tool:omarchy-hook$' "$SUDO_TEST_LOG" || fail "$entry hook could not run a user-installed tool"
|
||||
grep -q '^user-tool:omarchy-update-mise$' "$SUDO_TEST_LOG" || fail "$entry mise could not run a user-installed tool"
|
||||
if [[ $entry == "fresh" ]]; then
|
||||
grep -q '^logged-reexec$' "$SUDO_TEST_LOG" || fail "fresh update did not exercise the logging exec"
|
||||
fi
|
||||
if [[ $entry != "locked" ]]; then
|
||||
grep -q '^locked-reexec$' "$SUDO_TEST_LOG" || fail "$entry update did not exercise the lock exec"
|
||||
fi
|
||||
assert_boundary_cold "$entry update"
|
||||
pass "$entry update preserves the original user PATH through logging and locking with no-update sudo first"
|
||||
done
|
||||
Reference in new issue
Block a user