# 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 /discovery/rpm:/d:ro 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 `` + `` | | 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` (`//`) | ### 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 ``. * 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 (`nameoldnewrepo`) ### 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): ```bash _su_installed_evr() { # "name.archevr", 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 | `` tags (`Loading repository data...`, Leap: `Refreshing service 'openSUSE'.`) | `-q` removes all non-table lines | | | fields | name, edition (new), edition-old, arch, summary, description, `` | `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 `` | 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 element per package; attributes + summary + description (candidate!) + source skeptical-updater dummy su-dummy-a (v2.0)Dummy package su-dummy-a version 2.0 for skeptical-updater tests. ...` 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): ```bash _su_xattr() { # _su_xattr '' 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/*} _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#*`, `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): ```bash 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 `` 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): ```bash 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 `capabilitypackage` 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]`). ```bash su_req_table() { # "capabilityrequiring-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]' ` -> filter -> `rpm -q --whatprovides --qf '%{NAME}\n' `. 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). ```bash 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 = /locks`, seed `/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). ```bash 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). ```bash 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 `: 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.) ```bash 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 `sectionnameoldnewrepoarchreplaces`, honouring exclusions: * zypper: `zypper --non-interactive --xmlout dup|update --dry-run` yields exact data (no table parsing): ``` ... ......... ``` (the `` and `` 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 /discovery/rpm:/d:ro 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 (v)`, 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 `` 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 `` 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).