#!/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" }