#!/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 old-evr new-evr repo 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.archevr", 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 '' 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//</<}; v=${v//>/>}; v=${v//"/\"}; v=${v//'/\'}; v=${v//&/&} 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 ... 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/*} _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#*/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: "capabilityrequiring-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= 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]... -> sectionnameoldnewrepoarchreplaces # 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 $? # ...... printf '%s\n' "$out" | tr '\n' ' ' | sed -e 's/*} ;; '*} _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 }