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.
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
- EL10 is dnf4 (4.20.0), not dnf5. AlmaLinux 10 has no
dnf5package anywhere (BaseOS/AppStream/CRB/Extras/EPEL:dnf -q --enablerepo='*' list --available dnf5-> "No matching Packages"; onlymicrodnf 3.10.1). Rocky 10 shipsdnf5 5.1.12only in the disableddevelrepo. 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 onfedora:latest. dnf5silently ignores a dead repo (exit 0,-qhides the warning) unless--setopt=skip_if_unavailable=0. dnf4 errors by default (exit 1).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[...].- Missing tools in base images: no
awk/find/xargs/hostname/python3/xmllinton Tumbleweed; nofind/xargs/python3/xmllinton Leap; nodiff/cmp/ps/which/sudoalmost everywhere. All proven code uses only bash + rpm + sed/grep/tr/sort/join/cut/mktemp. rpm -q --whatrequires bashreturns 1 package on EL9 wherednf repoquery --installed --whatrequires bashandsu_rdeps bashreturn 52 (file deps/bin/sh,/usr/bin/bash, sonames).zypper removelockis not an undo (deletes all matching locks, leaves an empty/etc/zypp/locksfile even if there was none).dnf list --upgradesand (dnf4)repoquery --upgradesignore the versionlock plugin; onlycheck-updatehonours 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 inextras, 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).batsinstall itself took 3-5 s;epel-release1-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.) sudoinstallable everywhere: EL baseos1.9.17p2/1.9.17-10.p2.el10_2.7; Tumbleweed1.9.17p2; Leap1.9.17p1.python3on openSUSE: Leap haspython3/python313; Tumbleweed has versionedpython311/python312/... (no unversioned package).rpm-buildandcreaterepo_care 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 makecachere-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-updateexit 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-updatestill returned 100 from stale cache, whilemakecache --refresh,repoqueryandupgradefailed 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-3on all EL images (prints4.14.0/4.20.0);/usr/bin/yum -> dnf5on Fedora. Detection:dnf --version | head -1starts withdnf5 versionon 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
-qsilences it; exit 0 either way.zypper refresh --force su-dummyre-downloads ("Forcing raw metadata refresh ... Building repository 'su-dummy' cache [....done]", exit 0).- Normal
refreshonly checks whether the repo changed (cheap); it is also whatzypper lu/up/dupdo 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-qmode it disappears, in--xmloutmode it is a<message type="info">. - GPG: the test repo is
--no-gpgcheck. For real third-party repos--non-interactivewould refuse unknown keys;--gpg-auto-import-keysis 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 'openSUSE'.) |
-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//</<}; v=${v//>/>}; v=${v//"/\"}; v=${v//'/\'}; v=${v//&/&}; 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; dnf5Argument 'su-dummy-*' matches only excluded packages.exit 1. With no package arguments and nothing to do both printNothing to do.exit 0. - an excluded dependency blocks the upgrade of its dependant (
su-dummy-p2.0Requires: su-dummy-q >= 2.0,--exclude=su-dummy-q):- dnf4 (EL default
best = 1,skip_if_unavailable = 0in 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 filteringexit 1;--skip-brokendoes 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-dummythenNothing to do.exit 0 (no flag needed;--skip-brokenis rejected onupgrade:Unknown argument "--skip-broken" for command "upgrade"). => exit codes cannot tell "everything upgraded" from "some skipped because of the exclusion": re-runsu_upgradableafterwards (the proof does) and report what is left.
- dnf4 (EL default
6.2 ZYPPER (no --exclude)
Findings that drive the recipe:
zypper update/duphave no exclude option. Locks (zypper addlock NAME..., glob via*/?) are honoured by the solver: a fullzypper update(no package args) silently skips locked packages; a lock ona,b-> onlycis upgraded.- An explicit
zypper update LOCKEDor an update whose dependency is locked ends in an interactive "Problem" which--non-interactiveanswers with cancel: exit 4:while a full update with a locked dependency of a newer package just skips both: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=4The following 2 package updates will NOT be installed: su-dummy-p su-dummy-q(exit 0),p/qstay 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 upgradedsu-dummy-q(dependency pull) - verified. - Lock file
/etc/zypp/locks(path =locksfile.pathin zypp.conf; created on firstaddlock; on Tumbleweed/etc/zypp/zypp.confdoes 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 lockstable:# | Name | Type | Repository | Comment;zypper -q llprints the same. zypper removelock NAMEis 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/locksbehind 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 argumentNo match for argument: X+Error: No packages marked for upgrade., dependency problem, dead repo withskip_if_unavailable=0); 100 exists only forcheck-update;--assumeno(dry run) ends with exit 1 (Operation aborted./Operation aborted by the user.) after printing the transaction table. -qremoves the progress chatter but dnf5 still printsNothing to do.; dnf5 printsWarning: skipped OpenPGP checks for 3 packages from repository: su-dummyfor gpgcheck=0 repos.- On the stale
rockylinux:9(9.3) the first-ytransaction 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--ypath would prompt. - There is no machine-readable transaction output in dnf4.
dnf5 check-upgrade --jsonexists (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-runyields exact data (no table parsing):(the<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><prompt>and<progress>elements that follow are ignored;need-reboot/need-restartattributes are available). - dnf:
dnf --assumeno upgradetable 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 atTransaction Summary. dnf4 prints noreplacingline for plain upgrades (old version then comes fromrpm -q), dnf5 printsreplacingrows with0:-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_removedefault 1 on both (dnf4:dnf config-manager --dump->clean_requirements_on_remove = 1; dnf5 behaves the same: its plan showsRemoving 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 plainrpm -Uvh) stays afterdnf -y remove su-dummy-aon both majors. dnf remove nonexistent-xxexits 0 on dnf4 (Nothing to do.) and dnf5 (No packages to remove for argument: nonexistent-xx/Nothing to do.) -> confirm withrpm -qafterwards.- 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 purgeequivalent in the rpm world;%confighandling on removal (*.rpmsave) was not exercised here.
ZYPPER
zypper --non-interactive remove --clean-deps PKG(-u). Plainremoveleaves the dependency: afterzypper -n install su-dummy-r(pullssu-dummy-s),zypper -n remove su-dummy-r-> only r removed,rpm -q su-dummy-sstill 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 plainrpm(the fixture'ssu-dummy-b) are never cleaned:remove --clean-deps su-dummy-aleftsu-dummy-binstalled. - Unknown name:
'nonexistent-xx' not found in package names. Trying capabilities....No provider of 'nonexistent-xx' found./Nothing to do.exit 104. proof.shsection 7 asserts all of the above throughsu_removeon 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):
- 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; - 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_64suffix path is exercised), versions 1.0-1 and 2.0-1, one file each,Summary: skeptical-updater dummy <name> (v<ver>), two-paragraph%description; - installs the three 1.0 packages with
rpm -Uvh --oldpackage --replacepkgs(distro-agnostic; re-running the script resets to the 1.0 state); - publishes only the 2.0 packages in
/var/lib/su-dummy-repoand runscreaterepo_c; - 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; zypperzypper addrepo --no-gpgcheck --no-check --refresh file:///var/lib/su-dummy-repo su-dummy+zypper refresh su-dummy; - verifies that
check-update/list-updateslistssu-dummy-a,-b,-c, else exits 1.SU_FIXTURE_EXTRAS=1additionally installssu-dummy-d1.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(Requiressu-dummy-s) /su-dummy-sfor 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
- Never assume dnf5 on EL10; support dnf4 + dnf5 through one parser (
check-update, 3 fields, tolerate blank line,Upgradesheader,Obsoleting Packagessection). - Always
--setopt=skip_if_unavailable=0(dnf5 otherwise hides dead repos,-qhides the warning) and gate on the refresh exit status; do not trustcheck-updatealone on dnf 4.14. - dnf
repoquery --queryformat: pass real TAB/newline ($'%{name}\t%{evr}\n'), thensed '/^$/d'(dnf4 adds blank lines, dnf5 expands only\n,\tstays literal).--qfand--queryformatboth work on both majors. - dnf4
repoquery --upgradesanddnf list --upgradesignore versionlock;check-updatedoes not. dnf5 has no--disableexcludes. check-update/list-updateshide obsoleters, new dependencies and removals. Usesu_planwhen 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).rpm --qf '[%{=NAME}...]', never%{NAME}inside[...].rpm -q --whatrequires pkgis name-only; use provides + owned files (su_rdeps). Rich deps: members only, conditions dropped.- zypper XML: all
<update>elements are on one line, descriptions multi-line, no xmllint/python on the images, trailing-newline trap withtr+sed+read. - zypper
list-updatesexits 0 even with updates; never look for 100. Locked and vendor-change candidates are hidden (lu --allshows them). - zypper has no
--exclude: useZYPP_CONFprivate locks (or snapshot/restore of the real locks file;removelockis not an undo). Tumbleweed has no/etc/zypp/zypp.conf, only/usr/etc/zypp/zypp.conf. - Tumbleweed ->
dup, Leap ->update, decided fromID=in/etc/os-release;dup/updateunder--non-interactiveturn unresolved Problems into exit 4 (cancel) - show the Problem, do not blindly retry. - Base images lack
awk,find,xargs,python3,xmllint,sudo,hostname,diff;rockylinux:9is a stale 9.3 image (userockylinux/rockylinux:9);rockylinux:10does not exist. dnf removeof a non-existent package exits 0; zypper exits 104. Verify withrpm -q.- Packages installed with plain
rpm -iare never auto-removed bydnf 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 hasdnf5 5.1.12only in the disableddevelrepo 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.archand keeps the highest installedevrper 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,--qfand--queryformatboth work. dnf4 ends the value with a newline, dnf5 does not (several installed versions would run together), so the format carries its own\nand 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); dnf5External Usercounts as user-installed (repoquery --userinstalledlists those packages) -> explicit; dnf4unknown-> unknown.- Fallback
dnf history userinstallednot 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 (reasonunknown) 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); dnf5dnf -y mark dependency|user PKG(without-y: "Operation aborted", rc 1;mark remove|install: unknown argument, rc 2). A mark survives a laterdnf upgradeand anrpm -Uvh --oldpackage --replacepkgsback 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=1now also runsdnf mark install su-dummy-a su-dummy-c(dnf5:-y mark user), ande2e_resetre-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, pluspattern:...lines and#comments; 0644 root, world-readable). Deletingsu-dummy-bfrom it turns the line intoYesimmediately; anrpm -Uvh --replacepkgsreinstall keeps it;zypper install su-dummy-a(rpm -e'd first) installs b withYes (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.pidwith a live pid),zypper infoexits 7 ("System management is locked by the application with pid N (sleep)") - asnobodyit still answers. Fallback in that case (and for exit 104):rpm -q PKGfails -> not installed; else membership in AutoInstalled -> dependency / explicit; unreadable -> unknown. - Fixture: a/c (plain rpm, not in AutoInstalled) are
Yes, b isYes (automatically)withSU_FIXTURE_B_AUTO=1;e2e_resetre-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:9info --available glibcprints 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 glibcprints exactly the x86_64 candidate. Not installed (zsh):--installedfails,--availableprints the record.- Both rc 1 with no match (dnf4
Error: No matching Packages to list, dnf5No 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 RPMvsSource package(different file names), the description layout (rpm: unindented lines afterDescription :; 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??stayszypper info --requires(opensuse/tumbleweed, zypper 1.14.101).