Files
bryanpedini 875c8294bf feat(show): merge the installed record with the upstream candidate
N?? and `show PKG` now print the installed record in the package
manager's own key/value layout, after one line explaining the format:

    # lines starting with > come from the upstream candidate: a differing
    value, or a field the installed record lacks

After each field whose candidate value differs comes the candidate's
line prefixed with "> "; fields only the candidate has are appended the
same way; installed-only fields stay; identical fields print once.
Multi-line values (pacman Optional Deps, dnf Description, RFC-822
continuation lines) are compared as blocks and a differing candidate
block gets "> " on every line. A package that is not installed shows the
candidate alone ("# not installed: this is the upstream candidate").

Merged where the backend gives comparable records for both sides:
- pacman: -Qi vs -Si REPO/PKG, REPO = the first repository -Sl lists
- apt: dpkg-query -s (Status, Conffiles, the long description; richer
  than apt-cache show PKG=<installed>, which gives the archive record
  when that version is still listed) vs apt-cache show --no-all-versions;
  Description-xx pairs with Description and a one-line candidate
  Description equal to the installed first line counts as identical
  (the archives ship the long text in Translation files)
- dnf: info --installed vs info --upgrades (--available lists other
  architectures first and nothing for an up-to-date package); not
  installed: --available; dnf5's differently padded keys are re-padded
zypper keeps `zypper info --requires`: rpm -qi and zypper info share
almost no keys and zypper cannot print the installed record itself.
No candidate, or not exactly one record per side, keeps the old output.

Formats verified in containers and recorded in discovery/*.md. Unit
tests replay recorded pacman, apt, dnf4 and dnf5 records; the E2E suite
checks 2?? on su-dummy-b on all 12 distros.
2026-10-10 14:24:39 +02:00

71 KiB

RPM family discovery: DNF (AlmaLinux / Rocky / Fedora) and ZYPPER (openSUSE)

Everything below was run in throwaway docker run --rm -i containers (Docker 29.8.2 on the host, 2026-10-09). All outputs are real; "exit=N" is the real exit status. All commands run with LC_ALL=C. Companion files (all in discovery/rpm/, all proven on every image listed in section 0):

file purpose
fixture.sh E2E fixture: builds 3 dummy RPMs (+ optional extras), local repo, 1.0 installed / 2.0 offered. dnf vs zypper auto-detected
lib-rpm.sh the proven bash building blocks quoted in this document (su_refresh, su_upgradable, su_describe, su_rdeps, su_closure, su_upgrade, su_plan, su_remove, ...)
proof.sh runs the fixture + every lib-rpm.sh function and asserts the results (43-53 PASS lines per image)

Reproduce on any image: docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh or ... bash /d/proof.sh.


0. Verdict, images, and the surprises

0.1 Chosen commands (one line each)

need DNF (dnf4 and dnf5, yum is the same binary) ZYPPER
refresh dnf -q --refresh --setopt=skip_if_unavailable=0 makecache >/dev/null (exit 0/1) zypper --non-interactive --quiet refresh (exit 0; 4 repo error; 3 unknown repo; 6 no repos)
upgradable list dnf -q check-update --setopt=skip_if_unavailable=0 (exit 100 = updates, 0 = none, 1 = error), parse 3 columns name.arch evr repo, old version from rpm -qa zypper --non-interactive --xmlout list-updates (exit 0 always), parse <update ... name edition edition-old arch> + <source alias>
summary/description dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' PKG, fallback rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' PKG zypper -q -n info PKG (shows candidate, else installed), parse Summary : / indented Description :
installed reverse deps pure rpm: provides + owned files joined against all REQUIRENAMEs (su_rdeps); == dnf repoquery --installed --whatrequires same pure-rpm function (identical on both families)
recursive installed closure pure rpm BFS su_closure (rpm -q REQUIRENAME + rpm -q --whatprovides); == dnf4 repoquery --installed --requires --resolve --recursive; dnf5 has no --resolve same function
per-run exclusion dnf -y upgrade --exclude=a,b (comma list; repeated flags also work; dnf4 and dnf5) no --exclude. Chosen: private ZYPP_CONF with its own locksfile.path (su_zypper_excluding). Alternative with exact restore: snapshot/restore /etc/zypp/locks + trap (su_zypper_excluding_snapshot)
non-interactive upgrade dnf -y upgrade Leap/SLE: zypper --non-interactive update; Tumbleweed (os-release ID=opensuse-tumbleweed): zypper --non-interactive dup
remove + unneeded deps dnf -y remove PKG (clean_requirements_on_remove=1 is the default on dnf4 and dnf5) zypper --non-interactive remove --clean-deps PKG
plan preview (new deps, removals, obsoletes) dnf --assumeno upgrade [--exclude=..] + table parse (su_plan_dnf) zypper --non-interactive --xmlout update or dup plus --dry-run (<install-summary><to-upgrade>/<to-install>/<to-remove>)

0.2 Correct image names

asked result
almalinux:9 exists. AlmaLinux 9.8, dnf 4.14.0, rpm 4.16.1.3, bash 5.1.8
almalinux:10 exists. AlmaLinux 10.2, dnf 4.20.0 (NOT dnf5), rpm 4.19.1.1, bash 5.2.26
rockylinux:9 exists but stale: Docker "official" library image = Rocky 9.3 created 2023-11-28 (96 pending upgrades on day one), dnf 4.14.0. Fresh equivalent: rockylinux/rockylinux:9 or quay.io/rockylinux/rockylinux:9 = Rocky 9.8 (same image id 686e4558d0a5)
rockylinux:10 does not exist: docker manifest inspect rockylinux:10 -> "no such manifest" (also :10.0, :10.1, :10-minimal missing). Correct: rockylinux/rockylinux:10 (Docker Hub) or quay.io/rockylinux/rockylinux:10
quay vs Docker Hub Rocky 10 same image: both resolve to id sha256:0b5329d806d0..., repo digest sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834. Rocky 10.2, dnf 4.20.0, rpm 4.19.1.1. (fixture.sh also passes on the quay tag, 8 s.)
opensuse/tumbleweed:latest openSUSE Tumbleweed (os-release ID=opensuse-tumbleweed, VERSION_ID=20261007), zypper 1.14.101, rpm 4.20.1, bash 5.3.20
opensuse/leap:latest Leap 16.0 (ID=opensuse-leap), image created 2025-08-26, zypper 1.14.101, rpm 4.20.1, bash 5.2.37
extra, for dnf5 only fedora:latest = Fedora 44, dnf5 5.4.3.0, rpm 6.0.2 (pulled only to verify dnf5 syntax; not a target)

0.3 Surprises that change the design

  1. EL10 is dnf4 (4.20.0), not dnf5. AlmaLinux 10 has no dnf5 package anywhere (BaseOS/AppStream/CRB/Extras/EPEL: dnf -q --enablerepo='*' list --available dnf5 -> "No matching Packages"; only microdnf 3.10.1). Rocky 10 ships dnf5 5.1.12 only in the disabled devel repo. So dnf5 is only met on Fedora (or a user-installed dnf5). The tool must support dnf4 and dnf5 from one code path; the only target images that run dnf5 are not in the requested list, so dnf5 was verified on fedora:latest.
  2. dnf5 silently ignores a dead repo (exit 0, -q hides the warning) unless --setopt=skip_if_unavailable=0. dnf4 errors by default (exit 1).
  3. rpm -qa --qf '[%{REQUIRENAME}\t%{NAME}\n]' is broken (error: incorrect format: array iterator used with different sized arrays, ~50% of packages) on every rpm 4.16..6.0; the scalar tag must be written %{=NAME} inside [...].
  4. Missing tools in base images: no awk/find/xargs/hostname/python3/xmllint on Tumbleweed; no find/xargs/python3/xmllint on Leap; no diff/cmp/ps/which/sudo almost everywhere. All proven code uses only bash + rpm + sed/grep/tr/sort/join/cut/mktemp.
  5. rpm -q --whatrequires bash returns 1 package on EL9 where dnf repoquery --installed --whatrequires bash and su_rdeps bash return 52 (file deps /bin/sh, /usr/bin/bash, sonames).
  6. zypper removelock is not an undo (deletes all matching locks, leaves an empty /etc/zypp/locks file even if there was none).
  7. dnf list --upgrades and (dnf4) repoquery --upgrades ignore the versionlock plugin; only check-update honours it on both majors.

0.4 Base image facts (this is item 9 of the brief; repeated as a pointer in section 9)

alma:9 alma:10 rocky:9 (9.3 lib) rocky 10 tumbleweed leap 16.0
package mgr dnf 4.14.0 dnf 4.20.0 dnf 4.14.0 dnf 4.20.0 zypper 1.14.101 zypper 1.14.101
yum symlink -> dnf-3 dnf-3 dnf-3 dnf-3 absent absent
rpm 4.16.1.3 4.19.1.1 4.16.1.3 4.19.1.1 4.20.1 4.20.1
bash 5.1.8 5.2.26 5.1.8 5.2.26 5.3.20 5.2.37
sudo no (installable, baseos) no no no no (installable) no (installable)
su yes no yes yes yes yes
python3 yes yes yes yes no no
awk / find / xargs yes yes yes yes no / no / no awk yes, find, xargs no
xmllint yes yes yes yes no no
runs as root root root root root root

bats:

  • EL9/EL10 (Alma and Rocky): not in BaseOS/AppStream/CRB; needs EPEL: dnf -y install epel-release && dnf -y install bats (epel-release is in extras, enabled by default). Verified: EL9 -> Bats 1.8.0 (bats-1.8.0-1.el9), EL10 -> Bats 1.11.1 (bats-1.11.1-1.el10_0). bats install itself took 3-5 s; epel-release 1-12 s (measured on all four EL images).
  • Tumbleweed: zypper -n in bats -> Bats 1.14.0 (bats-1.14.0-1.1), main OSS repo, no extra repo. Leap 16.0: Bats 1.11.1 (bats-1.11.1-160000.2.2), main repo. (install of bats+sudo: 8-9 s.)
  • sudo installable everywhere: EL baseos 1.9.17p2 / 1.9.17-10.p2.el10_2.7; Tumbleweed 1.9.17p2; Leap 1.9.17p1.
  • python3 on openSUSE: Leap has python3 / python313; Tumbleweed has versioned python311/python312/... (no unversioned package).
  • rpm-build and createrepo_c are installable on all six images (BaseOS/AppStream on EL, main OSS on openSUSE). EL9: rpm-build 4.16.1.3-40.el9 + createrepo_c 0.20.1-4.el9; EL10: rpm-build 4.19.1.1-23.el10 + createrepo_c 1.1.2-4.el10; Tumbleweed: rpm-build 4.20.1-11.1 + createrepo_c 1.2.3-1.4; Leap 16: rpm-build 4.20.1-160000.3.1 + createrepo_c 1.1.0-160000.2.2. No EPEL needed for them.

1. Refresh metadata non-interactively

DNF

$ dnf makecache                                  # alma9 (dnf4), cold cache, 9.0 s
AlmaLinux 9 - AppStream                          23 MB/s |  28 MB     00:01
AlmaLinux 9 - BaseOS                             32 MB/s |  45 MB     00:01
AlmaLinux 9 - Extras                             52 kB/s |  33 kB     00:00
Last metadata expiration check: 0:00:01 ago on Fri Oct  9 08:20:43 2026.
Metadata cache created.                          exit=0

Variants tested (stdout lines / stderr lines / exit):

command dnf4 (alma9/10) dnf5 (fedora 44)
dnf makecache 5 / 0 / 0 (progress + "Metadata cache created." on stdout) 1 / 3 / 0 (progress "Updating and loading repositories:" ... "Repositories loaded." on stderr, "Metadata cache created." on stdout)
dnf -q makecache 0 / 0 / 0 (silent) 1 / 0 / 0 (still prints "Metadata cache created." to stdout) -> >/dev/null
dnf --refresh makecache and dnf makecache --refresh both accepted, forces re-download both accepted
plain makecache (no --refresh) no re-download while the cache is younger than metadata_expire (dnf4 dump on alma9: metadata_expire = 172800 = 48 h; ~1-2 s of revalidation measured) same
  • --refresh (= "set metadata as expired") is the right switch for a skeptical tool: dnf -q --refresh makecache re-downloads every enabled repo (alma9 1.6-7 s, alma10 2.5-7 s, rocky 1-1.4 s, fedora 1.7 s measured warm; cold 3-9.5 s, section 11).
  • dnf check-update exit codes (verified on dnf4 and dnf5): 100 = updates available, 0 = nothing (also 0 when a name pattern matches nothing: dnf -q check-update 'nonexist*' -> exit=0), 1 = error.
  • Dead repo handling differs (repodata removed from the local repo):
command dnf4 alma10 dnf5 fedora
dnf -q --refresh makecache exit 1: Error: Failed to download metadata for repo 'su-dummy': Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried exit 0, no message (repo silently ignored, default skip_if_unavailable=true for repos that do not set it)
same + --setopt=skip_if_unavailable=0 exit 1 (same message) exit 1: Failed to download metadata (baseurl: "file:///var/lib/su-dummy-repo") for repository "su-dummy": Usable URL not found
dnf -y upgrade su-dummy-a exit 1 exit 0, prints Nothing to do.; su-dummy-a stays 1.0 (the dead repo is only a stderr warning >>> Curl error (37) ..., absent with -q) unless skip_if_unavailable=0

So always pass --setopt=skip_if_unavailable=0 (harmless on dnf4) and never rely on -q to see problems.

  • Oddity seen on dnf 4.14.0 (EL9): with the repo gone, dnf -q check-update still returned 100 from stale cache, while makecache --refresh, repoquery and upgrade failed with exit 1. => run the explicit refresh first and abort on its failure, do not infer repo health from check-update.
  • Real-world flake observed once on rockylinux:9: Error: Failed to download metadata for repo 'extras': repomd.xml parser error: Parse error at line: 1 (Extra content at the end of the document (mirror served garbage), exit 1; re-run was clean. The refresh step correctly failed the run; the tool should surface this and let the user retry.
  • yum: /usr/bin/yum -> dnf-3 on all EL images (prints 4.14.0 / 4.20.0); /usr/bin/yum -> dnf5 on Fedora. Detection: dnf --version | head -1 starts with dnf5 version on dnf5, with a bare version (4.14.0) on dnf4.

ZYPPER

$ zypper --non-interactive refresh              # tumbleweed, metadata fresh, 0.6 s
Repository 'openSUSE-Tumbleweed-Update' is up to date.
Repository 'openSUSE-Tumbleweed-Non-Oss' is up to date.
Repository 'Open H.264 Codec (openSUSE Tumbleweed)' is up to date.
Repository 'openSUSE-Tumbleweed-Oss' is up to date.
Repository 'su-dummy' is up to date.
All repositories have been refreshed.           exit=0
  • -q silences it; exit 0 either way. zypper refresh --force su-dummy re-downloads ("Forcing raw metadata refresh ... Building repository 'su-dummy' cache [....done]", exit 0).
  • Normal refresh only checks whether the repo changed (cheap); it is also what zypper lu/up/dup do implicitly (autorefresh=on).
  • Broken repo: Retrieving repository 'su-dummy' metadata [.error] ... Could not refresh the repositories because of errors. exit=4. Unknown name (refresh nonexist): Repository 'nonexist' not found by its alias, number, or URI. exit=3.
  • Leap prints Refreshing service 'openSUSE'. as first line of nearly every command (it is a service, not a repo) - in -q mode it disappears, in --xmlout mode it is a <message type="info">.
  • GPG: the test repo is --no-gpgcheck. For real third-party repos --non-interactive would refuse unknown keys; --gpg-auto-import-keys is the (security-relevant) opt-in. Not used by the tool.

2. Machine-parsable list of upgradable packages (name<TAB>old<TAB>new<TAB>repo)

2.1 DNF: candidates compared (alma9/alma10 = dnf4, fedora = dnf5)

dnf -q check-update dnf -q repoquery --upgrades --queryformat ... dnf -q list --upgrades
first line(s) dnf4: one blank line; dnf5: a header line Upgrades (printed even with -q) none Available Upgrades (dnf4) / Available upgrades (dnf5)
columns 3: name.arch evr repo (arch suffix attached, epoch shown as 2:... when non-zero) whatever you ask for same 3 columns
honours --exclude / excludepkgs yes (dnf4, dnf5) yes yes
honours versionlock plugin yes (dnf4 and dnf5) dnf4: no (locked pkg still listed), dnf5: yes no on both
same code path as dnf upgrade yes no no
"Obsoleting Packages" dnf4: trailing section Obsoleting Packages (+ indented old pkg @System); dnf5: not listed not listed not listed
exit 100 / 0 / 1 0 0
queryformat escapes - dnf4 expands \t \n and appends its own newline (blank line after each record); dnf5 does not expand \t (prints literal \t), expands \n, appends nothing -
old (installed) version not given not given (no installed-version tag) not given

Samples (cat -A; EL9 shown, EL10 identical in shape):

$ dnf -q check-update | cat -A                                     # dnf4, alma9, exit=100
$
gnutls.x86_64                     3.8.10-9.el9_8                        baseos  $
su-dummy-a.noarch                 2.0-1                                 su-dummy$
su-dummy-b.noarch                 2.0-1                                 su-dummy$
su-dummy-c.x86_64                 2.0-1                                 su-dummy$
vim-minimal.x86_64                2:8.2.2637-26.el9_8.23                baseos  $

$ dnf -q check-update | head -3                                    # dnf5, fedora 44, exit=100
Upgrades
ca-certificates.noarch             2026.2.90_v9.0.317-1.fc44 updates
curl.x86_64                        8.18.0-12.fc44            updates

$ dnf -q check-update 'nonexist*' ; echo exit=$?                   # both: nothing printed, exit=0
$ dnf -q repoquery --upgrades --queryformat '%{name}\t%{evr}\t%{repoid}\n' 'su-dummy-*'
su-dummy-a	2.0-1	su-dummy                                       # dnf4: ...followed by a BLANK line after every record
su-dummy-a\t2.0-1\tsu-dummy                                        # dnf5: literal "\t" (not expanded!)
$ dnf -q repoquery --upgrades --queryformat $'%{name}\t%{arch}\t%{evr}\t%{repoid}\n' 'su-dummy-*' | cat -A
su-dummy-a^Inoarch^I2.0-1^Isu-dummy$                               # dnf4 and dnf5 identical when real TAB chars are passed
$ dnf -q repoquery --upgrades                                      # default format on both: name-epoch:ver-rel.arch
su-dummy-a-0:2.0-1.noarch

Other tag differences: %{reponame} is the repo id on dnf4 but the human name on dnf5 (skeptical-updater dummy packages); %{repoid} is the id on both. %{evr} omits epoch 0 on both, includes 1: when non-zero (su-dummy-e with Epoch: 1 -> 1:2.0-1).

dnf4 long names do not wrap in a non-tty even at COLUMNS=30 (a 108-character package name stays on one line, padded): the 3-field read is safe; the same holds on dnf5.

Decision: parse dnf -q check-update (only listing that honours excludes and versionlock on both majors and is the same solver view as dnf upgrade), take the old version from rpm -qa, and use repoquery only for summary/description. Parse (from lib-rpm.sh, proven on 5 dnf images):

_su_installed_evr() {   # "name.arch<TAB>evr", ascending so the highest version (installonly pkgs have several) wins
  rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2V
}
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 trailing "Obsoleting Packages" section
    [ -n "$repo" ] && [ -z "$rest" ] || continue    # drops the blank line (dnf4), the "Upgrades" header (dnf5), anything not 3 fields
    name=${na%.*}; arch=${na##*.}                   # plain name; the arch never contains a dot
    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
}

Output from proof.sh (alma10, tab shown as ^I):

gnutls^I3.8.10-4.el10_2^I3.8.10-5.el10_2^Ibaseos^Ix86_64$
rpm-sequoia^I1.10.1.1-2.el10^I1.10.2.1-1.el10_2^Ibaseos^Ix86_64$
su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-b^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-c^I1.0-1^I2.0-1^Isu-dummy^Ix86_64$

Epoch example (alma9): vim-minimal 2:8.2.2637-26.el9_8.21 2:8.2.2637-26.el9_8.23 baseos x86_64.

Excluded / locked packages: both are removed from the sack, so they simply do not appear in check-update/repoquery (with --exclude=a,b: only su-dummy-c remains). To see them anyway: dnf4 --disableexcludes=all; dnf5 has no such option (Unknown argument "--disableexcludes=all" for command "repoquery", also no --disable-excludes/--no-excludes). Versionlock: dnf4 python3-dnf-plugin-versionlock (/etc/dnf/plugins/versionlock.list, line su-dummy-a-0:1.0-1.*), dnf5 dnf5-plugins (/etc/dnf/versionlock.toml); after versionlock add su-dummy-a, check-update lists only b and c on both.

Blind spot - obsoletes: a package that obsoletes an installed one (su-dummy-new obsoleting su-dummy-old) is listed only by dnf4 check-update (in the trailing section, which the parser drops) and by no repoquery --upgrades/dnf5 listing, but dnf upgrade will install it and remove the old one:

$ dnf --assumeno upgrade      # dnf4
Installing:
 su-dummy-new         noarch  1.0-1  su-dummy  6.5 k
     replacing  su-dummy-old.noarch 1.0-1

=> use the plan preview of section 7 (su_plan) when the user must see everything that will change.

2.2 ZYPPER: candidates compared

zypper -n --xmlout list-updates zypper -q -n list-updates (table) ... lu --all
exit 0 (also with updates; there is no 100) 0 0
noise <message> tags (Loading repository data..., Leap: Refreshing service &apos;openSUSE&apos;.) -q removes all non-table lines
fields name, edition (new), edition-old, arch, summary, description, <source alias=...> S | Repository | Name | Current Version | Available Version | Arch (repo = display name, e.g. openSUSE-Tumbleweed-Update) same table, locked rows get status vl
locked packages hidden hidden shown with status vl
vendor-change candidates hidden hidden shown (v)
when nothing empty <update-list> prints nothing (even no header)
$ zypper -q -n list-updates                                       # tumbleweed
S  | Repository                 | Name               | Current Version | Available Version | Arch
---+----------------------------+--------------------+-----------------+-------------------+-------
v  | openSUSE-Tumbleweed-Update | openSUSE-build-key | 1.0-68.1        | 1.0-69.1          | x86_64
v  | su-dummy                   | su-dummy-a         | 1.0-1           | 2.0-1             | noarch
v  | su-dummy                   | su-dummy-b         | 1.0-1           | 2.0-1             | noarch
v  | su-dummy                   | su-dummy-c         | 1.0-1           | 2.0-1             | x86_64

$ zypper -n --xmlout list-updates          # one <update> element per package; attributes + summary + description (candidate!) + source
<update-list>
<update kind="package" name="su-dummy-a" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-a (v2.0)</summary><description>Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
...</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" ...

Note the XML puts all <update> elements on one physical line and descriptions span several lines, xmllint does not exist on SUSE images, python3 neither. Parse with tr+sed+bash (proven on Tumbleweed and Leap):

_su_xattr() {   # _su_xattr '<tag text>' attr -> $REPLY (XML-unescaped); rc 1 if absent. ' edition="' never matches ' edition-old="'
  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
}
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"
  # tr leaves no trailing newline and GNU sed keeps it that way: without `|| [ -n "$line" ]` the LAST package is silently dropped
  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
}

Output (tumbleweed): openSUSE-build-key^I1.0-68.1^I1.0-69.1^Irepo-update^Ix86_64$, su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$ ... The repo column is the stable alias (repo-update, Leap: openSUSE:repo-oss).

Table fallback (also proven; repo = display name, may contain spaces, e.g. repo-oss (16.0)):

trim() { local v=$1; v=${v#"${v%%[![:space:]]*}"}; v=${v%"${v##*[![:space:]]}"}; REPLY=$v; }
zypper -q -n list-updates | while IFS='|' read -r s repo name cur avail arch; do
  case $s in v*) ;; *) continue ;; esac            # data rows only (header/separator rows do not start with "v")
  trim "$repo"; repo=$REPLY; trim "$name"; name=$REPLY; trim "$cur"; cur=$REPLY; trim "$avail"; avail=$REPLY; trim "$arch"; arch=$REPLY
  printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$cur" "$avail" "$repo" "$arch"
done

zypper already gives plain names (arch is a separate attribute/column).

Locks (see section 6): after zypper addlock su-dummy-a su-dummy-b, lu shows only su-dummy-c; lu --all shows

vl | su-dummy                   | su-dummy-a         | 1.0-1           | 2.0-1             | noarch
vl | su-dummy                   | su-dummy-b         | 1.0-1           | 2.0-1             | noarch
v  | su-dummy                   | su-dummy-c         | 1.0-1           | 2.0-1             | x86_64

Vendor change: package whose candidate has another vendor (Vendor: FooCorp installed, BarCorp in repo) is hidden by lu, shown by lu --all, and zypper up prints There is an update candidate for 'su-dummy-v' from vendor 'BarCorp', while the current vendor is 'FooCorp'. Use 'zypper install su-dummy-v-2.0-1.noarch' to install this candidate. followed by Nothing to do. (the update is skipped).


3. One-line description and full info dump

Fixture packages carry Summary: skeptical-updater dummy su-dummy-a (v1.0|v2.0) and a two-paragraph description (blank line between paragraphs) so installed vs candidate and paragraph handling are distinguishable.

3.1 Installed version (all backends): rpm

$ rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' su-dummy-a
skeptical-updater dummy su-dummy-a (v1.0)
Dummy package su-dummy-a version 1.0 for skeptical-updater tests.

Second paragraph of su-dummy-a 1.0 (blank line above on purpose).
$ rpm -qi su-dummy-a        # Name/Version/Release/Architecture/Install Date/Group/Size/License/Signature/Source RPM/Build Date/Build Host/Summary/Description/Distribution

3.2 Candidate (new) version

DNF: dnf info prints both an Installed Packages and an Available Packages block (dnf4 Description : first line + : continuation lines; dnf5 info adds Epoch, From repository : <unknown>, Vendor). dnf info --upgrades PKG (dnf4 and dnf5) prints only the candidate block (Available Upgrades / Available upgrades), as does dnf repoquery -i --upgrades PKG. For machine use take repoquery with a queryformat (same on dnf4/dnf5):

dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' su-dummy-a
# -> line 1 summary, then the description with paragraph breaks intact; empty output => no candidate => fall back to rpm -q

dnf4 appends one extra blank line (harmless; $(...) strips it). Verified output (both dnf4 and dnf5): skeptical-updater dummy su-dummy-a (v2.0) / Dummy package su-dummy-a version 2.0 ... / blank / Second paragraph of su-dummy-a 2.0 .... Caveat: dnf4 repoquery --upgrades does not honour versionlock, so the "candidate" may be one the lock blocks; for the summary only this is irrelevant.

ZYPPER: zypper info PKG already shows the best candidate (and marks the installed state), falling back to the installed package when no repo has it:

$ zypper -q -n info su-dummy-a
Information for package su-dummy-a:
-----------------------------------
Repository     : su-dummy
Name           : su-dummy-a
Version        : 2.0-1
Arch           : noarch
Vendor         :
Installed Size : 15 B
Installed      : Yes
Status         : out-of-date (version 1.0-1 installed)
Source package : su-dummy-a-2.0-1.src
Summary        : skeptical-updater dummy su-dummy-a (v2.0)
Description    :
    Dummy package su-dummy-a version 2.0 for skeptical-updater tests.

    Second paragraph of su-dummy-a 2.0 (blank line above on purpose).

zypper info nonexistentpkg -> package 'nonexistentpkg' not found. exit=104. --xmlout info is useless (one big <message> blob). -q does not remove the Information for package banner. Description lines are indented 4 spaces; paragraph breaks are real empty lines and the block also ends with an empty line (and a second empty line before the next Information for package banner when several names are given), so a plain /^Description/,/^$/ sed range truncates after the first paragraph. State machine (proven):

zypper --non-interactive --quiet info "$pkg" 2>/dev/null | {
  indesc=0; pend=
  while IFS= read -r l; do
    if [ $indesc = 1 ]; then
      case $l in '    '*) printf '%s%s\n' "$pend" "${l#    }"; pend= ;;   # text line: flush held blank lines first
                 '')      pend+=$'\n' ;;                                  # blank: paragraph break OR end of block - decide on next line
                 *)       break ;; esac                                   # anything else = next block
    else
      case $l in Summary*:*) [ -z "${sum:-}" ] && { sum=${l#*: }; printf '%s\n' "$sum"; } ;; Description*:*) indesc=1 ;; esac
    fi
  done; }
# if zypper info exits non-zero (104) fall back to: rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg"

The XML of list-updates/update --dry-run (sections 2.2 and 7) also carries the candidate summary+description for upgradable packages.

su_describe PKG in lib-rpm.sh wraps all of this; proof.sh asserts candidate summary skeptical-updater dummy su-dummy-a (v2.0), the preserved blank line, and installed-only su-dummy-d (v1.0) on all 7 images.


4. Installed reverse dependencies

Candidates and what they really do:

command result on EL9 for bash verdict
rpm -q --whatrequires bash 1 package (ca-certificates) - matches only a Requires entry literally named bash misses file deps (/bin/sh, /usr/bin/bash), sonames, virtual provides
dnf repoquery --installed --whatrequires bash 52 (dnf4 default = --alldeps: all provides and files; --exactdeps drops to 1) correct, dnf only; works on dnf4 and dnf5 (fedora: 42)
zypper search --installed-only -t package --requires bash = naive (name only); --requires-pkg bash uses all provides but over-reports on Tumbleweed (43 hits vs 4 direct: includes packages that depend via /bin/sh -> bash-sh -> bash) not the reference
pure rpm su_rdeps (below) EL9 52, EL10 40, Fedora 42, Tumbleweed 4 direct (69 recursive), Leap 5 direct (67 recursive) chosen; identical to the dnf answer wherever dnf can answer

Why name != capability: on Tumbleweed /bin/sh is provided by bash-sh, not bash (rpm -q --whatprovides /bin/sh -> bash-sh), so aaa_base, binutils, ... are direct dependents of bash-sh and only transitive dependents of bash; su_rdeps_recursive bash (69) is a superset of zypper's 43 (comm -23 empty).

Algorithm: (1) the set of names the package is known by = [%{PROVIDENAME}\n] + rpm -ql (owned files, so file deps like /bin/sh match); (2) one table of every installed package's requirements capability<TAB>package from rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' with rpmlib(...) dropped and rich deps expanded; (3) join the two on the capability, drop the package itself. Mandatory trick %{=NAME} (a bare %{NAME} inside [...] makes rpm print error: incorrect format: array iterator used with different sized arrays for roughly half of the packages; reproduced on rpm 4.16.1.3, 4.19.1.1, 4.20.1, 6.0.2 - and the same error appears for [%{PROVIDENAME}\t%{NAME}\n]).

su_req_table() {   # "capability<TAB>requiring-package", sorted; rich deps expanded via su_cap_names (section 5)
  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() {       # direct installed dependents of PKG, plain names, sorted
  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... (also in the lib) loops su_rdeps with one shared table. Proof assertions: su_rdeps su-dummy-b = su-dummy-a su-dummy-d; su_rdeps su-dummy-c = su-dummy-d (c is only required through the rich dep (su-dummy-c if su-dummy-b)); su_rdeps su-dummy-d = empty; su_rdeps(bash) equals dnf repoquery --installed --whatrequires bash (EL9: 52 = 52, EL10: 40 = 40, Fedora/dnf5: 42 = 42). Cost: ~60-130 ms on these minimal images.

Plain names: %{NAME} has no arch/version; multilib duplicates collapse through sort -u. dnf repoquery ... --queryformat $'%{name}\n' | sed '/^$/d' is the dnf-native equivalent (dnf4 blank lines).


5. Recursive dependency closure restricted to installed packages

  • dnf repoquery --installed --requires --resolve --recursive --queryformat $'%{name}\n' PKG: works on dnf4 (alma9/alma10); dnf5 rejects it: Unknown argument "--resolve" for command "repoquery". ... The argument is available for commands: download (rc=2). Also dnf-only.
  • Chosen: pure-rpm breadth-first search, identical on dnf and zypper distros. Per BFS level: rpm -q --qf '[%{REQUIRENAME}\n]' <frontier> -> filter -> rpm -q --whatprovides --qf '%{NAME}\n' <caps>. Input handling (all verified):
dependency kind example handling
rpmlib(...) rpmlib(CompressedFileNames) dropped (not a package; --whatprovides would just say "no package provides")
file deps /bin/sh, /usr/bin/perl looked up with rpm -q --whatprovides /bin/sh (file owner or explicit Provides: bash on EL, bash-sh on SUSE)
soname / virtual libc.so.6(GLIBC_2.34)(64bit), perl(Foo::Bar), config(bash) kept verbatim (REQUIRENAME already carries no version); contain parentheses but do not start with (
versioned su-dummy-b >= 1.0 %{REQUIRENAME} returns only su-dummy-b; the constraint is ignored (installed state is already consistent)
rich / boolean (su-dummy-c if su-dummy-b), (glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config), (account-utils or shadow-pw-mgmt = 4.20.3), (annobin if (gcc or clang)) su_cap_names tokenises with paren-depth tracking: members kept, comparison operators and the version token after them dropped, and/or/with/without ignored, the condition after if/unless dropped (until else or the closing paren). Conditions are not evaluated -> safe superset
unresolvable nothing installed provides it no package provides X lines are filtered out (rpm prints them on stdout, exit 1)

su_cap_names unit results (proven, EL9):

rpmlib(CompressedFileNames)                    =>
/bin/sh                                        => /bin/sh
libc.so.6(GLIBC_2.34)(64bit)                   => libc.so.6(GLIBC_2.34)(64bit)
(su-dummy-c if su-dummy-b)                     => su-dummy-c
(glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config) => glibc-gconv-extra(x86-64)
(foo >= 1.0-2 or (bar and baz))                => foo bar baz
((a or b) if c)                                => a b
(a if b else c)                                => a c
(a unless (b and c) else d)                    => a d
(libx.so.1()(64bit) if foo)                    => libx.so.1()(64bit)
(annobin if (gcc or clang))                    => annobin
(account-utils or shadow-pw-mgmt = 4.20.3)     => account-utils shadow-pw-mgmt

Why the condition must be dropped: with the naive "all tokens" version, glibc on EL9 (rich dep (glibc-gconv-extra ... if redhat-rpm-config)) appeared to depend on redhat-rpm-config, which dragged rpm, rpm-build, binutils, gdb-minimal and the perl modules into the closure of filesystem (167 packages instead of ~15).

su_closure() {   # su_closure PKG... -> installed packages PKG... need transitively (plain names, sorted, roots excluded)
  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
}

Proof (proof.sh, every image): su_closure su-dummy-a = su-dummy-b; su_closure su-dummy-d contains su-dummy-a su-dummy-b su-dummy-c glibc and the provider of /bin/sh (bash on EL, bash-sh on SUSE), no rpmlib, not the root. On dnf4 the result equals dnf repoquery --installed --requires --resolve --recursive: alma9 18 = 18, alma10 17 = 17 (dnf lists bash in its own closure because of the filesystem <-> bash cycle; ours excludes roots by design). Sample (alma10): almalinux-gpg-keys almalinux-release almalinux-repos basesystem bash filesystem glibc glibc-common glibc-gconv-extra glibc-minimal-langpack libgcc ncurses-base ncurses-libs setup su-dummy-a su-dummy-b su-dummy-c tzdata; Tumbleweed closure(su-dummy-d) = bash bash-sh filesystem glibc libncurses6 libreadline8 su-dummy-a su-dummy-b su-dummy-c system-user-root terminfo-base. Timing: 70-130 ms.


6. Per-run exclusion

6.1 DNF (--exclude, verified on dnf4 4.14/4.20 and dnf5 5.4.3)

form dnf4 dnf5
dnf -y upgrade --exclude=su-dummy-a,su-dummy-b (comma list) ok ok (documented as -x package,..., --exclude=package,..., global option)
--exclude=a --exclude=b (repeated) ok ok
-x a -x b ok ok
globs --exclude='su-dummy-[ab]' ok ok
space separated in one value --exclude='a b' ok (repoquery) ok (repoquery)
works on check-update, repoquery, upgrade yes yes
--disableexcludes=all to see them again yes not available

Result of dnf -y upgrade --exclude=su-dummy-a,su-dummy-b 'su-dummy-*' (dnf4 and dnf5): su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0 (both syntaxes). Excluded packages disappear from check-update too (only su-dummy-c left).

Edge cases (verified):

  • all packages matched by an explicit pattern are excluded: dnf4 No match for argument: su-dummy-* / Error: No packages marked for upgrade. exit 1; dnf5 Argument 'su-dummy-*' matches only excluded packages. exit 1. With no package arguments and nothing to do both print Nothing to do. exit 0.
  • an excluded dependency blocks the upgrade of its dependant (su-dummy-p 2.0 Requires: su-dummy-q >= 2.0, --exclude=su-dummy-q):
    • dnf4 (EL default best = 1, skip_if_unavailable = 0 in the dnf4 config dump): Error: Problem: package su-dummy-p-2.0-1.noarch from su-dummy requires su-dummy-q >= 2.0, but none of the providers can be installed ... package su-dummy-q-2.0-1.noarch from su-dummy is filtered out by exclude filtering exit 1; --skip-broken does not help (same error); --nobest -> Skip 1 Package ... Nothing to do. exit 0.
    • dnf5: Skipping packages with broken dependencies: su-dummy-p noarch 2.0-1 su-dummy then Nothing to do. exit 0 (no flag needed; --skip-broken is rejected on upgrade: Unknown argument "--skip-broken" for command "upgrade"). => exit codes cannot tell "everything upgraded" from "some skipped because of the exclusion": re-run su_upgradable afterwards (the proof does) and report what is left.

6.2 ZYPPER (no --exclude)

Findings that drive the recipe:

  • zypper update/dup have no exclude option. Locks (zypper addlock NAME..., glob via */?) are honoured by the solver: a full zypper update (no package args) silently skips locked packages; a lock on a,b -> only c is upgraded.
  • An explicit zypper update LOCKED or an update whose dependency is locked ends in an interactive "Problem" which --non-interactive answers with cancel: exit 4:
    Problem: 1: Detailed information: - conflicting requests
     Solution 1: Following actions will be done:  remove lock to allow installation of su-dummy-a-2.0-1.noarch[su-dummy] ...
     Solution 2: do not ask to install a solvable providing su-dummy-a.noarch = 2.0-1
    Choose from above solutions by number or skip, retry or cancel [1/2/s/r/c/d/?] (c): c      exit=4
    
    while a full update with a locked dependency of a newer package just skips both: The following 2 package updates will NOT be installed: su-dummy-p su-dummy-q (exit 0), p/q stay 1.0.
  • Naming the packages explicitly instead (zypper update a b c) is not an exclusion mechanism: zypper update su-dummy-p (p 2.0 needs q >= 2.0) also upgraded su-dummy-q (dependency pull) - verified.
  • Lock file /etc/zypp/locks (path = locksfile.path in zypp.conf; created on first addlock; on Tumbleweed /etc/zypp/zypp.conf does not exist at all, only /usr/etc/zypp/zypp.conf):
    $ cat -A /etc/zypp/locks
    $
    type: package$
    match_type: glob$
    case_sensitive: on$
    solvable_name: su-dummy-a$
    $
    
    zypper locks table: # | Name | Type | Repository | Comment; zypper -q ll prints the same.
  • zypper removelock NAME is not a safe undo: it deletes every lock that matches NAME (a pre-existing user lock of the same name is destroyed - verified), and it leaves a 0-byte /etc/zypp/locks behind even when no file existed before (verified: -rw-r--r-- 1 root root 0).

Recipe A (chosen): private zypp.conf, nothing persisted - su_zypper_excluding

libzypp reads $ZYPP_CONF instead of /etc/zypp/zypp.conf. Copy the current config (fall back to /usr/etc/zypp/zypp.conf on Tumbleweed), append [main] locksfile.path = <tmp>/locks, seed <tmp>/locks with a copy of the real locks, addlock the exclusions there, run, rm -rf the temp dir. The real lock set is never opened for writing, so there is nothing to restore (Ctrl-C safe, concurrent-run safe).

su_zypper_excluding() {   # su_zypper_excluding NAME... -- zypper-args...   e.g.  ... a b -- update   |   ... a -- --xmlout dup --dry-run
  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
  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"       # second [main] block: last assignment wins (verified)
  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
}

Proof (Tumbleweed dup and Leap update, proof.sh section 6): su_upgrade --exclude su-dummy-a --exclude su-dummy-b -> exit 0, su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0; /etc/zypp/locks unchanged (absent before, absent after); no leftover /tmp/tmp.*; su_upgradable still lists a and b afterwards; the following unrestricted su_upgrade upgrades them (a=2.0 b=2.0 c=2.0).

Recipe B (alternative with exact restore of the real locks file) - su_zypper_excluding_snapshot

Use this only if the real locks must be the ones libzypp sees (e.g. another tool reads /etc/zypp/locks concurrently - note it is then visible to that tool for the duration).

su_zypper_excluding_snapshot() (          # subshell => traps are local
  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          # snapshot: bytes + mode + mtime, or "absent"
  _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 "$@"
)

Proof (proof.sh section 10, Tumbleweed; Leap likewise): with a pre-existing user lock on su-dummy-d (comment "user lock"): cksum+mtime identical after a run (834034310 97 1791570407 before and after); user lock still present, temporary locks gone; a user lock on the same name as an exclusion survives (zypper removelock would have deleted it - asserted); file absent before => absent after; run killed with SIGTERM mid-way (shadowed zypper that kills its parent subshell) -> exit 143 and locks restored byte-identical.

Recipe C (rejected): explicit package list

zypper update <list of everything except X>: dependencies pull X anyway (shown above) and any name that is already newest prints No update candidate for .... Rejected.


7. Non-interactive upgrade, exit codes, plan preview

7.1 DNF

  • dnf -y upgrade [--exclude=a,b] [--setopt=skip_if_unavailable=0]
  • Exit codes (4 and 5): 0 success and "Nothing to do." (also Dependencies resolved. Nothing to do. Complete! on dnf4); 1 any error (unknown package argument No match for argument: X + Error: No packages marked for upgrade., dependency problem, dead repo with skip_if_unavailable=0); 100 exists only for check-update; --assumeno (dry run) ends with exit 1 (Operation aborted. / Operation aborted by the user.) after printing the transaction table.
  • -q removes the progress chatter but dnf5 still prints Nothing to do.; dnf5 prints Warning: skipped OpenPGP checks for 3 packages from repository: su-dummy for gpgcheck=0 repos.
  • On the stale rockylinux:9 (9.3) the first -y transaction imports the Rocky 9 GPG key automatically (Importing GPG key 0x350D275D: ... From : /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9) - harmless with -y, but a non--y path would prompt.
  • There is no machine-readable transaction output in dnf4. dnf5 check-upgrade --json exists (dnf5 only), not used.

7.2 ZYPPER

Detection (verified): /etc/os-release ID=opensuse-tumbleweed (Tumbleweed) -> dup; ID=opensuse-leap (Leap 16.0) -> update; ID_LIKE is opensuse suse / suse opensuse on both, so test ID, not ID_LIKE. (opensuse-slowroll is rolling too and treated like Tumbleweed in the lib - not tested; MicroOS/Aeon/Leap Micro are transactional and out of scope.)

su_zypper_verb() { case "$(su_os_id)" in opensuse-tumbleweed|opensuse-slowroll) echo dup ;; *) echo update ;; esac; }
zypper --non-interactive dup        # prints: Warning: You are about to do a distribution upgrade with all enabled repositories. ...  Computing distribution upgrade...
zypper --non-interactive update

Observed exit codes:

situation exit
success / "Nothing to do." (update and dup; No update candidate for 'su-dummy-a-2.0-1.noarch'. The highest available version is already installed. for explicit names) 0
list-updates with or without updates; patch-check with 0 patches 0
repo cannot be refreshed (Could not refresh the repositories because of errors.) 4
unknown repository name 3
unresolved Problem answered by --non-interactive (default = cancel): explicit update of a locked package; dup with a vendor/"distupgrade repository" problem (the installed su-dummy-v-1.0-1.noarch does not belong to a distupgrade repository and must be replaced / Solution 1 install from vendor BarCorp / Solution 2 keep obsolete) 4
info/remove of a name nothing provides (No provider of 'nonexistent-xx' found.) 104
update --dry-run / dup --dry-run (even with Continue? [y/n/...] (y): y echoed) 0 (dry run, nothing changed)
update of a package listed in /etc/zypp/needreboot(.d) 0, plus the text Since the last system boot core libraries or services have been updated. Reboot is suggested to ensure that your system benefits from these updates.

Not reproduced here (documented zypper(8) semantics, no fixture can trigger them without real SUSE patches): 100/101 (patch-check: updates / security updates needed), 102 (reboot needed after a patch with the reboot flag), 103 (zypper itself/libzypp was updated, restart zypper), 106 (some repos skipped), 107 (an rpm scriptlet failed). The tool should treat any non-zero exit as failure to be shown, 0 as success, and treat 100-103 as "success with advice" if it ever sees them.

dup vs update on Tumbleweed: in this snapshot update --dry-run and dup --dry-run computed the same plan (4 upgrades openSUSE-build-key su-dummy-a su-dummy-b su-dummy-c, 11 new packages krb5 libbrotlicommon1 ... libverto1, 1 removal libcurl-mini4 - i.e. a package swap that list-updates never shows). The documented difference shows with vendor changes: list-updates hides the candidate, update skips it with a message, dup raises the Problem above (exit 4 under -n) - so Tumbleweed must use dup, and a skeptical tool must expect exit 4 there and show the Problem text (re-run without -n... or --dry-run text) instead of retrying.

7.3 Plan preview (what would REALLY change: new deps, removals, obsoletes)

list-updates/check-update never show newly installed dependencies, removals or obsolete-replacements. su_plan [--exclude N]... (in lib-rpm.sh) prints section<TAB>name<TAB>old<TAB>new<TAB>repo<TAB>arch<TAB>replaces, honouring exclusions:

  • zypper: zypper --non-interactive --xmlout dup|update --dry-run yields exact data (no table parsing):
    <install-summary download-size="19047" space-usage-diff="0" ... packages-to-change="3" need-restart="0" need-reboot="0">
    <to-upgrade>
    <solvable type="package" name="su-dummy-a" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="..."><description>...</description></solvable>
    ...</to-upgrade><to-install>...</to-install><to-remove>...</to-remove></install-summary>
    
    (the <prompt> and <progress> elements that follow are ignored; need-reboot/need-restart attributes are available).
  • dnf: dnf --assumeno upgrade table parse - section headers are col-0 lines ending in : (Upgrading:, Installing:, Installing dependencies:, Installing weak dependencies:, Removing:, Removing unused dependencies:, Skipping packages with broken dependencies: ...), rows start with one space, parsing stops at Transaction Summary. dnf4 prints no replacing line for plain upgrades (old version then comes from rpm -q), dnf5 prints replacing rows with 0:-prefixed epochs.

Proof section 9 (builds su-dummy-new with Obsoletes: su-dummy-old < 2 + Provides: su-dummy-old = 2): with --exclude su-dummy-a:

zypper (tumbleweed)                                     dnf4 (alma9)                                           dnf5 (fedora)
upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch  -       install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old   upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch -
upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64  -       upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch -            upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 -
install su-dummy-new -   1.0-1 su-dummy noarch  -       upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 -            install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old
remove  su-dummy-old -   1.0-1 @System noarch   -

and su_upgradable shows 0 rows for su-dummy-new on all of them (the blind spot).


8. Remove / purge

DNF (dnf4 and dnf5 identical in effect)

  • clean_requirements_on_remove default 1 on both (dnf4: dnf config-manager --dump -> clean_requirements_on_remove = 1; dnf5 behaves the same: its plan shows Removing unused dependencies: su-dummy-s noarch 0:1.0-1 su-dummy).
    $ dnf -y install su-dummy-r         # r Requires s; both installed by dnf
    $ dnf -y remove su-dummy-r          # dnf4: "Erasing: su-dummy-r-1.0-1.noarch ... Erasing: su-dummy-s-1.0-1.noarch"   exit=0   (both removed)
    $ dnf -y remove --setopt=clean_requirements_on_remove=0 su-dummy-r   # only r removed; s stays
    $ dnf -y autoremove                 # then removes the leftover s
    
  • Only dependencies installed by dnf as dependencies are cleaned. su-dummy-b (installed by plain rpm -Uvh) stays after dnf -y remove su-dummy-a on both majors.
  • dnf remove nonexistent-xx exits 0 on dnf4 (Nothing to do.) and dnf5 (No packages to remove for argument: nonexistent-xx / Nothing to do.) -> confirm with rpm -q afterwards.
  • dnf4 refuses protected packages: dnf -y remove dnf -> Error: Problem: The operation would result in broken dependencies for the following protected packages: yum ... exit 1.
  • There is no apt purge equivalent in the rpm world; %config handling on removal (*.rpmsave) was not exercised here.

ZYPPER

  • zypper --non-interactive remove --clean-deps PKG (-u). Plain remove leaves the dependency: after zypper -n install su-dummy-r (pulls su-dummy-s), zypper -n remove su-dummy-r -> only r removed, rpm -q su-dummy-s still present; with --clean-deps -> 2 packages to remove. (1/2) Removing: su-dummy-r ... (2/2) Removing: su-dummy-s, exit 0.
  • Which deps are "unneeded" is tracked by zypper in /var/lib/zypp/AutoInstalled; packages installed with plain rpm (the fixture's su-dummy-b) are never cleaned: remove --clean-deps su-dummy-a left su-dummy-b installed.
  • Unknown name: 'nonexistent-xx' not found in package names. Trying capabilities. ... No provider of 'nonexistent-xx' found. / Nothing to do. exit 104.
  • proof.sh section 7 asserts all of the above through su_remove on every image.

9. Base image facts

See section 0.4 for the table (bash versions, sudo/su/python3/awk/find/xmllint presence), bats (EPEL on EL9/EL10; main repos on openSUSE) and rpm-build/createrepo_c availability. Short version: bash is present everywhere (5.1-5.3), sudo nowhere, python3 only on EL, awk/find/xargs missing on Tumbleweed, find/xargs missing on Leap, su missing on alma10; bats needs EPEL on Alma/Rocky; rpm-build and createrepo_c install from the base repos everywhere.


10. E2E fixture - discovery/rpm/fixture.sh

docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh        # SU_FIXTURE_EXTRAS=1 for the extras

What it does (single script, detects zypper first, then dnf/yum; refuses to run as non-root):

  1. installs rpm-build + createrepo_c (dnf: dnf -y -q install --setopt=install_weak_deps=False ...; zypper: zypper --non-interactive --quiet install --no-recommends ...) - no EPEL/extra repo needed anywhere;
  2. generates minimal specs and builds with rpmbuild -bb --define "_topdir /var/tmp/su-fixture": su-dummy-a (noarch, Requires: su-dummy-b), su-dummy-b (noarch), su-dummy-c (arch-specific, %global debug_package %{nil}, so the .x86_64 suffix path is exercised), versions 1.0-1 and 2.0-1, one file each, Summary: skeptical-updater dummy <name> (v<ver>), two-paragraph %description;
  3. installs the three 1.0 packages with rpm -Uvh --oldpackage --replacepkgs (distro-agnostic; re-running the script resets to the 1.0 state);
  4. publishes only the 2.0 packages in /var/lib/su-dummy-repo and runs createrepo_c;
  5. registers the repo: dnf /etc/yum.repos.d/su-dummy.repo (baseurl=file:///var/lib/su-dummy-repo, gpgcheck=0, repo_gpgcheck=0, metadata_expire=0) + dnf -q makecache --repo su-dummy; zypper zypper addrepo --no-gpgcheck --no-check --refresh file:///var/lib/su-dummy-repo su-dummy + zypper refresh su-dummy;
  6. verifies that check-update / list-updates lists su-dummy-a, -b, -c, else exits 1. SU_FIXTURE_EXTRAS=1 additionally installs su-dummy-d 1.0 (not upgradable; Requires: su-dummy-a, /bin/sh, su-dummy-b >= 1.0, (su-dummy-c if su-dummy-b), rpmlib(CompressedFileNames)) for rdeps/closure tests and publishes (not installs) su-dummy-r (Requires su-dummy-s) / su-dummy-s for remove tests.

Side effect worth knowing: installing rpm-build pulls redhat-rpm-config etc. on EL, which flips the condition of rich dependencies such as glibc's (glibc-gconv-extra if redhat-rpm-config); the closure code must therefore not treat conditions as dependencies (it does not).

Real output (fixture.sh standalone, each run exit 0):

[fixture] backend=dnf                       # almalinux:9
[fixture] build tools installed in 14s
[fixture] rpmbuild done in 0s
[fixture] repo refresh in 0s
[fixture] OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total 15s, since start 15s)

and the same OK line (totals 8-16 s) on almalinux:10, rockylinux:9, rockylinux/rockylinux:10, quay.io/rockylinux/rockylinux:10, opensuse/tumbleweed (backend=zypper, 13 s), opensuse/leap (backend=zypper, 14 s), and fedora:latest (dnf5, 16 s). Also verified: a second run on the same container is a clean reset (proof.sh section 8).

proof.sh results (final run, all rc=0): almalinux:9 44 PASS, almalinux:10 44, rockylinux:9 44, rockylinux/rockylinux:10 44, opensuse/tumbleweed 53, opensuse/leap 53, fedora:latest 43 (dnf5). (zypper images run two extra sections: lock snapshot/restore.) One transient failure was seen and is not a code issue: the first rockylinux:9 run failed section 1 (su_refresh exit 1: Rocky extras mirror returned a broken repomd.xml); re-run passed (the refresh gate did exactly what it is for).


11. Rough timings (host with fast network, Docker 29.8.2; wall clock, one run each, sequential)

image docker pull cold metadata refresh (first dnf makecache / zypper refresh in the container) warm refresh fixture.sh wall (of which tool install) proof.sh wall
almalinux:9 4.7 s 9.5 s 1.6 s 15.1 s (14 s) 29.5 s
almalinux:10 4.5 s 5.9 s 1.9 s 11.5 s (9 s) 25.5 s
rockylinux:9 (9.3 lib image, 96 pending upgrades) 6.1 s 8.4 s 1.1 s 16.6 s (15 s) 29.8 s
rockylinux/rockylinux:10 5.9 s 3.3 s 1.6 s 10.5 s (8 s) 21.0 s
quay.io/rockylinux/rockylinux:10 1.0 s (layers cached) 4.2 s - 8.1 s (7 s) not run (same image)
opensuse/tumbleweed 3.4 s 5.9 s 0.9 s 14.3 s (12 s) 32.7 s
opensuse/leap (16.0) 3.5 s 6.3 s 0.6 s 14.4 s (12 s) 34.3 s
fedora:latest (dnf5, reference) n/a 5.3 s 0.3 s 16.0 s (15 s) 29.2 s

Inside proof.sh (warm containers): forced refresh (su_refresh) 0.5-2.6 s (up to 7 s on alma when the mirror is slow), su_upgradable 0.3-0.8 s, su_closure 0.07-0.13 s, su_rdeps ~0.06-0.13 s, dummy upgrade 0.1-0.3 s (dnf) / 1.6-7 s (zypper, because a full update/dup also upgrades real packages). "Warm refresh" for dnf is a plain dnf makecache without --refresh (it still revalidates); --refresh forces a full download and is the number to budget for a skeptical run (3-9 s cold).


12. Gotcha checklist for the implementer

  1. Never assume dnf5 on EL10; support dnf4 + dnf5 through one parser (check-update, 3 fields, tolerate blank line, Upgrades header, Obsoleting Packages section).
  2. Always --setopt=skip_if_unavailable=0 (dnf5 otherwise hides dead repos, -q hides the warning) and gate on the refresh exit status; do not trust check-update alone on dnf 4.14.
  3. dnf repoquery --queryformat: pass real TAB/newline ($'%{name}\t%{evr}\n'), then sed '/^$/d' (dnf4 adds blank lines, dnf5 expands only \n, \t stays literal). --qf and --queryformat both work on both majors.
  4. dnf4 repoquery --upgrades and dnf list --upgrades ignore versionlock; check-update does not. dnf5 has no --disableexcludes.
  5. check-update/list-updates hide obsoleters, new dependencies and removals. Use su_plan when the user must see the real transaction; compare list vs result afterwards because exit codes do not reveal skipped packages (dnf5 skips broken-dependency packages with exit 0; dnf4 fails with exit 1 unless --nobest).
  6. rpm --qf '[%{=NAME}...]', never %{NAME} inside [...].
  7. rpm -q --whatrequires pkg is name-only; use provides + owned files (su_rdeps). Rich deps: members only, conditions dropped.
  8. zypper XML: all <update> elements are on one line, descriptions multi-line, no xmllint/python on the images, trailing-newline trap with tr+sed+read.
  9. zypper list-updates exits 0 even with updates; never look for 100. Locked and vendor-change candidates are hidden (lu --all shows them).
  10. zypper has no --exclude: use ZYPP_CONF private locks (or snapshot/restore of the real locks file; removelock is not an undo). Tumbleweed has no /etc/zypp/zypp.conf, only /usr/etc/zypp/zypp.conf.
  11. Tumbleweed -> dup, Leap -> update, decided from ID= in /etc/os-release; dup/update under --non-interactive turn unresolved Problems into exit 4 (cancel) - show the Problem, do not blindly retry.
  12. Base images lack awk, find, xargs, python3, xmllint, sudo, hostname, diff; rockylinux:9 is a stale 9.3 image (use rockylinux/rockylinux:9); rockylinux:10 does not exist.
  13. dnf remove of a non-existent package exits 0; zypper exits 104. Verify with rpm -q.
  14. Packages installed with plain rpm -i are never auto-removed by dnf remove / zypper remove --clean-deps.

13. Not verified / limits

  • dnf5 was verified on fedora:latest (Fedora 44, dnf5 5.4.3.0 - an image outside the requested list), not on an EL10 userland (no dnf5 package in Alma 10; Rocky 10 has dnf5 5.1.12 only in the disabled devel repo and was not exercised).
  • zypper exit codes 100-103, 106, 107 were not reproduced (see 7.2). opensuse-slowroll, MicroOS/Aeon (transactional), SLES were not tested.
  • Multi-arch (x86_64 + i686) and installonly kernels were not exercised; the parser keys on name.arch and keeps the highest installed evr per key for the "old" column.
  • Docker images pulled during this work (all kept, none removed or re-tagged): almalinux:9, almalinux:10, rockylinux:9, rockylinux/rockylinux:9, rockylinux/rockylinux:10, quay.io/rockylinux/rockylinux:9, quay.io/rockylinux/rockylinux:10, opensuse/tumbleweed:latest, opensuse/leap:latest, fedora:latest.

14. Install reason (N? / info)

Verified 2026-10-10, fixture SU_FIXTURE_ISOLATE=1 SU_FIXTURE_ORPHAN=1, LC_ALL=C.

14.1 DNF: dnf -q repoquery --installed --queryformat '%{reason}\n' PKG

Same answers on almalinux:9 and rockylinux/rockylinux:9 (dnf 4.14.0), almalinux:10 and rockylinux/rockylinux:10 (dnf 4.20.0); fedora:latest (Fedora 44, dnf5 5.4.3.0) uses other words:

state dnf4 dnf5
installed with plain rpm -Uvh (fixture's a, b, c) unknown External User
su-dummy-orphan (pulled in by dnf install su-dummy-orphan-parent) dependency Dependency
dnf install su-dummy-a pulling in su-dummy-b a user, b dependency a User, b Dependency
not installed (in a repo or unknown) no output, rc 0 no output, rc 0
  • %{reason} exists on every target, --qf and --queryformat both work. dnf4 ends the value with a newline, dnf5 does not (several installed versions would run together), so the format carries its own \n and the first non-empty line is used. Other values in the code: weak-dependency/Weak Dependency (dependency), group/Group (explicit: asked for with its group); dnf5 External User counts as user-installed (repoquery --userinstalled lists those packages) -> explicit; dnf4 unknown -> unknown.
  • Fallback dnf history userinstalled not needed and not used: dnf5 has no such command (Unknown argument "userinstalled", rc 2), and on dnf4 it lists the fixture's rpm-installed a, b, c (reason unknown) as "Packages installed by user", i.e. it would call b explicit.
  • Marks: dnf4 dnf mark remove PKG -> dependency, dnf mark install PKG -> user (mark user/mark dependency: invalid choice, rc 2); dnf5 dnf -y mark dependency|user PKG (without -y: "Operation aborted", rc 1; mark remove|install: unknown argument, rc 2). A mark survives a later dnf upgrade and an rpm -Uvh --oldpackage --replacepkgs back to 1.0 (the e2e reset).
  • Works as nobody (setpriv / python3 on almalinux:10), with or without -C, in 110-150 ms (dnf4) / 45 ms (dnf5), and does not touch the repositories even with the distro repos re-enabled and their metadata expired.
  • Fixture consequence: on dnf the rpm-installed a and c would print reason: unknown; SU_FIXTURE_B_AUTO=1 now also runs dnf mark install su-dummy-a su-dummy-c (dnf5: -y mark user), and e2e_reset re-marks a/c user and b dependency.

14.2 ZYPPER: Installed line of zypper --non-interactive --no-refresh --quiet info PKG

opensuse/tumbleweed (zypper 1.14.101, libzypp 17.38.16) and opensuse/leap 16.0 (same versions), identical, fixture with SU_FIXTURE_B_AUTO=1:

su-dummy-a              Installed      : Yes                    rc 0
su-dummy-b              Installed      : Yes (automatically)    rc 0   (listed in /var/lib/zypp/AutoInstalled)
su-dummy-orphan         Installed      : Yes (automatically)    rc 0   (pulled in by zypper install su-dummy-orphan-parent)
su-dummy-orphan-parent  Installed      : No                     rc 0   (Status : not installed)
bash                    Installed      : Yes                    rc 0   (Repository : @System, repos isolated)
no-such-pkg             package 'no-such-pkg' not found.        rc 104
  • The block also has Installed Size : 15 B: match ^Installed *: only.
  • Cross-check: the flag is exactly membership in /var/lib/zypp/AutoInstalled (bare package names, plus pattern:... lines and # comments; 0644 root, world-readable). Deleting su-dummy-b from it turns the line into Yes immediately; an rpm -Uvh --replacepkgs reinstall keeps it; zypper install su-dummy-a (rpm -e'd first) installs b with Yes (automatically) and adds it to the file; naming b on the command line (zypper update su-dummy-a su-dummy-b) drops the flag (b becomes user-installed).
  • Works as nobody. As root, while another process owns the zypp lock (/run/zypp.pid with a live pid), zypper info exits 7 ("System management is locked by the application with pid N (sleep)") - as nobody it still answers. Fallback in that case (and for exit 104): rpm -q PKG fails -> not installed; else membership in AutoInstalled -> dependency / explicit; unreadable -> unknown.
  • Fixture: a/c (plain rpm, not in AutoInstalled) are Yes, b is Yes (automatically) with SU_FIXTURE_B_AUTO=1; e2e_reset re-adds b to AutoInstalled. No change needed.

15. Full info (N?? / show): installed record merged with the candidate

Verified 2026-10-10, fixture SU_FIXTURE_ISOLATE=1 SU_FIXTURE_ORPHAN=1 SU_FIXTURE_B_AUTO=1, LC_ALL=C, plus real packages on almalinux:9 (glibc 2.34-275.el9_8 installed, 283.el9_8 in baseos; bash, up to date; zsh, not installed).

15.1 DNF: dnf -q info --installed PKG vs dnf -q info --upgrades PKG

dnf4 (almalinux:9 4.14.0, almalinux:10 4.20.0 - identical apart from sizes): one heading line (Installed Packages, Available Upgrades, Available Packages), then Key : value records, : at column 14 on both sides, Description continued by : text lines (an empty paragraph is :), a blank line after each record; Size : 15 keeps trailing blanks. Same keys on both sides (Name, Version, Release, Architecture, Size, Source, Repository, From repo (installed only, when dnf installed it), Summary, URL, License, Description): Version/Release, Size (installed size vs download size, same key), Source, Repository (@System vs the repo id) and changed Summary/Description differ.

dnf5 (fedora:latest, 5.4.3.0): headings Installed packages / Available upgrades / Available packages; installed keys Name, Epoch, Version, Release, Architecture, Installed size, Source, From repository, Summary, URL, License, Description, Vendor (: at column 17); the candidate has Download size, Installed size, Repository instead of From repository (: at column 16). Installed size is the same value with different padding; Vendor is <NULL> installed and empty in the repo for the fixture.

  • --upgrades, not --available, for an installed package: on almalinux:9 info --available glibc prints the i686 build first and then x86_64 (both not installed), and nothing for the up-to-date bash (Error: No matching Packages to list, rc 1); --upgrades glibc prints exactly the x86_64 candidate. Not installed (zsh): --installed fails, --available prints the record.
  • Both rc 1 with no match (dnf4 Error: No matching Packages to list, dnf5 No matching packages to list, stderr).

15.2 ZYPPER: not merged

$ rpm -qi su-dummy-b                    $ zypper -n --no-refresh info su-dummy-b
Name        : su-dummy-b                Information for package su-dummy-b:
Version     : 1.0                       -----------------------------------
Release     : 1                         Repository     : su-dummy
Architecture: noarch                    Name           : su-dummy-b
Install Date: Sat Oct 10 12:02:01 2026  Version        : 2.0-1
Group       : Unspecified               Arch           : noarch
Size        : 15                        Vendor         :
License     : MIT                       Installed Size : 15 B
Signature   : (none)                    Installed      : Yes (automatically)
Source RPM  : su-dummy-b-1.0-1.src.rpm  Status         : out-of-date (version 1.0-1 installed)
Build Date  : Sat Oct 10 12:02:01 2026  Source package : su-dummy-b-2.0-1.src
Build Host  : 17756d8ad56d              Summary        : skeptical-updater dummy su-dummy-b (v2.0)
Summary     : skeptical-updater ...     Description    :
Description :                               Dummy package su-dummy-b version 2.0 ...
Dummy package su-dummy-b version 1.0 ...
Distribution: (none)
  • Only Name and Summary pair directly. Version (version vs version-release), Arch/Architecture, Size (bytes) vs Installed Size (15 B), Source RPM vs Source package (different file names), the description layout (rpm: unindented lines after Description :; zypper: indented 4) would all need translating, and zypper cannot print the installed record in its own layout (zypper info -r @System PKG: Repository '@System' not found by its alias, number, or URI., rc 3). N?? stays zypper info --requires (opensuse/tumbleweed, zypper 1.14.101).