Files
skeptical-updater/tests/unit/robustness.bats
T
2026-10-10 10:37:53 +02:00

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"
}