Files
skeptical-updater/discovery/rpm/proof.sh
T
2026-10-10 10:37:53 +02:00

176 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# proof.sh - runs lib-rpm.sh against the fixture and asserts the behaviour documented in rpm.md.
# docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/proof.sh
# Prints PASS/FAIL lines + sample output; exit code = number of failures.
export LC_ALL=C
D=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
now() { printf '%s' "${EPOCHREALTIME/./}"; } # microseconds
T_START=$(now)
FAILS=0
pass() { printf 'PASS %s\n' "$1"; }
fail() { printf 'FAIL %s\n' "$1"; FAILS=$((FAILS+1)); }
check() { local name=$1; shift; if "$@" >/dev/null 2>&1; then pass "$name"; else fail "$name"; fi; }
eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1: got [$2] want [$3]"; fi; }
exec 3>&1 # fd 3 = real stdout, so tm's timing is not swallowed by redirections
tm() { local t0=$(now); "$@"; local rc=$?; printf ' (took %d ms)\n' $(( ($(now)-t0)/1000 )) >&3; return $rc; }
sec() { printf '\n== %s\n' "$*"; }
sec "fixture"
t0=$(now); SU_FIXTURE_EXTRAS=1 bash "$D/fixture.sh" 2>&1 | sed 's/^/ /' | tail -4; [ "${PIPESTATUS[0]}" -eq 0 ] || { echo "FIXTURE FAILED"; exit 99; }
FIXTURE_MS=$(( ($(now)-t0)/1000 ))
. "$D/lib-rpm.sh"
. /etc/os-release
BACKEND=$(su_backend)
echo "image=$PRETTY_NAME ID=$ID backend=$BACKEND dnf_major=$([ $BACKEND = dnf ] && su_dnf_major || echo -) zypper_verb=$([ $BACKEND = zypper ] && su_zypper_verb || echo -)"
sec "1. refresh"
t0=$(now); su_refresh; rc=$?; REFRESH_MS=$(( ($(now)-t0)/1000 )); eq "su_refresh exit code" "$rc" 0; echo " (took $REFRESH_MS ms)"
sec "2. upgradable list (name old new repo arch)"
t0=$(now); su_upgradable > /tmp/up.tsv; rc=$?; LIST_MS=$(( ($(now)-t0)/1000 )); eq "su_upgradable exit code" "$rc" 0
echo " (took $LIST_MS ms; $(wc -l </tmp/up.tsv) rows) first 5 rows:"; head -5 /tmp/up.tsv | cat -A | sed 's/^/ /'
for p in a b c; do
arch=noarch; [ $p = c ] && arch=$(rpm -q --qf '%{ARCH}' su-dummy-c)
grep -q -x -F "su-dummy-$p"$'\t'"1.0-1"$'\t'"2.0-1"$'\t'"su-dummy"$'\t'"$arch" /tmp/up.tsv && pass "row su-dummy-$p 1.0-1 -> 2.0-1 [su-dummy] $arch" || fail "row su-dummy-$p missing"
done
check "no su-dummy-d (not upgradable)" test "$(grep -c '^su-dummy-d' /tmp/up.tsv)" -eq 0
check "no '.arch' suffix in name column" test "$(cut -f1 /tmp/up.tsv | grep -c '\.\(noarch\|x86_64\|aarch64\)$')" -eq 0
sec "3. summary + description (candidate if upgradable, else installed)"
su_describe su-dummy-a > /tmp/d_a.txt; echo "--- su-dummy-a (candidate):"; sed 's/^/ /' /tmp/d_a.txt
eq "candidate summary" "$(head -n1 /tmp/d_a.txt)" "skeptical-updater dummy su-dummy-a (v2.0)"
check "candidate description has 2nd paragraph" grep -q 'Second paragraph of su-dummy-a 2.0' /tmp/d_a.txt
check "paragraph break preserved" test "$(sed -n 3p /tmp/d_a.txt)" = ""
su_describe su-dummy-d > /tmp/d_d.txt; echo "--- su-dummy-d (installed only):"; sed 's/^/ /' /tmp/d_d.txt
eq "installed-only summary" "$(head -n1 /tmp/d_d.txt)" "skeptical-updater dummy su-dummy-d (v1.0)"
rpm -qi su-dummy-a | sed -n '/^Summary/p' | sed 's/^/ rpm -qi: /'
sec "4. installed reverse dependencies"
eq "rdeps su-dummy-b" "$(su_rdeps su-dummy-b | tr '\n' ' ')" "su-dummy-a su-dummy-d "
eq "rdeps su-dummy-a" "$(su_rdeps su-dummy-a | tr '\n' ' ')" "su-dummy-d "
eq "rdeps su-dummy-c (only via rich dep (c if b))" "$(su_rdeps su-dummy-c | tr '\n' ' ')" "su-dummy-d "
eq "rdeps su-dummy-d (leaf)" "$(su_rdeps su-dummy-d | tr '\n' ' ')" ""
n_mine=$(su_rdeps bash | wc -l); n_naive=$(rpm -q --qf '%{NAME}\n' --whatrequires bash 2>/dev/null | sort -u | wc -l)
echo " bash: su_rdeps=$n_mine naive 'rpm -q --whatrequires bash'=$n_naive"
provider_sh=$(rpm -q --whatprovides --qf '%{NAME}\n' /bin/sh | head -n1)
check "su_rdeps($provider_sh) finds su-dummy-d via file dep /bin/sh" bash -c ". $D/lib-rpm.sh; su_rdeps $provider_sh | grep -qx su-dummy-d"
[ "$n_mine" -gt "$n_naive" ] && pass "su_rdeps(bash) > naive rpm --whatrequires" || fail "su_rdeps(bash) not > naive"
if [ "$BACKEND" = dnf ]; then
dnf -q repoquery --installed --whatrequires bash --queryformat $'%{name}\n' 2>/dev/null | sed '/^$/d' | sort -u > /tmp/native_rd.txt
su_rdeps bash > /tmp/mine_rd.txt; eq "su_rdeps(bash) == dnf repoquery --installed --whatrequires" "$(cat /tmp/mine_rd.txt | tr '\n' ' ')" "$(cat /tmp/native_rd.txt | tr '\n' ' ')"
else
zypper -q -n search --installed-only -t package --requires-pkg bash 2>/dev/null | grep -E '^[iv ][+ ]? *\|' | cut -d'|' -f2 | tr -d ' ' | grep -v -x Name | sort -u > /tmp/native_rd.txt
su_rdeps bash > /tmp/mine_rd.txt; su_rdeps_recursive bash > /tmp/mine_rrd.txt
echo " zypper se --requires-pkg bash : $(wc -l </tmp/native_rd.txt) pkgs"; echo " su_rdeps bash (direct) : $(wc -l </tmp/mine_rd.txt) pkgs: $(tr '\n' ' ' </tmp/mine_rd.txt)"
echo " su_rdeps_recursive bash : $(wc -l </tmp/mine_rrd.txt) pkgs"
echo " native minus recursive : $(LC_ALL=C comm -23 /tmp/native_rd.txt /tmp/mine_rrd.txt | tr '\n' ' ')"
fi
eq "su_rdeps_recursive(su-dummy-b)" "$(su_rdeps_recursive su-dummy-b | tr '\n' ' ')" "su-dummy-a su-dummy-d "
eq "su_rdeps_recursive(su-dummy-d) (leaf)" "$(su_rdeps_recursive su-dummy-d | tr '\n' ' ')" ""
sec "5. recursive installed dependency closure"
tm su_closure su-dummy-d > /tmp/clo_d.txt
echo " closure(su-dummy-d): $(wc -l </tmp/clo_d.txt) pkgs: $(head -c 300 /tmp/clo_d.txt | tr '\n' ' ')..."
for p in su-dummy-a su-dummy-b su-dummy-c glibc; do grep -qx "$p" /tmp/clo_d.txt && pass "closure(d) contains $p" || fail "closure(d) missing $p"; done
provider_sh=$(rpm -q --whatprovides --qf '%{NAME}\n' /bin/sh | head -n1); grep -qx "$provider_sh" /tmp/clo_d.txt && pass "closure(d) contains $provider_sh (provider of file dep /bin/sh)" || fail "closure(d) missing $provider_sh"
! grep -qx 'su-dummy-d' /tmp/clo_d.txt && pass "closure(d) excludes root" || fail "closure(d) contains root"
grep -q 'rpmlib' /tmp/clo_d.txt && fail "closure contains rpmlib" || pass "no rpmlib(...) leakage"
eq "closure(su-dummy-a)" "$(su_closure su-dummy-a | tr '\n' ' ')" "su-dummy-b "
if [ "$BACKEND" = dnf ] && [ "$(su_dnf_major)" = 4 ]; then
dnf -q repoquery --installed --requires --resolve --recursive --queryformat $'%{name}\n' su-dummy-d 2>/dev/null | sed '/^$/d' | sort -u > /tmp/clo_native.txt
eq "closure(d) == dnf4 repoquery --requires --resolve --recursive" "$(tr '\n' ' ' </tmp/clo_d.txt)" "$(tr '\n' ' ' </tmp/clo_native.txt)"
fi
if [ "$BACKEND" = dnf ] && [ "$(su_dnf_major)" = 5 ]; then
dnf -q repoquery --installed --requires --resolve --recursive su-dummy-d >/dev/null 2>&1; echo " dnf5 repoquery --requires --resolve --recursive -> rc=$? (unsupported)"
fi
sec "6. upgrade with per-run exclusion (a,b excluded) -- c must upgrade, a,b must stay"
if [ $BACKEND = dnf ]; then RESTRICT=(--disablerepo='*' --enablerepo=su-dummy); else RESTRICT=(); fi
LOCKS_BEFORE=$( [ -e /etc/zypp/locks ] && cksum < /etc/zypp/locks || echo absent )
tm su_upgrade --exclude su-dummy-a --exclude su-dummy-b -- "${RESTRICT[@]}" > /tmp/up_ex.log 2>&1; rc=$?
eq "exit code of excluded upgrade" "$rc" 0
eq "versions after excluded upgrade" "$(rpm -q --qf '%{NAME}=%{VERSION} ' su-dummy-a su-dummy-b su-dummy-c)" "su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0 "
if [ $BACKEND = zypper ]; then
LOCKS_AFTER=$( [ -e /etc/zypp/locks ] && cksum < /etc/zypp/locks || echo absent )
eq "/etc/zypp/locks untouched ($LOCKS_BEFORE)" "$LOCKS_AFTER" "$LOCKS_BEFORE"
check "no leftover temp zypp dirs" bash -c 'ls /tmp | grep -q "^tmp\." && exit 1 || exit 0'
fi
su_upgradable | cut -f1,2,3 | grep '^su-dummy' | sed 's/^/ still upgradable: /'
check "a and b still listed as upgradable" bash -c ". $D/lib-rpm.sh; su_upgradable | cut -f1 | grep -c '^su-dummy-[ab]$' | grep -qx 2"
echo "--- now without exclusions"
tm su_upgrade -- "${RESTRICT[@]}" > /tmp/up_all.log 2>&1; rc=$?; eq "exit code of plain upgrade" "$rc" 0
eq "versions after full upgrade" "$(rpm -q --qf '%{NAME}=%{VERSION} ' su-dummy-a su-dummy-b su-dummy-c)" "su-dummy-a=2.0 su-dummy-b=2.0 su-dummy-c=2.0 "
check "nothing left for su-dummy-*" test "$(su_upgradable | grep -c '^su-dummy')" -eq 0
sec "7. remove (+ unneeded deps)"
if [ $BACKEND = dnf ]; then dnf -y -q install su-dummy-r >/dev/null 2>&1; else zypper -n -q install su-dummy-r >/dev/null 2>&1; fi
eq "installed r and s" "$(rpm -q --qf '%{NAME} ' su-dummy-r su-dummy-s)" "su-dummy-r su-dummy-s "
su_remove su-dummy-r >/tmp/rm.log 2>&1; rc=$?; eq "remove exit code" "$rc" 0
eq "r AND its auto-installed dep s removed" "$(rpm -q su-dummy-r su-dummy-s 2>&1 | sed 's/ is not installed//;s/^package //' | tr '\n' ' ')" "su-dummy-r su-dummy-s "
su_remove su-dummy-a >/tmp/rm2.log 2>&1
eq "rpm-installed dep b NOT removed with a (not marked auto-installed)" "$(rpm -q su-dummy-b)" "su-dummy-b-2.0-1.noarch"
su_remove no-such-pkg-xyz >/tmp/rm3.log 2>&1; echo " remove of unknown pkg -> exit $?"
sec "8. fixture re-run resets state"
SU_FIXTURE_EXTRAS=1 bash "$D/fixture.sh" >/dev/null 2>&1; su_upgradable > /tmp/up2.tsv
check "after re-run a,b,c upgradable again" test "$(grep -c '^su-dummy-[abc] 1.0-1 2.0-1' /tmp/up2.tsv)" -eq 3
sec "9. plan preview (display only) incl. an obsoleting package"
TOP=/var/tmp/su-fixture; REPO=/var/lib/su-dummy-repo
mkp() { # mkp name ver [extra spec header lines...] -> builds noarch rpm, echoes path
local n=$1 v=$2; shift 2; local f=$TOP/SPECS/$n-$v.spec
{ echo "Name: $n"; echo "Version: $v"; echo "Release: 1"; echo "Summary: dummy $n"; echo "License: MIT"; echo "BuildArch: noarch"
local l; for l in "$@"; do echo "$l"; done
echo '%description'; echo "d"; echo '%install'; echo 'mkdir -p %{buildroot}/usr/share/su-dummy'; echo "echo $n > %{buildroot}/usr/share/su-dummy/$n"
echo '%files'; echo "/usr/share/su-dummy/$n"; } > "$f"
rpmbuild -bb --quiet --define "_topdir $TOP" "$f" >/dev/null 2>&1; ls "$TOP"/RPMS/noarch/$n-$v-1.*.rpm
}
rpm -Uvh "$(mkp su-dummy-old 1.0)" >/dev/null
cp "$(mkp su-dummy-new 1.0 'Obsoletes: su-dummy-old < 2' 'Provides: su-dummy-old = 2')" "$REPO"/ && createrepo_c --quiet --update "$REPO" >/dev/null
su_refresh
su_plan --exclude su-dummy-a > /tmp/plan.tsv; rc=$?; eq "su_plan exit code" "$rc" 0
echo " su_plan --exclude su-dummy-a (section name old new repo arch replaces), su-dummy rows only:"; grep -E $'\tsu-dummy-' /tmp/plan.tsv | sed 's/^/ /'
check "plan: b upgrade 1.0-1 -> 2.0-1" grep -q -x -F $'upgrade\tsu-dummy-b\t1.0-1\t2.0-1\tsu-dummy\tnoarch\t-' /tmp/plan.tsv
check "plan: excluded su-dummy-a is absent" test "$(grep -c $'\tsu-dummy-a\t' /tmp/plan.tsv)" -eq 0
check "plan: obsoleting install of su-dummy-new is visible" grep -q $'^install\tsu-dummy-new\t' /tmp/plan.tsv
echo " (upgradable list does NOT show the obsoleter:) $(su_upgradable | cut -f1 | grep -c '^su-dummy-new$') row(s) for su-dummy-new"
rpm -e su-dummy-old >/dev/null 2>&1
if [ $BACKEND = zypper ]; then
sec "10. zypper lock save/restore recipe (su_zypper_excluding_snapshot)"
zypper -n -q addlock -m "user lock" su-dummy-d >/dev/null
sum() { [ -e /etc/zypp/locks ] && { cksum < /etc/zypp/locks; stat -c %Y /etc/zypp/locks; } | tr '\n' ' ' || echo absent; }
B=$(sum); echo " before: $B"
su_zypper_excluding_snapshot su-dummy-a su-dummy-b -- "$(su_zypper_verb)" --dry-run > /tmp/snap.log 2>&1; rc=$?
eq "snapshot recipe exit code (dry-run)" "$rc" 0
eq "locks file byte-identical + same mtime after normal run" "$(sum)" "$B"
check "user lock still present afterwards" grep -q 'solvable_name: su-dummy-d' /etc/zypp/locks
check "temporary locks are gone" test "$(grep -c 'solvable_name: su-dummy-[ab]$' /etc/zypp/locks)" -eq 0
echo "--- why not addlock + removelock: a pre-existing user lock on the same name is destroyed"
zypper -n -q addlock su-dummy-a >/dev/null # the 'user' lock on su-dummy-a
zypper -n -q removelock su-dummy-a >/dev/null
check "NAIVE removelock destroyed the user's own lock on su-dummy-a (why we snapshot)" test "$(grep -c 'solvable_name: su-dummy-a$' /etc/zypp/locks)" -eq 0
zypper -n -q addlock su-dummy-a >/dev/null; B3=$(sum)
su_zypper_excluding_snapshot su-dummy-a -- "$(su_zypper_verb)" --dry-run >/dev/null 2>&1
eq "snapshot recipe keeps pre-existing lock on su-dummy-a" "$(sum)" "$B3"
echo "--- file absent before => absent after (removelock would leave a 0-byte file)"
rm -f /etc/zypp/locks
su_zypper_excluding_snapshot su-dummy-a -- "$(su_zypper_verb)" --dry-run >/dev/null 2>&1
eq "locks file absent after" "$(sum)" absent
echo "--- SIGTERM in the middle of the run => trap restores"
zypper -n -q addlock -m "user lock" su-dummy-d >/dev/null; B4=$(sum)
zypper() { if [ "$3" = "$(su_zypper_verb)" ] || [ "$2" = "$(su_zypper_verb)" ]; then kill -TERM $BASHPID; sleep 5; else command zypper "$@"; fi; }
su_zypper_excluding_snapshot su-dummy-a su-dummy-b -- "$(su_zypper_verb)" > /dev/null 2>&1; rc=$?
unset -f zypper
eq "killed run exit code (143 = 128+SIGTERM)" "$rc" 143
eq "locks restored after SIGTERM" "$(sum)" "$B4"
fi
TOTAL_MS=$(( ($(now)-T_START)/1000 ))
printf '\n== timings: fixture=%d ms refresh=%d ms list=%d ms total=%d ms\n' "$FIXTURE_MS" "$REFRESH_MS" "$LIST_MS" "$TOTAL_MS"
printf '== RESULT: %d failure(s)\n' "$FAILS"
exit "$FAILS"