first commit

This commit is contained in:
bryanpedini committed 2026-10-10 10:37:53 +02:00
commit 1354db6c39
90 files changed
+13123

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fixture.sh - deterministic E2E fixture for RPM distros (dnf: Alma/Rocky/..., zypper: openSUSE)
#
# Builds three dummy packages with rpmbuild, installs version 1.0 of each, publishes
# version 2.0 in a local file:// repo and registers that repo, so that the package
# manager reports exactly these upgrades:
#
# su-dummy-a 1.0-1 -> 2.0-1 (noarch, Requires: su-dummy-b)
# su-dummy-b 1.0-1 -> 2.0-1 (noarch)
# su-dummy-c 1.0-1 -> 2.0-1 (ARCH-SPECIFIC, so the ".x86_64" suffix path is exercised)
#
# Optional (SU_FIXTURE_EXTRAS=1):
# - installs su-dummy-d 1.0 (NOT upgradable): Requires su-dummy-a, /bin/sh (file dep),
# "su-dummy-b >= 1.0" (versioned), "(su-dummy-c if su-dummy-b)" (rich dep) and rpmlib(...)
# -> for reverse-dep / dependency-closure tests;
# - publishes su-dummy-r 1.0 (Requires su-dummy-s) and su-dummy-s 1.0 in the repo, NOT installed
# -> for install/remove/--clean-deps tests.
#
# Re-runnable: re-running resets everything to the "1.0 installed, 2.0 available" state.
#
# Usage (inside the container, as root): bash fixture.sh
# docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh
set -euo pipefail
export LC_ALL=C
T0=$SECONDS
log() { printf '[fixture] %s\n' "$*" >&2; }
die() { printf '[fixture] ERROR: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "must run as root"
# ---- 1. backend detection ---------------------------------------------------
BACKEND=
if command -v zypper >/dev/null 2>&1; then BACKEND=zypper
elif command -v dnf >/dev/null 2>&1; then BACKEND=dnf
elif command -v yum >/dev/null 2>&1; then BACKEND=yum
else die "neither zypper nor dnf/yum found"; fi
log "backend=$BACKEND"
TOP=/var/tmp/su-fixture # rpmbuild topdir
REPO=/var/lib/su-dummy-repo # file:// repo (only 2.0 packages)
SPEC_DIR=$TOP/SPECS
# ---- 2. build tools ---------------------------------------------------------
t=$SECONDS
case $BACKEND in
zypper) zypper --non-interactive --quiet install --no-recommends rpm-build createrepo_c >/dev/null ;;
dnf|yum) "$BACKEND" -y -q install --setopt=install_weak_deps=False rpm-build createrepo_c >/dev/null ;;
esac
command -v rpmbuild >/dev/null || die "rpmbuild missing after install"
command -v createrepo_c >/dev/null || die "createrepo_c missing after install"
log "build tools installed in $((SECONDS - t))s"
# ---- 3. build RPMs ----------------------------------------------------------
rm -rf "$TOP" "$REPO"
mkdir -p "$TOP"/{BUILD,BUILDROOT,RPMS,SRPMS,SOURCES,SPECS} "$REPO"
# mkspec <name> <version> <arch|noarch> <extra spec header lines...>
mkspec() {
local name=$1 ver=$2 arch=$3; shift 3
local f=$SPEC_DIR/$name-$ver.spec
{
echo "Name: $name"
echo "Version: $ver"
echo "Release: 1"
echo "Summary: skeptical-updater dummy $name (v$ver)"
echo "License: MIT"
[ "$arch" = noarch ] && echo "BuildArch: noarch"
local l; for l in "$@"; do echo "$l"; done
echo "%global debug_package %{nil}"
echo "%description"
echo "Dummy package $name version $ver for skeptical-updater tests."
echo ""
echo "Second paragraph of $name $ver (blank line above on purpose)."
echo "%install"
echo "mkdir -p %{buildroot}/usr/share/su-dummy"
echo "echo '$name $ver' > %{buildroot}/usr/share/su-dummy/$name"
echo "%files"
echo "/usr/share/su-dummy/$name"
} > "$f"
printf '%s\n' "$f"
}
build() { rpmbuild -bb --quiet --define "_topdir $TOP" "$1" >/dev/null 2>"$TOP/rpmbuild.err" \
|| { cat "$TOP/rpmbuild.err" >&2; die "rpmbuild failed for $1"; }; }
t=$SECONDS
for v in 1.0 2.0; do
build "$(mkspec su-dummy-a $v noarch 'Requires: su-dummy-b')"
build "$(mkspec su-dummy-b $v noarch)"
build "$(mkspec su-dummy-c $v arch)"
done
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then
build "$(mkspec su-dummy-s 1.0 noarch)" # published in the repo, NOT installed
build "$(mkspec su-dummy-r 1.0 noarch 'Requires: su-dummy-s')" # (used to test remove/--clean-deps)
build "$(mkspec su-dummy-d 1.0 noarch \
'Requires: su-dummy-a' \
'Requires: /bin/sh' \
'Requires: su-dummy-b >= 1.0' \
'Requires: (su-dummy-c if su-dummy-b)' \
'Requires: rpmlib(CompressedFileNames)')"
fi
log "rpmbuild done in $((SECONDS - t))s"
# ---- 4. install 1.0 (rpm directly: distro-agnostic, resets to 1.0 on re-run) ----
shopt -s nullglob
v1=( "$TOP"/RPMS/*/su-dummy-[abc]-1.0-1.*.rpm )
[ "${#v1[@]}" -eq 3 ] || die "expected 3 built 1.0 rpms, got ${#v1[@]}"
rpm -Uvh --oldpackage --replacepkgs "${v1[@]}" >/dev/null
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then
rpm -Uvh --oldpackage --replacepkgs "$TOP"/RPMS/noarch/su-dummy-d-1.0-1.*.rpm >/dev/null
fi
# ---- 5. publish 2.0 as a local repo -------------------------------------------
v2=( "$TOP"/RPMS/*/su-dummy-[abc]-2.0-1.*.rpm )
[ "${#v2[@]}" -eq 3 ] || die "expected 3 built 2.0 rpms, got ${#v2[@]}"
cp "${v2[@]}" "$REPO"/
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then cp "$TOP"/RPMS/noarch/su-dummy-[rs]-1.0-1.*.rpm "$REPO"/; fi
createrepo_c --quiet "$REPO" >/dev/null
# ---- 6. register repo ---------------------------------------------------------
case $BACKEND in
zypper)
zypper --non-interactive --quiet removerepo su-dummy >/dev/null 2>&1 || true
zypper --non-interactive --quiet addrepo --no-gpgcheck --no-check --refresh "file://$REPO" su-dummy >/dev/null
t=$SECONDS
zypper --non-interactive --quiet refresh su-dummy >/dev/null
;;
dnf|yum)
cat > /etc/yum.repos.d/su-dummy.repo <<REPOEOF
[su-dummy]
name=skeptical-updater dummy packages
baseurl=file://$REPO
enabled=1
gpgcheck=0
repo_gpgcheck=0
metadata_expire=0
REPOEOF
t=$SECONDS
"$BACKEND" -q makecache --repo su-dummy >/dev/null
;;
esac
log "repo refresh in $((SECONDS - t))s"
# ---- 7. verify: the three must be listed as upgradable --------------------------
want="su-dummy-a su-dummy-b su-dummy-c"
case $BACKEND in
zypper) listing=$(zypper --non-interactive --quiet list-updates 2>&1 || true) ;;
dnf|yum) listing=$("$BACKEND" -q check-update 2>&1 || true) ;;
esac
bad=0
for p in $want; do
printf '%s\n' "$listing" | grep -Eq "(^|[| ])$p([. ]|$)" || { log "NOT listed as upgradable: $p"; bad=1; }
done
if [ "$bad" -ne 0 ]; then printf '%s\n' "$listing" >&2; die "fixture verification failed"; fi
log "OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total ${SECONDS}s, since start $((SECONDS - T0))s)"
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# lib-rpm.sh - proven building blocks for RPM distros: dnf4, dnf5 (yum alias) and zypper.
#
# SOURCE this file; nothing runs on source. Every function here was exercised by proof.sh on
# almalinux:9, almalinux:10, rockylinux:9, rockylinux/rockylinux:10, opensuse/tumbleweed,
# opensuse/leap (16.0) and (dnf5 reference) fedora:latest.
#
# Runtime needs only: bash >= 4.2, rpm, sed, grep, tr, sort (GNU, for -V), join, cut, mktemp.
# NOT assumed (missing on at least one target image): awk, find, xargs, python3, xmllint, sudo,
# hostname, diff, cmp, column.
#
# All upgradable-package lists use one record format (TAB separated):
# name <TAB> old-evr <TAB> new-evr <TAB> repo <TAB> arch
# (name is the PLAIN rpm name, arch is a separate 5th column; "old-evr" is "?" if unknown)
export LC_ALL=C
# ---------------------------------------------------------------- detection
su_backend() { # -> "zypper" | "dnf" (dnf covers dnf4, dnf5 and the yum alias on EL9+)
if command -v zypper >/dev/null 2>&1; then echo zypper
elif command -v dnf >/dev/null 2>&1; then echo dnf
else return 1; fi
}
su_os_id() { ( . /etc/os-release 2>/dev/null; printf '%s' "${ID:-}" ); }
su_dnf_major() { dnf --version 2>/dev/null | grep -q '^dnf5' && echo 5 || echo 4; }
# zypper verb for "upgrade everything": Tumbleweed (and Slowroll) are rolling -> dup; Leap/SLE -> update
su_zypper_verb() {
case "$(su_os_id)" in opensuse-tumbleweed|opensuse-slowroll) echo dup ;; *) echo update ;; esac
}
# ---------------------------------------------------------------- 1. refresh
# exit 0 = fresh metadata for ALL repos. dnf: 1 on any failure (we force skip_if_unavailable=0 because
# dnf5 otherwise silently ignores a dead repo and exits 0 - and `-q` hides even the warning).
# zypper: 4 = repo error, 3 = bad repo name, 6 = no repos defined.
su_refresh() {
case "$(su_backend)" in
dnf) dnf -q --refresh --setopt=skip_if_unavailable=0 makecache >/dev/null ;;
zypper) zypper --non-interactive --quiet refresh ;;
esac
}
# ---------------------------------------------------------------- 2. upgradable list
# installed evr table "name.arch<TAB>evr", highest version last (installonly packages have several)
_su_installed_evr() {
rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' \
| LC_ALL=C sort -t $'\t' -k1,1 -k2,2V
}
# dnf (4 and 5): authoritative source = `dnf -q check-update` (the only listing that honours excludes AND
# versionlock in both majors; repoquery/list do not honour dnf4 versionlock). exit 100 = updates, 0 = none.
su_upgradable_dnf() {
local out rc na ver repo rest name arch k evr
local -A old=() oldn=()
out=$(dnf -q check-update --setopt=skip_if_unavailable=0); rc=$?
case $rc in 0) return 0 ;; 100) ;; *) return "$rc" ;; esac
while IFS=$'\t' read -r k evr; do evr=${evr#0:}; old[$k]=$evr; oldn[${k%.*}]=$evr; done < <(_su_installed_evr)
while read -r na ver repo rest; do
[ "$na" = Obsoleting ] && break # dnf4 appends an "Obsoleting Packages" section
[ -n "$repo" ] && [ -z "$rest" ] || continue # skips blank line, dnf5 "Upgrades" header, etc.
name=${na%.*}; arch=${na##*.}
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo" "$arch"
done <<<"$out" | LC_ALL=C sort
}
# XML attribute helper: _su_xattr '<tag text>' attr -> $REPLY (unescaped); rc 1 if absent
_su_xattr() {
local v=${1#* $2=\"}
if [ "$v" = "$1" ]; then REPLY=; return 1; fi
v=${v%%\"*}
v=${v//&lt;/<}; v=${v//&gt;/>}; v=${v//&quot;/\"}; v=${v//&apos;/\'}; v=${v//&amp;/&}
REPLY=$v
}
# zypper: `zypper --xmlout list-updates` (locked packages are hidden; `lu --all` would show them as "vl").
# The XML is flattened with tr so each <update ...>...</update> becomes one line, then split with sed.
su_upgradable_zypper() {
local xml rc line tag name new old arch src alias
xml=$(zypper --non-interactive --xmlout list-updates); rc=$?
[ "$rc" -eq 0 ] || return "$rc"
# NB: after tr the stream has no trailing newline; GNU sed keeps it that way, so the LAST record would be
# silently dropped by a plain `while read` -> `|| [ -n "$line" ]` is required.
printf '%s\n' "$xml" | tr '\n' ' ' | sed 's/<update /\n<update /g' | while IFS= read -r line || [ -n "$line" ]; do
case $line in '<update kind="package" '*) ;; *) continue ;; esac
tag=${line%%>*}
_su_xattr "$tag" name; name=$REPLY
_su_xattr "$tag" edition; new=$REPLY
_su_xattr "$tag" edition-old; old=$REPLY
_su_xattr "$tag" arch; arch=$REPLY
src=${line#*<source }; alias=
[ "$src" != "$line" ] && { _su_xattr "$src" alias && alias=$REPLY; }
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$old" "$new" "$alias" "$arch"
done | LC_ALL=C sort
}
su_upgradable() {
case "$(su_backend)" in dnf) su_upgradable_dnf ;; zypper) su_upgradable_zypper ;; esac
}
# ---------------------------------------------------------------- 3. summary + description
# su_describe PKG : line 1 = Summary, remaining lines = Description (paragraph breaks kept).
# Candidate (upgrade) version if there is one, otherwise the installed one.
su_describe() {
local pkg=$1 out
case "$(su_backend)" in
dnf)
out=$(dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' "$pkg" 2>/dev/null)
[ -n "$out" ] || out=$(rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg" 2>/dev/null)
printf '%s\n' "$out" ;;
zypper)
out=$(zypper --non-interactive --quiet info "$pkg" 2>/dev/null) || { rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg"; return; }
printf '%s\n' "$out" | {
local l indesc=0 pend=
while IFS= read -r l; do
if [ $indesc = 1 ]; then # description lines are indented by 4 spaces;
case $l in # blank lines are real empty lines (paragraph break OR end of block)
' '*) printf '%s%s\n' "$pend" "${l# }"; pend= ;;
'') pend+=$'\n' ;;
*) break ;;
esac
else
case $l in
Summary*:*) [ -z "${sum:-}" ] && { sum=${l#*: }; printf '%s\n' "$sum"; } ;;
Description*:*) indesc=1 ;;
esac
fi
done
} ;;
esac
}
# ---------------------------------------------------------------- 4/5. installed dependency graph (pure rpm)
# su_cap_names: stdin = one REQUIRENAME per line; stdout = plain capability names to look up.
# - drops rpmlib(...) pseudo-deps
# - expands rich/boolean deps "(a if b)", "(a >= 1.0 or b)", "((a and b) unless c else d)" to their
# *members*; the CONDITION of "if"/"unless" is dropped (it is not a dependency). Conditions are not
# evaluated, so "(a if b)" yields "a" even if b is not installed = safe superset.
# - names like libc.so.6(GLIBC_2.34)(64bit), perl(Foo::Bar), /bin/sh, config(x) are kept verbatim
su_cap_names() {
local cap tok w depth cond skip o c n
set -f
while IFS= read -r cap; do
case $cap in
''|rpmlib\(*) ;;
\(*)
depth=0; cond=-1; skip=0
for tok in $cap; do
w=$tok; n=0
while [ "${w:0:1}" = '(' ]; do w=${w:1}; n=$((n+1)); done # structural opens (a cap never starts with '(')
depth=$((depth+n))
o=${w//[^(]/}; c=${w//[^)]/}; n=$(( ${#c} - ${#o} )) # structural closes = surplus ')' at the end
[ "$n" -lt 0 ] && n=0
[ "$n" -gt 0 ] && w=${w%"${w: -$n}"}
if [ "$skip" = 1 ]; then skip=0
else
case $w in
if|unless) cond=$depth ;;
else) [ "$cond" = "$depth" ] && cond=-1 ;;
and|or|with|without|'') ;;
'='|'<'|'>'|'<='|'>='|'==') skip=1 ;; # next token is a version
*) [ "$cond" -lt 0 ] && printf '%s\n' "$w" ;;
esac
fi
depth=$((depth-n))
[ "$cond" -ge 0 ] && [ "$depth" -lt "$cond" ] && cond=-1
done ;;
*) printf '%s\n' "$cap" ;;
esac
done
set +f
}
# su_closure PKG...: installed packages (plain names, sorted; the roots themselves are not listed) that PKG...
# need, transitively. Breadth-first; 2 rpm calls per level. Identical results to
# `dnf repoquery --installed --requires --resolve --recursive` on dnf4 (dnf5 has no such option).
su_closure() {
local -A seen=(); local -a frontier=("$@") caps provs next out=(); local p
for p in "$@"; do seen[$p]=1; done
while [ "${#frontier[@]}" -gt 0 ]; do
mapfile -t caps < <(rpm -q --qf '[%{REQUIRENAME}\n]' "${frontier[@]}" 2>/dev/null | sort -u | su_cap_names | sort -u)
[ "${#caps[@]}" -gt 0 ] || break
mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" 2>/dev/null | grep -v -e '^no package provides ' | sort -u)
next=()
for p in "${provs[@]}"; do
[ -n "$p" ] && [ -z "${seen[$p]:-}" ] && { seen[$p]=1; next+=("$p"); out+=("$p"); }
done
frontier=("${next[@]}")
done
[ "${#out[@]}" -gt 0 ] && printf '%s\n' "${out[@]}" | sort -u
return 0
}
# su_req_table FILE: "capability<TAB>requiring-package" for every installed package, sorted (LC_ALL=C).
# NB: inside [...] the scalar tag MUST be written %{=NAME}; plain %{NAME} makes rpm fail with
# "array iterator used with different sized arrays" for ~50% of packages (all rpm versions 4.16..6.0).
su_req_table() {
local out=$1 cap name t all
all=$(mktemp)
rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' > "$all"
{
grep -v -e '^rpmlib(' -e '^(' "$all" || true
{ grep -e '^(' "$all" || true; } | while IFS=$'\t' read -r cap name; do
su_cap_names <<<"$cap" | while IFS= read -r t; do printf '%s\t%s\n' "$t" "$name"; done
done
} | LC_ALL=C sort -u > "$out"
rm -f "$all"
}
# su_rdeps PKG: installed packages (plain names, sorted) that DIRECTLY require something PKG provides: its name,
# any Provides (sonames, virtual capabilities; versions ignored) or any file it owns (file deps like /bin/sh).
# Same result as `dnf repoquery --installed --whatrequires PKG` (verified); plain `rpm -q --whatrequires PKG`
# only matches the literal capability "PKG" and misses e.g. 51 of 52 dependents of bash on EL9.
# (Set SU_REQ_TAB=<file made by su_req_table> to reuse one table across many calls.)
su_rdeps() {
local pkg=$1 tab caps own=0
if [ -n "${SU_REQ_TAB:-}" ] && [ -s "$SU_REQ_TAB" ]; then tab=$SU_REQ_TAB; else tab=$(mktemp); su_req_table "$tab"; own=1; fi
caps=$(mktemp)
{ rpm -q --qf '[%{PROVIDENAME}\n]' "$pkg"; rpm -ql "$pkg" | grep -v -e '^(contains no files)$'; } | LC_ALL=C sort -u > "$caps"
LC_ALL=C join -t $'\t' "$caps" "$tab" | cut -f2 | LC_ALL=C sort -u | grep -v -x -F -e "$pkg" || true
rm -f "$caps"; [ "$own" = 1 ] && rm -f "$tab"
return 0
}
# su_rdeps_recursive PKG...: everything installed that (transitively) depends on PKG... (roots excluded).
# NB: "direct" dependents can be far fewer than you expect: on Tumbleweed `/bin/sh` is provided by bash-sh, not
# bash, so aaa_base/binutils/... are dependents of bash-sh (which requires bash), i.e. only in the recursive set.
su_rdeps_recursive() {
local -A seen=(); local -a frontier=("$@") next out=(); local p r SU_REQ_TAB
SU_REQ_TAB=$(mktemp); su_req_table "$SU_REQ_TAB"
for p in "$@"; do seen[$p]=1; done
while [ "${#frontier[@]}" -gt 0 ]; do
next=()
for p in "${frontier[@]}"; do
while IFS= read -r r; do
[ -n "$r" ] && [ -z "${seen[$r]:-}" ] && { seen[$r]=1; next+=("$r"); out+=("$r"); }
done < <(su_rdeps "$p")
done
frontier=("${next[@]}")
done
rm -f "$SU_REQ_TAB"
[ "${#out[@]}" -gt 0 ] && printf '%s\n' "${out[@]}" | sort -u
return 0
}
# ---------------------------------------------------------------- 6. upgrade with per-run exclusions
# su_zypper_excluding NAME... -- zypper-args...
# zypper has no --exclude. Per-run locks WITHOUT touching /etc/zypp/locks: point libzypp at a private
# zypp.conf (env ZYPP_CONF) whose locksfile.path is a temp copy of the current locks, add the exclusions there,
# run zypper, delete the temp dir. Nothing to restore, nothing persisted, safe on Ctrl-C.
su_zypper_excluding() {
local -a ex=(); local d src locks rc
while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done
[ "${1:-}" = -- ] && shift
d=$(mktemp -d) || return 1
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf # Tumbleweed ships only /usr/etc
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1)
: "${locks:=/etc/zypp/locks}"
cp "$src" "$d/zypp.conf" || { rm -rf "$d"; return 1; }
if [ -e "$locks" ]; then cp "$locks" "$d/locks"; else : > "$d/locks"; fi
printf '\n[main]\nlocksfile.path = %s\n' "$d/locks" >> "$d/zypp.conf"
if [ "${#ex[@]}" -gt 0 ]; then
ZYPP_CONF=$d/zypp.conf zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || { rm -rf "$d"; return 1; }
fi
ZYPP_CONF=$d/zypp.conf zypper --non-interactive "$@"; rc=$?
rm -rf "$d"
return $rc
}
# su_upgrade [--exclude NAME]... [-- extra-pm-args...] (non-interactive; exit status of the package manager)
# dnf : dnf -y upgrade --exclude=a,b (comma list works on dnf4 AND dnf5; repeated flags too)
# zypper : update (Leap/SLE) or dup (Tumbleweed) with temporary locks (see su_zypper_excluding)
su_upgrade() {
local -a ex=() extra=()
while [ $# -gt 0 ]; do
case $1 in
--exclude) ex+=("$2"); shift 2 ;;
--) shift; extra=("$@"); break ;;
*) echo "su_upgrade: bad arg $1" >&2; return 2 ;;
esac
done
case "$(su_backend)" in
dnf)
local ox=(); if [ "${#ex[@]}" -gt 0 ]; then local IFS=,; ox=("--exclude=${ex[*]}"); fi
dnf -y --setopt=skip_if_unavailable=0 upgrade "${ox[@]}" "${extra[@]}" ;;
zypper)
su_zypper_excluding "${ex[@]}" -- "$(su_zypper_verb)" "${extra[@]}" ;;
esac
}
# su_zypper_excluding_snapshot NAME... -- zypper-args...
# Alternative recipe (what you would do if ZYPP_CONF were unavailable): add real locks to /etc/zypp/locks
# and restore the EXACT previous file afterwards. `zypper removelock NAME` is NOT a safe undo: it deletes ALL
# locks matching NAME (including a pre-existing user lock) and leaves an empty /etc/zypp/locks behind even if the
# file did not exist before. Hence: byte copy (cp -p) of the file, or "file absent" flag, restored by a trap.
# Runs in a subshell so the traps are local. Not safe against two concurrent runs (use su_zypper_excluding).
su_zypper_excluding_snapshot() (
ex=(); while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
bak=$(mktemp) || exit 1; had=0; restored=0
if [ -e "$locks" ]; then cp -p -- "$locks" "$bak" || exit 1; had=1; fi
_restore() {
[ "$restored" = 1 ] && return 0; restored=1
if [ "$had" = 1 ]; then cp -p -- "$bak" "$locks"; else rm -f -- "$locks"; fi
rm -f -- "$bak"
}
trap '_restore' EXIT; trap '_restore; exit 130' INT; trap '_restore; exit 143' TERM HUP
if [ "${#ex[@]}" -gt 0 ]; then zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || exit 1; fi
zypper --non-interactive "$@"
)
# ---------------------------------------------------------------- 7. plan preview (display only)
# su_plan [--exclude NAME]... -> section<TAB>name<TAB>old<TAB>new<TAB>repo<TAB>arch<TAB>replaces
# section = upgrade | install | remove | downgrade | reinstall | skipped | other ; old/replaces are "-" if n/a.
# Shows what `su_upgrade` would do INCLUDING new dependencies, removals and obsolete-replacements, which the
# upgradable list (check-update / list-updates) never shows. Nothing is changed.
su_plan_zypper() {
local out
out=$(su_zypper_excluding "$@" -- --xmlout "$(su_zypper_verb)" --dry-run) || return $?
# <install-summary><to-upgrade><solvable type="package" name= edition= arch= repository= edition-old= .../>...</to-upgrade><to-install>...
printf '%s\n' "$out" | tr '\n' ' ' | sed -e 's/<to-/\n<to-/g' -e 's/<solvable /\n<solvable /g' |
{
local line sec=other tag n e o a r
while IFS= read -r line || [ -n "$line" ]; do
case $line in
'<to-'*) sec=${line#<to-}; sec=${sec%%>*} ;;
'<solvable type="package" '*)
tag=${line%%>*}
_su_xattr "$tag" name; n=$REPLY
_su_xattr "$tag" edition; e=$REPLY
_su_xattr "$tag" edition-old && o=$REPLY || o=-
_su_xattr "$tag" arch; a=$REPLY
_su_xattr "$tag" repository; r=$REPLY
printf '%s\t%s\t%s\t%s\t%s\t%s\t-\n' "$sec" "$n" "$o" "$e" "$r" "$a" ;;
esac
done
}
}
# dnf: no machine-readable transaction output exists in dnf4; parse the (stable, non-tty) table printed by
# `dnf --assumeno upgrade`. Section headers are col-0 lines ending in ':'; rows start with one space:
# dnf4: " name arch evr repo size unit" , then optionally " replacing old.arch oldevr"
# dnf5: " name arch 0:evr repo size unit", then optionally " replacing old arch 0:oldevr repo size unit"
su_plan_dnf() {
local -a ex=(); local ox=() out l a b c d rest sec=other
local ps= pn= po=- pv= pr= pa= pp=- # pending row (printed when the next row/section/summary arrives)
while [ $# -gt 0 ]; do case $1 in --exclude) ex+=("$2"); shift 2 ;; *) shift ;; esac; done
if [ "${#ex[@]}" -gt 0 ]; then ox=("--exclude=$(IFS=,; echo "${ex[*]}")"); fi
out=$(dnf --assumeno --setopt=skip_if_unavailable=0 upgrade "${ox[@]}" 2>/dev/null) # --assumeno => rc 1 is normal
printf '%s\n' "$out" | grep -q '^Transaction Summary' || return 1
_flush() {
[ -n "$pn" ] || return 0
if [ "$ps" = upgrade ] && [ "$po" = - ]; then # dnf4 prints no "replacing" line for plain upgrades
po=$(rpm -q --qf '%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' "$pn.$pa" 2>/dev/null | LC_ALL=C sort -V | tail -n1); po=${po#0:}; : "${po:=-}"
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$ps" "$pn" "$po" "$pv" "$pr" "$pa" "$pp"; pn=; po=-; pp=-
}
while IFS= read -r l; do
case $l in
'Transaction Summary'*) break ;;
[A-Z]*:) _flush
case $l in Upgrading*) sec=upgrade ;; Installing*) sec=install ;; Removing*) sec=remove ;;
Downgrading*) sec=downgrade ;; Reinstalling*) sec=reinstall ;; Skipping*) sec=skipped ;; *) sec=other ;; esac ;;
' '*)
read -r a b c d rest <<<"$l"
case $a in
Package|=*|'') ;;
replacing)
if [ -n "$d" ]; then c=${d#0:}; else b=${b%.*}; c=${c#0:}; fi # dnf5: name arch evr repo ; dnf4: name.arch evr
if [ "$sec" = upgrade ]; then po=$c; else pp=$b; fi ;;
*) _flush; ps=$sec; pn=$a; pa=$b; pv=${c#0:}; pr=$d ;;
esac ;;
esac
done <<<"$out"
_flush
}
su_plan() {
case "$(su_backend)" in dnf) su_plan_dnf "$@" ;; zypper)
local -a ex=(); while [ $# -gt 0 ]; do case $1 in --exclude) ex+=("$2"); shift 2 ;; *) shift ;; esac; done
su_plan_zypper "${ex[@]}" ;; esac
}
# ---------------------------------------------------------------- 8. remove (+ unneeded dependencies)
# dnf: clean_requirements_on_remove=1 is the default on dnf4 and dnf5 -> unneeded deps go too.
# (A dependency installed via plain `rpm -i` is NOT considered unneeded; only deps installed by dnf are.)
# Exit status is 0 even if the package did not exist -> always verify with rpm -q.
# zypper: --clean-deps (-u). `remove` of an unknown name exits 104.
su_remove() {
case "$(su_backend)" in
dnf) dnf -y remove "$@" ;;
zypper) zypper --non-interactive remove --clean-deps "$@" ;;
esac
}
+175
View File
@@ -0,0 +1,175 @@
#!/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"