Pinning /usr/bin ran the packaged helper even where the migration came from somewhere else. Under a dev link the migration is read from the checkout while /usr/bin still holds the last installed package, and a package predating __migrate prints its usage and fails the update. By name, the unprivileged call goes through PATH and the sudo call through secure_path, which is how other migrations reach their helpers and which lands on /usr/bin on an install. Co-Authored-By: Codex XHigh <noreply@openai.com>
219 lines
10 KiB
Bash
219 lines
10 KiB
Bash
#!/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.
|
|
# The migration names the helper rather than a path, so both of its calls reach
|
|
# the mapped copy through PATH, as they reach a dev checkout's through the link.
|
|
mkdir -p "$test_tmp/source/migrations"
|
|
cp "$ROOT/migrations/1788163635.sh" "$test_tmp/source/migrations/"
|
|
ln -s ../omarchy-sudo-passwordless "$test_tmp/bin/omarchy-sudo-passwordless"
|
|
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"
|