272 lines
10 KiB
Bash
272 lines
10 KiB
Bash
#!/usr/bin/env bats
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
# Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
# Robustness: the apt hold restore survives a closed stdout and repeated signals, locks and
|
|
# merge-on-save of the statefile, an inaccessible state directory.
|
|
|
|
# each @test runs in its own subshell: per-test exports are intended
|
|
# shellcheck disable=SC2030,SC2031
|
|
|
|
load test_helper
|
|
|
|
setup() { common_setup; }
|
|
|
|
# nobody_env: a world-reachable copy of the test environment under /tmp for running the applet as
|
|
# nobody (setpriv): $W/bin (tools), $W/fake (log), $W/tmp; sets W
|
|
nobody_env() {
|
|
((EUID == 0)) || skip "needs root to switch to an unprivileged user"
|
|
command -v setpriv >/dev/null 2>&1 || skip "setpriv not available"
|
|
W=$(mktemp -d /tmp/su-nobody.XXXXXX)
|
|
chmod 0755 "$W"
|
|
mkdir -p "$W/bin" "$W/fake" "$W/tmp" "$W/state"
|
|
chmod 0777 "$W/fake" "$W/tmp"
|
|
for t in "$SYSBIN"/*; do ln -s "$(readlink "$t")" "$W/bin/${t##*/}"; done
|
|
: >"$W/fake/log"
|
|
chmod 0666 "$W/fake/log"
|
|
}
|
|
# nobody CMD-ARGS...: run the applet as nobody (no flock in PATH: the mkdir lock fallback)
|
|
# shellcheck disable=SC2329 # invoked through `run`
|
|
nobody() {
|
|
rm -f "$W/bin/flock"
|
|
setpriv --reuid=65534 --regid=65534 --clear-groups -- env -u SKEPTICAL_UPDATER_EUID \
|
|
PATH="$FAKES/apt:$W/bin" FAKE_DIR="$W/fake" FAKE_LOG="$W/fake/log" TMPDIR="$W/tmp" \
|
|
SKEPTICAL_UPDATER_STATE_DIR="$W/state" "$SU_BIN" "$@"
|
|
}
|
|
|
|
@test "a closed stdout (the tee reading it was killed) does not stop the hold restore" {
|
|
FAKE_KILL_READER=1 run bash -c '
|
|
printf "2-\n\n" | env PATH="$1" "$2" --no-refresh |
|
|
{ echo "$BASHPID" >"$3/reader.pid"; while IFS= read -r _; do :; done; }
|
|
echo "applet status ${PIPESTATUS[1]}"' _ "$TEST_PATH" "$SU_BIN" "$FAKE_DIR"
|
|
assert_line 'applet status 141'
|
|
log_has 'apt-mark hold su-dummy-b'
|
|
log_has 'apt-mark unhold su-dummy-b'
|
|
run cat "$FAKE_DIR/holds"
|
|
assert_output ''
|
|
[[ ! -e $STATE/holds.added ]]
|
|
}
|
|
|
|
@test "a second signal while the exit handler restores the holds does not abort the restore" {
|
|
# 1st TERM during the upgrade starts the exit handler, a 2nd TERM arrives during its apt-mark unhold
|
|
FAKE_APT_UPGRADE_KILL=TERM FAKE_APT_MARK_UNHOLD_SIGNAL=TERM \
|
|
env PATH="$TEST_PATH" "$SU_BIN" --no-refresh <<<$'2-\n' >"$BATS_TEST_TMPDIR/out" 2>&1 &
|
|
pid=$!
|
|
echo "$pid" >"$FAKE_DIR/applet.pid"
|
|
rc=0
|
|
wait "$pid" || rc=$?
|
|
[[ $rc -eq 143 ]] || fail "exit status $rc: $(cat "$BATS_TEST_TMPDIR/out")"
|
|
log_has 'apt-mark unhold su-dummy-b'
|
|
run cat "$FAKE_DIR/holds"
|
|
assert_output ''
|
|
[[ ! -e $STATE/holds.added ]] || fail "the journal was left behind: $(cat "$STATE/holds.added")"
|
|
}
|
|
|
|
@test "the package manager does not inherit our lock descriptors" {
|
|
[[ -d /proc/self/fd ]] || skip "no /proc"
|
|
run skup --no-refresh <<<$'2-\n'
|
|
assert_success
|
|
log_has 'lock fds in the upgrade: 0'
|
|
}
|
|
|
|
@test "statefile writes merge: a row another process added meanwhile is kept" {
|
|
write_state "$HEADER" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
|
|
# while this session computes the closure of su-dummy-a, another process trusts zsh
|
|
FAKE_STATE_APPEND="apt${T}zsh${T}user${T}2026-02-02T00:00:00Z" run skup --no-refresh <<<$'1+\nq'
|
|
assert_success
|
|
run cut -f2,3 "$STATE_FILE"
|
|
assert_line "su-dummy-a${T}user"
|
|
assert_line "su-dummy-b${T}dep:su-dummy-a"
|
|
assert_line "su-dummy-c${T}user"
|
|
assert_line "zsh${T}user"
|
|
}
|
|
|
|
@test "statefile writes merge: a row another process removed meanwhile stays removed" {
|
|
write_state "$HEADER" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
|
|
FAKE_STATE_SED='/su-dummy-c/d' run skup trust su-dummy-a
|
|
assert_success
|
|
run cut -f2 "$STATE_FILE"
|
|
refute_line su-dummy-c
|
|
assert_line su-dummy-a
|
|
}
|
|
|
|
@test "statefile writes merge: a removal applies only to the row as this session saw it" {
|
|
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
|
|
"apt${T}su-dummy-b${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z" \
|
|
"apt${T}stale${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z"
|
|
# refresh drops "stale" (no longer in the closure); meanwhile another process made it a user row
|
|
FAKE_STATE_SED='s/^apt\tstale\tdep:su-dummy-a/apt\tstale\tuser/' run skup refresh
|
|
assert_success
|
|
assert_output 'refreshed the dependency closures of 1 trusted root: 0 added, 1 removed'
|
|
run cut -f2,3 "$STATE_FILE"
|
|
assert_line "stale${T}user"
|
|
assert_line "su-dummy-b${T}dep:su-dummy-a"
|
|
}
|
|
|
|
@test "the statefile lock is flock(1) on trusted.lock, released after the write" {
|
|
run skup trust su-dummy-c
|
|
assert_success
|
|
[[ -f $STATE/trusted.lock ]]
|
|
flock -n "$STATE/trusted.lock" true
|
|
}
|
|
|
|
@test "without flock(1) a mkdir lock is used and removed again" {
|
|
rm -f "$SYSBIN/flock"
|
|
run skup trust su-dummy-c
|
|
assert_success
|
|
[[ ! -e $STATE/trusted.lock ]]
|
|
[[ ! -e $STATE/trusted.lock.d ]]
|
|
run cut -f2 "$STATE_FILE"
|
|
assert_line su-dummy-c
|
|
}
|
|
|
|
@test "mkdir lock: a stale lock (dead owner) is taken over, a live one is respected" {
|
|
rm -f "$SYSBIN/flock"
|
|
mkdir -p "$STATE/holds.lock.d"
|
|
echo su-dummy-b >"$STATE/holds.added"
|
|
echo su-dummy-b >"$FAKE_DIR/holds"
|
|
# live owner: this test's own shell
|
|
echo "$$" >"$STATE/holds.lock.d/pid"
|
|
run skup list --no-refresh
|
|
assert_success
|
|
assert_output --partial 'another skeptical-updater run is using'
|
|
[[ -e $STATE/holds.added ]]
|
|
# dead owner
|
|
sleep 0 &
|
|
dead=$!
|
|
wait "$dead"
|
|
echo "$dead" >"$STATE/holds.lock.d/pid"
|
|
run skup list --no-refresh
|
|
assert_success
|
|
assert_line 'Released temporary holds left over by an interrupted run: su-dummy-b'
|
|
[[ ! -e $STATE/holds.added ]]
|
|
[[ ! -e $STATE/holds.lock.d ]]
|
|
}
|
|
|
|
@test "mkdir lock: an owner of another user (kill -0 fails with EPERM) counts as alive" {
|
|
nobody_env
|
|
chown 65534 "$W/state"
|
|
# the lock is held by this test's shell (root): nobody cannot signal it
|
|
mkdir "$W/state/trusted.lock.d"
|
|
echo "$$" >"$W/state/trusted.lock.d/pid"
|
|
chown -R 65534 "$W/state/trusted.lock.d"
|
|
SKEPTICAL_UPDATER_LOCK_WAIT=1 run nobody trust su-dummy-c
|
|
assert_failure 1
|
|
assert_output --partial "another skeptical-updater is writing $W/state/trusted.tsv"
|
|
[[ $(cat "$W/state/trusted.lock.d/pid") == "$$" ]]
|
|
[[ ! -e $W/state/trusted.tsv ]]
|
|
rm -rf "$W"
|
|
}
|
|
|
|
@test "mkdir lock: a stale lock nobody can delete is moved aside (taken over), without a busy loop" {
|
|
nobody_env
|
|
chown 65534 "$W/state"
|
|
sleep 0 &
|
|
dead=$!
|
|
wait "$dead"
|
|
# a dead owner, and the lock directory belongs to root: nobody cannot delete it, only rename it
|
|
mkdir "$W/state/trusted.lock.d"
|
|
echo "$dead" >"$W/state/trusted.lock.d/pid"
|
|
SECONDS=0
|
|
SKEPTICAL_UPDATER_LOCK_WAIT=2 run timeout 30 bash -c "$(declare -f nobody); W=$W FAKES=$FAKES SU_BIN=$SU_BIN; nobody trust su-dummy-c"
|
|
assert_success
|
|
assert_output 'trusted su-dummy-c'
|
|
((SECONDS < 20))
|
|
[[ ! -e $W/state/trusted.lock.d ]]
|
|
rm -rf "$W"
|
|
}
|
|
|
|
@test "mkdir lock: a live lock that never goes away ends in the timeout" {
|
|
rm -f "$SYSBIN/flock"
|
|
mkdir -p "$STATE/trusted.lock.d"
|
|
echo "$$" >"$STATE/trusted.lock.d/pid"
|
|
SECONDS=0
|
|
SKEPTICAL_UPDATER_LOCK_WAIT=2 run timeout 30 env PATH="$TEST_PATH" "$SU_BIN" trust su-dummy-c
|
|
assert_failure 1
|
|
assert_output --partial 'another skeptical-updater is writing'
|
|
((SECONDS < 20))
|
|
[[ $(cat "$STATE/trusted.lock.d/pid") == "$$" ]]
|
|
}
|
|
|
|
@test "mkdir lock: the lock directory appears with its pid, no temporary directory is left behind" {
|
|
rm -f "$SYSBIN/flock"
|
|
FAKE_STATE_APPEND='' run skup trust su-dummy-c
|
|
assert_success
|
|
run ls -A "$STATE"
|
|
assert_output 'trusted.tsv'
|
|
}
|
|
|
|
@test "mkdir lock: two takeovers of one stale lock - the late one finds the new live lock and leaves it" {
|
|
rm -f "$SYSBIN/flock"
|
|
mkdir -p "$STATE/holds.lock.d"
|
|
sleep 0 &
|
|
dead=$!
|
|
wait "$dead"
|
|
echo "$dead" >"$STATE/holds.lock.d/pid"
|
|
echo su-dummy-b >"$STATE/holds.added"
|
|
echo su-dummy-b >"$FAKE_DIR/holds"
|
|
# "C" sees the dead owner, then pauses before its takeover ...
|
|
SKEPTICAL_UPDATER_LOCK_WAIT=2 SKEPTICAL_UPDATER_LOCK_TAKEOVER_PAUSE=2 env PATH="$TEST_PATH" "$SU_BIN" --no-refresh <<<$'2-\n' >"$BATS_TEST_TMPDIR/c.out" 2>&1 &
|
|
c=$!
|
|
sleep 1
|
|
# ... meanwhile "B" took the stale lock over: the lock now belongs to a live process (this shell)
|
|
rm -rf "$STATE/holds.lock.d"
|
|
mkdir "$STATE/holds.lock.d"
|
|
echo "$$" >"$STATE/holds.lock.d/pid"
|
|
rc=0
|
|
wait "$c" || rc=$?
|
|
# C must not have removed B's live lock (it does not get it, and neither do its holds)
|
|
[[ $(cat "$STATE/holds.lock.d/pid") == "$$" ]] || fail "the live lock was taken: $(cat "$BATS_TEST_TMPDIR/c.out")"
|
|
run ls -A "$STATE"
|
|
refute_output --partial 'stale'
|
|
rm -rf "$STATE/holds.lock.d"
|
|
}
|
|
|
|
@test "mkdir lock: temporary and renamed lock directories of crashed acquirers are removed" {
|
|
rm -f "$SYSBIN/flock"
|
|
mkdir -p "$STATE/.trusted.lock.Crash1" "$STATE/.trusted.lock.Crash2" "$STATE/trusted.lock.d.stale.999999" "$STATE/.trusted.lock.Live01"
|
|
sleep 0 &
|
|
dead=$!
|
|
wait "$dead"
|
|
echo "$dead" >"$STATE/.trusted.lock.Crash1/pid"
|
|
touch -d '2 hours ago' "$STATE/.trusted.lock.Crash2"
|
|
echo "$dead" >"$STATE/trusted.lock.d.stale.999999/pid"
|
|
echo "$$" >"$STATE/.trusted.lock.Live01/pid"
|
|
run skup trust su-dummy-c
|
|
assert_success
|
|
run ls -A "$STATE"
|
|
assert_output "$(printf '%s\n' .trusted.lock.Live01 trusted.tsv)"
|
|
}
|
|
|
|
@test "a state directory that cannot be entered is an error, not 'no trusted packages'" {
|
|
((EUID == 0)) || skip "needs root to switch to an unprivileged user"
|
|
command -v setpriv >/dev/null 2>&1 || skip "setpriv not available"
|
|
w=$(mktemp -d /tmp/su-nobody.XXXXXX)
|
|
chmod 0755 "$w"
|
|
mkdir -p "$w/bin" "$w/fake" "$w/tmp" "$w/state"
|
|
chmod 0777 "$w/fake" "$w/tmp"
|
|
for t in "$SYSBIN"/*; do ln -s "$(readlink "$t")" "$w/bin/${t##*/}"; done
|
|
: >"$w/fake/log"
|
|
chmod 0666 "$w/fake/log"
|
|
printf '%s\n' "$HEADER" >"$w/state/trusted.tsv"
|
|
chmod 0000 "$w/state"
|
|
nobody() {
|
|
setpriv --reuid=65534 --regid=65534 --clear-groups -- env -u SKEPTICAL_UPDATER_EUID \
|
|
PATH="$FAKES/apt:$w/bin" FAKE_DIR="$w/fake" FAKE_LOG="$w/fake/log" TMPDIR="$w/tmp" \
|
|
SKEPTICAL_UPDATER_STATE_DIR="$w/state" "$SU_BIN" "$@"
|
|
}
|
|
run nobody trusted
|
|
assert_failure 1
|
|
assert_output --partial "cannot access the state directory $w/state (permission denied)"
|
|
run nobody list --no-refresh
|
|
assert_failure 1
|
|
assert_output --partial "cannot access the state directory $w/state (permission denied)"
|
|
# a readable directory with an unreadable statefile
|
|
chmod 0755 "$w/state"
|
|
chmod 0600 "$w/state/trusted.tsv"
|
|
run nobody trusted
|
|
assert_failure 1
|
|
assert_output --partial "cannot read the statefile $w/state/trusted.tsv (permission denied)"
|
|
rm -rf "$w"
|
|
}
|