#!/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 /discovery/rpm:/d:ro 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 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/clo_d.txt echo " closure(su-dummy-d): $(wc -l /dev/null | sed '/^$/d' | sort -u > /tmp/clo_native.txt eq "closure(d) == dnf4 repoquery --requires --resolve --recursive" "$(tr '\n' ' ' /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"