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

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

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

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

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

783 lines
71 KiB
Markdown

# RPM family discovery: DNF (AlmaLinux / Rocky / Fedora) and ZYPPER (openSUSE)
Everything below was run in throwaway `docker run --rm -i` containers (Docker 29.8.2 on the host, 2026-10-09).
All outputs are real; "exit=N" is the real exit status. All commands run with `LC_ALL=C`.
Companion files (all in `discovery/rpm/`, all proven on every image listed in section 0):
| file | purpose |
|---|---|
| `fixture.sh` | E2E fixture: builds 3 dummy RPMs (+ optional extras), local repo, 1.0 installed / 2.0 offered. dnf vs zypper auto-detected |
| `lib-rpm.sh` | the proven bash building blocks quoted in this document (`su_refresh`, `su_upgradable`, `su_describe`, `su_rdeps`, `su_closure`, `su_upgrade`, `su_plan`, `su_remove`, ...) |
| `proof.sh` | runs the fixture + every `lib-rpm.sh` function and asserts the results (43-53 PASS lines per image) |
Reproduce on any image: `docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh` or `... bash /d/proof.sh`.
---------------------------------------------------------------------------------------------------------
## 0. Verdict, images, and the surprises
### 0.1 Chosen commands (one line each)
| need | DNF (dnf4 and dnf5, `yum` is the same binary) | ZYPPER |
|---|---|---|
| refresh | `dnf -q --refresh --setopt=skip_if_unavailable=0 makecache >/dev/null` (exit 0/1) | `zypper --non-interactive --quiet refresh` (exit 0; 4 repo error; 3 unknown repo; 6 no repos) |
| upgradable list | `dnf -q check-update --setopt=skip_if_unavailable=0` (exit 100 = updates, 0 = none, 1 = error), parse 3 columns `name.arch evr repo`, old version from `rpm -qa` | `zypper --non-interactive --xmlout list-updates` (exit 0 always), parse `<update ... name edition edition-old arch>` + `<source alias>` |
| summary/description | `dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' PKG`, fallback `rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' PKG` | `zypper -q -n info PKG` (shows candidate, else installed), parse `Summary :` / indented `Description :` |
| installed reverse deps | pure rpm: provides + owned files joined against all REQUIRENAMEs (`su_rdeps`); == `dnf repoquery --installed --whatrequires` | same pure-rpm function (identical on both families) |
| recursive installed closure | pure rpm BFS `su_closure` (rpm -q REQUIRENAME + rpm -q --whatprovides); == dnf4 `repoquery --installed --requires --resolve --recursive`; dnf5 has no `--resolve` | same function |
| per-run exclusion | `dnf -y upgrade --exclude=a,b` (comma list; repeated flags also work; dnf4 and dnf5) | no `--exclude`. Chosen: private `ZYPP_CONF` with its own `locksfile.path` (`su_zypper_excluding`). Alternative with exact restore: snapshot/restore `/etc/zypp/locks` + trap (`su_zypper_excluding_snapshot`) |
| non-interactive upgrade | `dnf -y upgrade` | Leap/SLE: `zypper --non-interactive update`; Tumbleweed (os-release `ID=opensuse-tumbleweed`): `zypper --non-interactive dup` |
| remove + unneeded deps | `dnf -y remove PKG` (`clean_requirements_on_remove=1` is the default on dnf4 and dnf5) | `zypper --non-interactive remove --clean-deps PKG` |
| plan preview (new deps, removals, obsoletes) | `dnf --assumeno upgrade [--exclude=..]` + table parse (`su_plan_dnf`) | `zypper --non-interactive --xmlout update` or `dup` plus `--dry-run` (`<install-summary><to-upgrade>/<to-install>/<to-remove>`) |
### 0.2 Correct image names
| asked | result |
|---|---|
| `almalinux:9` | exists. AlmaLinux 9.8, **dnf 4.14.0**, rpm 4.16.1.3, bash 5.1.8 |
| `almalinux:10` | exists. AlmaLinux 10.2, **dnf 4.20.0 (NOT dnf5)**, rpm 4.19.1.1, bash 5.2.26 |
| `rockylinux:9` | exists but **stale**: Docker "official" library image = Rocky 9.3 created 2023-11-28 (96 pending upgrades on day one), dnf 4.14.0. Fresh equivalent: `rockylinux/rockylinux:9` or `quay.io/rockylinux/rockylinux:9` = Rocky 9.8 (same image id `686e4558d0a5`) |
| `rockylinux:10` | **does not exist**: `docker manifest inspect rockylinux:10` -> "no such manifest" (also `:10.0`, `:10.1`, `:10-minimal` missing). **Correct: `rockylinux/rockylinux:10`** (Docker Hub) **or `quay.io/rockylinux/rockylinux:10`** |
| quay vs Docker Hub Rocky 10 | **same image**: both resolve to id `sha256:0b5329d806d0...`, repo digest `sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834`. Rocky 10.2, dnf 4.20.0, rpm 4.19.1.1. (`fixture.sh` also passes on the quay tag, 8 s.) |
| `opensuse/tumbleweed:latest` | openSUSE Tumbleweed (os-release `ID=opensuse-tumbleweed`, `VERSION_ID=20261007`), zypper 1.14.101, rpm 4.20.1, bash 5.3.20 |
| `opensuse/leap:latest` | **Leap 16.0** (`ID=opensuse-leap`), image created 2025-08-26, zypper 1.14.101, rpm 4.20.1, bash 5.2.37 |
| extra, for dnf5 only | `fedora:latest` = Fedora 44, **dnf5 5.4.3.0**, rpm 6.0.2 (pulled only to verify dnf5 syntax; not a target) |
### 0.3 Surprises that change the design
1. **EL10 is dnf4 (4.20.0), not dnf5.** AlmaLinux 10 has no `dnf5` package anywhere (BaseOS/AppStream/CRB/Extras/EPEL: `dnf -q --enablerepo='*' list --available dnf5` -> "No matching Packages"; only `microdnf 3.10.1`). Rocky 10 ships `dnf5 5.1.12` only in the *disabled* `devel` repo. So dnf5 is only met on Fedora (or a user-installed dnf5). The tool must support **dnf4 and dnf5 from one code path**; the only target images that run dnf5 are not in the requested list, so dnf5 was verified on `fedora:latest`.
2. `dnf5` silently ignores a dead repo (exit 0, `-q` hides the warning) unless `--setopt=skip_if_unavailable=0`. dnf4 errors by default (exit 1).
3. `rpm -qa --qf '[%{REQUIRENAME}\t%{NAME}\n]'` is **broken** (`error: incorrect format: array iterator used with different sized arrays`, ~50% of packages) on every rpm 4.16..6.0; the scalar tag must be written `%{=NAME}` inside `[...]`.
4. Missing tools in base images: no `awk`/`find`/`xargs`/`hostname`/`python3`/`xmllint` on Tumbleweed; no `find`/`xargs`/`python3`/`xmllint` on Leap; no `diff`/`cmp`/`ps`/`which`/`sudo` almost everywhere. All proven code uses only bash + rpm + sed/grep/tr/sort/join/cut/mktemp.
5. `rpm -q --whatrequires bash` returns **1** package on EL9 where `dnf repoquery --installed --whatrequires bash` and `su_rdeps bash` return **52** (file deps `/bin/sh`, `/usr/bin/bash`, sonames).
6. `zypper removelock` is not an undo (deletes *all* matching locks, leaves an empty `/etc/zypp/locks` file even if there was none).
7. `dnf list --upgrades` and (dnf4) `repoquery --upgrades` ignore the versionlock plugin; only `check-update` honours it on both majors.
---------------------------------------------------------------------------------------------------------
### 0.4 Base image facts (this is item 9 of the brief; repeated as a pointer in section 9)
| | alma:9 | alma:10 | rocky:9 (9.3 lib) | rocky 10 | tumbleweed | leap 16.0 |
|---|---|---|---|---|---|---|
| package mgr | dnf 4.14.0 | dnf 4.20.0 | dnf 4.14.0 | dnf 4.20.0 | zypper 1.14.101 | zypper 1.14.101 |
| `yum` | symlink -> `dnf-3` | `dnf-3` | `dnf-3` | `dnf-3` | absent | absent |
| rpm | 4.16.1.3 | 4.19.1.1 | 4.16.1.3 | 4.19.1.1 | 4.20.1 | 4.20.1 |
| bash | 5.1.8 | 5.2.26 | 5.1.8 | 5.2.26 | 5.3.20 | 5.2.37 |
| `sudo` | no (installable, baseos) | no | no | no | no (installable) | no (installable) |
| `su` | yes | **no** | yes | yes | yes | yes |
| `python3` | yes | yes | yes | yes | **no** | **no** |
| awk / find / xargs | yes | yes | yes | yes | **no / no / no** | awk yes, **find, xargs no** |
| xmllint | yes | yes | yes | yes | no | no |
| runs as | root | root | root | root | root | root |
`bats`:
* EL9/EL10 (Alma and Rocky): **not in BaseOS/AppStream/CRB**; needs EPEL: `dnf -y install epel-release && dnf -y install bats` (epel-release is in `extras`, enabled by default). Verified: EL9 -> `Bats 1.8.0` (bats-1.8.0-1.el9), EL10 -> `Bats 1.11.1` (bats-1.11.1-1.el10_0). `bats` install itself took 3-5 s; `epel-release` 1-12 s (measured on all four EL images).
* Tumbleweed: `zypper -n in bats` -> `Bats 1.14.0` (bats-1.14.0-1.1), main OSS repo, no extra repo. Leap 16.0: `Bats 1.11.1` (bats-1.11.1-160000.2.2), main repo. (install of bats+sudo: 8-9 s.)
* `sudo` installable everywhere: EL baseos `1.9.17p2` / `1.9.17-10.p2.el10_2.7`; Tumbleweed `1.9.17p2`; Leap `1.9.17p1`.
* `python3` on openSUSE: Leap has `python3` / `python313`; Tumbleweed has versioned `python311`/`python312`/... (no unversioned package).
* `rpm-build` and `createrepo_c` are installable on all six images (BaseOS/AppStream on EL, main OSS on openSUSE). EL9: rpm-build 4.16.1.3-40.el9 + createrepo_c 0.20.1-4.el9; EL10: rpm-build 4.19.1.1-23.el10 + createrepo_c 1.1.2-4.el10; Tumbleweed: rpm-build 4.20.1-11.1 + createrepo_c 1.2.3-1.4; Leap 16: rpm-build 4.20.1-160000.3.1 + createrepo_c 1.1.0-160000.2.2. No EPEL needed for them.
---------------------------------------------------------------------------------------------------------
## 1. Refresh metadata non-interactively
### DNF
```
$ dnf makecache # alma9 (dnf4), cold cache, 9.0 s
AlmaLinux 9 - AppStream 23 MB/s | 28 MB 00:01
AlmaLinux 9 - BaseOS 32 MB/s | 45 MB 00:01
AlmaLinux 9 - Extras 52 kB/s | 33 kB 00:00
Last metadata expiration check: 0:00:01 ago on Fri Oct 9 08:20:43 2026.
Metadata cache created. exit=0
```
Variants tested (stdout lines / stderr lines / exit):
| command | dnf4 (alma9/10) | dnf5 (fedora 44) |
|---|---|---|
| `dnf makecache` | 5 / 0 / 0 (progress + "Metadata cache created." on **stdout**) | 1 / 3 / 0 (progress "Updating and loading repositories:" ... "Repositories loaded." on **stderr**, "Metadata cache created." on stdout) |
| `dnf -q makecache` | 0 / 0 / 0 (silent) | 1 / 0 / 0 (**still prints "Metadata cache created." to stdout**) -> `>/dev/null` |
| `dnf --refresh makecache` and `dnf makecache --refresh` | both accepted, forces re-download | both accepted |
| plain `makecache` (no `--refresh`) | no re-download while the cache is younger than `metadata_expire` (dnf4 dump on alma9: `metadata_expire = 172800` = 48 h; ~1-2 s of revalidation measured) | same |
* `--refresh` (= "set metadata as expired") is the right switch for a skeptical tool: `dnf -q --refresh makecache` re-downloads every enabled repo (alma9 1.6-7 s, alma10 2.5-7 s, rocky 1-1.4 s, fedora 1.7 s measured warm; cold 3-9.5 s, section 11).
* `dnf check-update` exit codes (verified on dnf4 and dnf5): **100** = updates available, **0** = nothing (also 0 when a name pattern matches nothing: `dnf -q check-update 'nonexist*'` -> exit=0), **1** = error.
* **Dead repo handling differs** (repodata removed from the local repo):
| command | dnf4 alma10 | dnf5 fedora |
|---|---|---|
| `dnf -q --refresh makecache` | exit 1: `Error: Failed to download metadata for repo 'su-dummy': Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried` | **exit 0**, no message (repo silently ignored, default `skip_if_unavailable=true` for repos that do not set it) |
| same + `--setopt=skip_if_unavailable=0` | exit 1 (same message) | exit 1: `Failed to download metadata (baseurl: "file:///var/lib/su-dummy-repo") for repository "su-dummy": Usable URL not found` |
| `dnf -y upgrade su-dummy-a` | exit 1 | **exit 0, prints `Nothing to do.`; su-dummy-a stays 1.0** (the dead repo is only a stderr warning `>>> Curl error (37) ...`, absent with `-q`) unless `skip_if_unavailable=0` |
So always pass `--setopt=skip_if_unavailable=0` (harmless on dnf4) and never rely on `-q` to see problems.
* Oddity seen on dnf 4.14.0 (EL9): with the repo gone, `dnf -q check-update` still returned **100 from stale cache**, while `makecache --refresh`, `repoquery` and `upgrade` failed with exit 1. => run the explicit refresh first and abort on its failure, do not infer repo health from check-update.
* Real-world flake observed once on `rockylinux:9`: `Error: Failed to download metadata for repo 'extras': repomd.xml parser error: Parse error at line: 1 (Extra content at the end of the document` (mirror served garbage), exit 1; re-run was clean. The refresh step correctly failed the run; the tool should surface this and let the user retry.
* `yum`: `/usr/bin/yum -> dnf-3` on all EL images (prints `4.14.0` / `4.20.0`); `/usr/bin/yum -> dnf5` on Fedora. Detection: `dnf --version | head -1` starts with `dnf5 version` on dnf5, with a bare version (`4.14.0`) on dnf4.
### ZYPPER
```
$ zypper --non-interactive refresh # tumbleweed, metadata fresh, 0.6 s
Repository 'openSUSE-Tumbleweed-Update' is up to date.
Repository 'openSUSE-Tumbleweed-Non-Oss' is up to date.
Repository 'Open H.264 Codec (openSUSE Tumbleweed)' is up to date.
Repository 'openSUSE-Tumbleweed-Oss' is up to date.
Repository 'su-dummy' is up to date.
All repositories have been refreshed. exit=0
```
* `-q` silences it; exit 0 either way. `zypper refresh --force su-dummy` re-downloads ("Forcing raw metadata refresh ... Building repository 'su-dummy' cache [....done]", exit 0).
* Normal `refresh` only checks whether the repo changed (cheap); it is also what `zypper lu/up/dup` do implicitly (autorefresh=on).
* Broken repo: `Retrieving repository 'su-dummy' metadata [.error]` ... `Could not refresh the repositories because of errors.` **exit=4**. Unknown name (`refresh nonexist`): `Repository 'nonexist' not found by its alias, number, or URI.` **exit=3**.
* Leap prints `Refreshing service 'openSUSE'.` as first line of nearly every command (it is a service, not a repo) - in `-q` mode it disappears, in `--xmlout` mode it is a `<message type="info">`.
* GPG: the test repo is `--no-gpgcheck`. For real third-party repos `--non-interactive` would refuse unknown keys; `--gpg-auto-import-keys` is the (security-relevant) opt-in. Not used by the tool.
---------------------------------------------------------------------------------------------------------
## 2. Machine-parsable list of upgradable packages (`name<TAB>old<TAB>new<TAB>repo`)
### 2.1 DNF: candidates compared (alma9/alma10 = dnf4, fedora = dnf5)
| | `dnf -q check-update` | `dnf -q repoquery --upgrades --queryformat ...` | `dnf -q list --upgrades` |
|---|---|---|---|
| first line(s) | dnf4: one **blank line**; dnf5: a header line `Upgrades` (printed even with `-q`) | none | `Available Upgrades` (dnf4) / `Available upgrades` (dnf5) |
| columns | 3: `name.arch evr repo` (arch suffix attached, epoch shown as `2:...` when non-zero) | whatever you ask for | same 3 columns |
| honours `--exclude` / `excludepkgs` | yes (dnf4, dnf5) | yes | yes |
| honours **versionlock** plugin | **yes** (dnf4 and dnf5) | dnf4: **no** (locked pkg still listed), dnf5: yes | **no** on both |
| same code path as `dnf upgrade` | yes | no | no |
| "Obsoleting Packages" | dnf4: trailing section `Obsoleting Packages` (+ indented old pkg `@System`); dnf5: not listed | not listed | not listed |
| exit | 100 / 0 / 1 | 0 | 0 |
| queryformat escapes | - | dnf4 expands `\t` `\n` **and appends its own newline** (blank line after each record); dnf5 does **not** expand `\t` (prints literal `\t`), expands `\n`, appends nothing | - |
| old (installed) version | not given | not given (no installed-version tag) | not given |
Samples (`cat -A`; EL9 shown, EL10 identical in shape):
```
$ dnf -q check-update | cat -A # dnf4, alma9, exit=100
$
gnutls.x86_64 3.8.10-9.el9_8 baseos $
su-dummy-a.noarch 2.0-1 su-dummy$
su-dummy-b.noarch 2.0-1 su-dummy$
su-dummy-c.x86_64 2.0-1 su-dummy$
vim-minimal.x86_64 2:8.2.2637-26.el9_8.23 baseos $
$ dnf -q check-update | head -3 # dnf5, fedora 44, exit=100
Upgrades
ca-certificates.noarch 2026.2.90_v9.0.317-1.fc44 updates
curl.x86_64 8.18.0-12.fc44 updates
$ dnf -q check-update 'nonexist*' ; echo exit=$? # both: nothing printed, exit=0
$ dnf -q repoquery --upgrades --queryformat '%{name}\t%{evr}\t%{repoid}\n' 'su-dummy-*'
su-dummy-a 2.0-1 su-dummy # dnf4: ...followed by a BLANK line after every record
su-dummy-a\t2.0-1\tsu-dummy # dnf5: literal "\t" (not expanded!)
$ dnf -q repoquery --upgrades --queryformat $'%{name}\t%{arch}\t%{evr}\t%{repoid}\n' 'su-dummy-*' | cat -A
su-dummy-a^Inoarch^I2.0-1^Isu-dummy$ # dnf4 and dnf5 identical when real TAB chars are passed
$ dnf -q repoquery --upgrades # default format on both: name-epoch:ver-rel.arch
su-dummy-a-0:2.0-1.noarch
```
Other tag differences: `%{reponame}` is the repo *id* on dnf4 but the human name on dnf5 (`skeptical-updater dummy packages`); `%{repoid}` is the id on both. `%{evr}` omits epoch `0` on both, includes `1:` when non-zero (`su-dummy-e` with `Epoch: 1` -> `1:2.0-1`).
dnf4 long names do **not** wrap in a non-tty even at `COLUMNS=30` (a 108-character package name stays on one line, padded): the 3-field read is safe; the same holds on dnf5.
**Decision: parse `dnf -q check-update`** (only listing that honours excludes *and* versionlock on both majors and is the same solver view as `dnf upgrade`), take the *old* version from `rpm -qa`, and use `repoquery` only for summary/description. Parse (from `lib-rpm.sh`, proven on 5 dnf images):
```bash
_su_installed_evr() { # "name.arch<TAB>evr", ascending so the highest version (installonly pkgs have several) wins
rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2V
}
su_upgradable_dnf() {
local out rc na ver repo rest name arch k evr; local -A old=() oldn=()
out=$(dnf -q check-update --setopt=skip_if_unavailable=0); rc=$?
case $rc in 0) return 0 ;; 100) ;; *) return "$rc" ;; esac
while IFS=$'\t' read -r k evr; do evr=${evr#0:}; old[$k]=$evr; oldn[${k%.*}]=$evr; done < <(_su_installed_evr)
while read -r na ver repo rest; do
[ "$na" = Obsoleting ] && break # dnf4 trailing "Obsoleting Packages" section
[ -n "$repo" ] && [ -z "$rest" ] || continue # drops the blank line (dnf4), the "Upgrades" header (dnf5), anything not 3 fields
name=${na%.*}; arch=${na##*.} # plain name; the arch never contains a dot
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo" "$arch"
done <<<"$out" | LC_ALL=C sort
}
```
Output from `proof.sh` (alma10, tab shown as `^I`):
```
gnutls^I3.8.10-4.el10_2^I3.8.10-5.el10_2^Ibaseos^Ix86_64$
rpm-sequoia^I1.10.1.1-2.el10^I1.10.2.1-1.el10_2^Ibaseos^Ix86_64$
su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-b^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-c^I1.0-1^I2.0-1^Isu-dummy^Ix86_64$
```
Epoch example (alma9): `vim-minimal 2:8.2.2637-26.el9_8.21 2:8.2.2637-26.el9_8.23 baseos x86_64`.
**Excluded / locked packages**: both are *removed from the sack*, so they simply do not appear in `check-update`/`repoquery` (with `--exclude=a,b`: only `su-dummy-c` remains). To see them anyway: dnf4 `--disableexcludes=all`; **dnf5 has no such option** (`Unknown argument "--disableexcludes=all" for command "repoquery"`, also no `--disable-excludes`/`--no-excludes`). Versionlock: dnf4 `python3-dnf-plugin-versionlock` (`/etc/dnf/plugins/versionlock.list`, line `su-dummy-a-0:1.0-1.*`), dnf5 `dnf5-plugins` (`/etc/dnf/versionlock.toml`); after `versionlock add su-dummy-a`, `check-update` lists only b and c on both.
**Blind spot - obsoletes**: a package that *obsoletes* an installed one (`su-dummy-new` obsoleting `su-dummy-old`) is listed only by dnf4 `check-update` (in the trailing section, which the parser drops) and by **no** `repoquery --upgrades`/dnf5 listing, but `dnf upgrade` will install it and remove the old one:
```
$ dnf --assumeno upgrade # dnf4
Installing:
su-dummy-new noarch 1.0-1 su-dummy 6.5 k
replacing su-dummy-old.noarch 1.0-1
```
=> use the plan preview of section 7 (`su_plan`) when the user must see *everything* that will change.
### 2.2 ZYPPER: candidates compared
| | `zypper -n --xmlout list-updates` | `zypper -q -n list-updates` (table) | `... lu --all` |
|---|---|---|---|
| exit | 0 (also with updates; there is no 100) | 0 | 0 |
| noise | `<message>` tags (`Loading repository data...`, Leap: `Refreshing service &apos;openSUSE&apos;.`) | `-q` removes all non-table lines | |
| fields | name, edition (new), edition-old, arch, summary, description, `<source alias=...>` | `S \| Repository \| Name \| Current Version \| Available Version \| Arch` (repo = display *name*, e.g. `openSUSE-Tumbleweed-Update`) | same table, locked rows get status `vl` |
| locked packages | hidden | hidden | **shown with status `vl`** |
| vendor-change candidates | hidden | hidden | shown (`v`) |
| when nothing | empty `<update-list>` | prints nothing (even no header) | |
```
$ zypper -q -n list-updates # tumbleweed
S | Repository | Name | Current Version | Available Version | Arch
---+----------------------------+--------------------+-----------------+-------------------+-------
v | openSUSE-Tumbleweed-Update | openSUSE-build-key | 1.0-68.1 | 1.0-69.1 | x86_64
v | su-dummy | su-dummy-a | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-b | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-c | 1.0-1 | 2.0-1 | x86_64
$ zypper -n --xmlout list-updates # one <update> element per package; attributes + summary + description (candidate!) + source
<update-list>
<update kind="package" name="su-dummy-a" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-a (v2.0)</summary><description>Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
...</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" ...
```
Note the XML puts *all `<update>` elements on one physical line* and descriptions span several lines, `xmllint` does not exist on SUSE images, `python3` neither. Parse with `tr`+`sed`+bash (proven on Tumbleweed and Leap):
```bash
_su_xattr() { # _su_xattr '<tag text>' attr -> $REPLY (XML-unescaped); rc 1 if absent. ' edition="' never matches ' edition-old="'
local v=${1#* $2=\"}; if [ "$v" = "$1" ]; then REPLY=; return 1; fi
v=${v%%\"*}; v=${v//&lt;/<}; v=${v//&gt;/>}; v=${v//&quot;/\"}; v=${v//&apos;/\'}; v=${v//&amp;/&}; REPLY=$v
}
su_upgradable_zypper() {
local xml rc line tag name new old arch src alias
xml=$(zypper --non-interactive --xmlout list-updates); rc=$?; [ "$rc" -eq 0 ] || return "$rc"
# tr leaves no trailing newline and GNU sed keeps it that way: without `|| [ -n "$line" ]` the LAST package is silently dropped
printf '%s\n' "$xml" | tr '\n' ' ' | sed 's/<update /\n<update /g' | while IFS= read -r line || [ -n "$line" ]; do
case $line in '<update kind="package" '*) ;; *) continue ;; esac
tag=${line%%>*}
_su_xattr "$tag" name; name=$REPLY; _su_xattr "$tag" edition; new=$REPLY
_su_xattr "$tag" edition-old; old=$REPLY; _su_xattr "$tag" arch; arch=$REPLY
src=${line#*<source }; alias=; [ "$src" != "$line" ] && { _su_xattr "$src" alias && alias=$REPLY; }
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$old" "$new" "$alias" "$arch"
done | LC_ALL=C sort
}
```
Output (tumbleweed): `openSUSE-build-key^I1.0-68.1^I1.0-69.1^Irepo-update^Ix86_64$`, `su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$` ... The repo column is the stable **alias** (`repo-update`, Leap: `openSUSE:repo-oss`).
Table fallback (also proven; repo = display name, may contain spaces, e.g. `repo-oss (16.0)`):
```bash
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):
```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 `<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):
```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 `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]`).
```bash
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).
```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 = <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).
```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 <list of everything except X>`: dependencies pull X anyway (shown above) and any name that is already newest prints `No update candidate for ...`. Rejected.
---------------------------------------------------------------------------------------------------------
## 7. Non-interactive upgrade, exit codes, plan preview
### 7.1 DNF
* `dnf -y upgrade [--exclude=a,b] [--setopt=skip_if_unavailable=0]`
* Exit codes (4 and 5): **0** success *and* "Nothing to do." (also `Dependencies resolved. Nothing to do. Complete!` on dnf4); **1** any error (unknown package argument `No match for argument: X` + `Error: No packages marked for upgrade.`, dependency problem, dead repo with `skip_if_unavailable=0`); **100 exists only for `check-update`**; `--assumeno` (dry run) ends with **exit 1** (`Operation aborted.` / `Operation aborted by the user.`) after printing the transaction table.
* `-q` removes the progress chatter but dnf5 still prints `Nothing to do.`; dnf5 prints `Warning: skipped OpenPGP checks for 3 packages from repository: su-dummy` for gpgcheck=0 repos.
* On the stale `rockylinux:9` (9.3) the first `-y` transaction imports the Rocky 9 GPG key automatically (`Importing GPG key 0x350D275D: ... From : /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9`) - harmless with `-y`, but a non-`-y` path would prompt.
* There is no machine-readable transaction output in dnf4. `dnf5 check-upgrade --json` exists (dnf5 only), not used.
### 7.2 ZYPPER
Detection (verified): `/etc/os-release` `ID=opensuse-tumbleweed` (Tumbleweed) -> **`dup`**; `ID=opensuse-leap` (Leap 16.0) -> **`update`**; `ID_LIKE` is `opensuse suse` / `suse opensuse` on both, so test `ID`, not `ID_LIKE`. (`opensuse-slowroll` is rolling too and treated like Tumbleweed in the lib - not tested; MicroOS/Aeon/Leap Micro are transactional and out of scope.)
```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 `section<TAB>name<TAB>old<TAB>new<TAB>repo<TAB>arch<TAB>replaces`, honouring exclusions:
* zypper: `zypper --non-interactive --xmlout dup|update --dry-run` yields exact data (no table parsing):
```
<install-summary download-size="19047" space-usage-diff="0" ... packages-to-change="3" need-restart="0" need-reboot="0">
<to-upgrade>
<solvable type="package" name="su-dummy-a" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="..."><description>...</description></solvable>
...</to-upgrade><to-install>...</to-install><to-remove>...</to-remove></install-summary>
```
(the `<prompt>` and `<progress>` elements that follow are ignored; `need-reboot`/`need-restart` attributes are available).
* dnf: `dnf --assumeno upgrade` table parse - section headers are col-0 lines ending in `:` (`Upgrading:`, `Installing:`, `Installing dependencies:`, `Installing weak dependencies:`, `Removing:`, `Removing unused dependencies:`, `Skipping packages with broken dependencies:` ...), rows start with one space, parsing stops at `Transaction Summary`. dnf4 prints no `replacing` line for plain upgrades (old version then comes from `rpm -q`), dnf5 prints `replacing` rows with `0:`-prefixed epochs.
Proof section 9 (builds `su-dummy-new` with `Obsoletes: su-dummy-old < 2` + `Provides: su-dummy-old = 2`): with `--exclude su-dummy-a`:
```
zypper (tumbleweed) dnf4 (alma9) dnf5 (fedora)
upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch -
upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 -
install su-dummy-new - 1.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old
remove su-dummy-old - 1.0-1 @System noarch -
```
and `su_upgradable` shows 0 rows for `su-dummy-new` on all of them (the blind spot).
---------------------------------------------------------------------------------------------------------
## 8. Remove / purge
### DNF (dnf4 and dnf5 identical in effect)
* `clean_requirements_on_remove` default **1** on both (dnf4: `dnf config-manager --dump` -> `clean_requirements_on_remove = 1`; dnf5 behaves the same: its plan shows `Removing unused dependencies: su-dummy-s noarch 0:1.0-1 su-dummy`).
```
$ dnf -y install su-dummy-r # r Requires s; both installed by dnf
$ dnf -y remove su-dummy-r # dnf4: "Erasing: su-dummy-r-1.0-1.noarch ... Erasing: su-dummy-s-1.0-1.noarch" exit=0 (both removed)
$ dnf -y remove --setopt=clean_requirements_on_remove=0 su-dummy-r # only r removed; s stays
$ dnf -y autoremove # then removes the leftover s
```
* Only dependencies **installed by dnf as dependencies** are cleaned. `su-dummy-b` (installed by plain `rpm -Uvh`) stays after `dnf -y remove su-dummy-a` on both majors.
* `dnf remove nonexistent-xx` exits **0** on dnf4 (`Nothing to do.`) and dnf5 (`No packages to remove for argument: nonexistent-xx` / `Nothing to do.`) -> confirm with `rpm -q` afterwards.
* dnf4 refuses protected packages: `dnf -y remove dnf` -> `Error: Problem: The operation would result in broken dependencies for the following protected packages: yum ...` **exit 1**.
* There is no `apt purge` equivalent in the rpm world; `%config` handling on removal (`*.rpmsave`) was not exercised here.
### ZYPPER
* `zypper --non-interactive remove --clean-deps PKG` (`-u`). Plain `remove` leaves the dependency: after `zypper -n install su-dummy-r` (pulls `su-dummy-s`), `zypper -n remove su-dummy-r` -> only r removed, `rpm -q su-dummy-s` still present; with `--clean-deps` -> `2 packages to remove.` `(1/2) Removing: su-dummy-r ... (2/2) Removing: su-dummy-s`, exit 0.
* Which deps are "unneeded" is tracked by zypper in `/var/lib/zypp/AutoInstalled`; packages installed with plain `rpm` (the fixture's `su-dummy-b`) are never cleaned: `remove --clean-deps su-dummy-a` left `su-dummy-b` installed.
* Unknown name: `'nonexistent-xx' not found in package names. Trying capabilities.` ... `No provider of 'nonexistent-xx' found.` / `Nothing to do.` **exit 104**.
* `proof.sh` section 7 asserts all of the above through `su_remove` on every image.
---------------------------------------------------------------------------------------------------------
## 9. Base image facts
See section 0.4 for the table (bash versions, sudo/su/python3/awk/find/xmllint presence), bats (EPEL on EL9/EL10; main repos on openSUSE) and `rpm-build`/`createrepo_c` availability. Short version: bash is present everywhere (5.1-5.3), `sudo` nowhere, `python3` only on EL, `awk`/`find`/`xargs` missing on Tumbleweed, `find`/`xargs` missing on Leap, `su` missing on alma10; `bats` needs EPEL on Alma/Rocky; `rpm-build` and `createrepo_c` install from the base repos everywhere.
---------------------------------------------------------------------------------------------------------
## 10. E2E fixture - `discovery/rpm/fixture.sh`
```
docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh # SU_FIXTURE_EXTRAS=1 for the extras
```
What it does (single script, detects `zypper` first, then `dnf`/`yum`; refuses to run as non-root):
1. installs `rpm-build` + `createrepo_c` (dnf: `dnf -y -q install --setopt=install_weak_deps=False ...`; zypper: `zypper --non-interactive --quiet install --no-recommends ...`) - no EPEL/extra repo needed anywhere;
2. generates minimal specs and builds with `rpmbuild -bb --define "_topdir /var/tmp/su-fixture"`: `su-dummy-a` (noarch, `Requires: su-dummy-b`), `su-dummy-b` (noarch), `su-dummy-c` (**arch-specific**, `%global debug_package %{nil}`, so the `.x86_64` suffix path is exercised), versions 1.0-1 and 2.0-1, one file each, `Summary: skeptical-updater dummy <name> (v<ver>)`, two-paragraph `%description`;
3. installs the three 1.0 packages with `rpm -Uvh --oldpackage --replacepkgs` (distro-agnostic; re-running the script resets to the 1.0 state);
4. publishes only the 2.0 packages in `/var/lib/su-dummy-repo` and runs `createrepo_c`;
5. registers the repo: dnf `/etc/yum.repos.d/su-dummy.repo` (`baseurl=file:///var/lib/su-dummy-repo`, `gpgcheck=0`, `repo_gpgcheck=0`, `metadata_expire=0`) + `dnf -q makecache --repo su-dummy`; zypper `zypper addrepo --no-gpgcheck --no-check --refresh file:///var/lib/su-dummy-repo su-dummy` + `zypper refresh su-dummy`;
6. verifies that `check-update` / `list-updates` lists `su-dummy-a`, `-b`, `-c`, else exits 1.
`SU_FIXTURE_EXTRAS=1` additionally installs `su-dummy-d` 1.0 (not upgradable; `Requires: su-dummy-a`, `/bin/sh`, `su-dummy-b >= 1.0`, `(su-dummy-c if su-dummy-b)`, `rpmlib(CompressedFileNames)`) for rdeps/closure tests and publishes (not installs) `su-dummy-r` (Requires `su-dummy-s`) / `su-dummy-s` for remove tests.
Side effect worth knowing: installing `rpm-build` pulls `redhat-rpm-config` etc. on EL, which flips the condition of rich dependencies such as glibc's `(glibc-gconv-extra if redhat-rpm-config)`; the closure code must therefore not treat conditions as dependencies (it does not).
Real output (`fixture.sh` standalone, each run exit 0):
```
[fixture] backend=dnf # almalinux:9
[fixture] build tools installed in 14s
[fixture] rpmbuild done in 0s
[fixture] repo refresh in 0s
[fixture] OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total 15s, since start 15s)
```
and the same `OK` line (totals 8-16 s) on almalinux:10, rockylinux:9, rockylinux/rockylinux:10, quay.io/rockylinux/rockylinux:10, opensuse/tumbleweed (`backend=zypper`, 13 s), opensuse/leap (`backend=zypper`, 14 s), and fedora:latest (dnf5, 16 s). Also verified: a second run on the same container is a clean reset (`proof.sh` section 8).
`proof.sh` results (final run, all rc=0): almalinux:9 44 PASS, almalinux:10 44, rockylinux:9 44, rockylinux/rockylinux:10 44, opensuse/tumbleweed 53, opensuse/leap 53, fedora:latest 43 (dnf5). (zypper images run two extra sections: lock snapshot/restore.) One transient failure was seen and is not a code issue: the first `rockylinux:9` run failed section 1 (`su_refresh` exit 1: Rocky `extras` mirror returned a broken `repomd.xml`); re-run passed (the refresh gate did exactly what it is for).
---------------------------------------------------------------------------------------------------------
## 11. Rough timings (host with fast network, Docker 29.8.2; wall clock, one run each, sequential)
| image | `docker pull` | cold metadata refresh (first `dnf makecache` / `zypper refresh` in the container) | warm refresh | `fixture.sh` wall (of which tool install) | `proof.sh` wall |
|---|---|---|---|---|---|
| almalinux:9 | 4.7 s | 9.5 s | 1.6 s | 15.1 s (14 s) | 29.5 s |
| almalinux:10 | 4.5 s | 5.9 s | 1.9 s | 11.5 s (9 s) | 25.5 s |
| rockylinux:9 (9.3 lib image, 96 pending upgrades) | 6.1 s | 8.4 s | 1.1 s | 16.6 s (15 s) | 29.8 s |
| rockylinux/rockylinux:10 | 5.9 s | 3.3 s | 1.6 s | 10.5 s (8 s) | 21.0 s |
| quay.io/rockylinux/rockylinux:10 | 1.0 s (layers cached) | 4.2 s | - | 8.1 s (7 s) | not run (same image) |
| opensuse/tumbleweed | 3.4 s | 5.9 s | 0.9 s | 14.3 s (12 s) | 32.7 s |
| opensuse/leap (16.0) | 3.5 s | 6.3 s | 0.6 s | 14.4 s (12 s) | 34.3 s |
| fedora:latest (dnf5, reference) | n/a | 5.3 s | 0.3 s | 16.0 s (15 s) | 29.2 s |
Inside `proof.sh` (warm containers): forced refresh (`su_refresh`) 0.5-2.6 s (up to 7 s on alma when the mirror is slow), `su_upgradable` 0.3-0.8 s, `su_closure` 0.07-0.13 s, `su_rdeps` ~0.06-0.13 s, dummy upgrade 0.1-0.3 s (dnf) / 1.6-7 s (zypper, because a full `update`/`dup` also upgrades real packages). "Warm refresh" for dnf is a plain `dnf makecache` without `--refresh` (it still revalidates); `--refresh` forces a full download and is the number to budget for a skeptical run (3-9 s cold).
---------------------------------------------------------------------------------------------------------
## 12. Gotcha checklist for the implementer
1. Never assume dnf5 on EL10; support dnf4 + dnf5 through one parser (`check-update`, 3 fields, tolerate blank line, `Upgrades` header, `Obsoleting Packages` section).
2. Always `--setopt=skip_if_unavailable=0` (dnf5 otherwise hides dead repos, `-q` hides the warning) and gate on the refresh exit status; do not trust `check-update` alone on dnf 4.14.
3. dnf `repoquery --queryformat`: pass real TAB/newline (`$'%{name}\t%{evr}\n'`), then `sed '/^$/d'` (dnf4 adds blank lines, dnf5 expands only `\n`, `\t` stays literal). `--qf` and `--queryformat` both work on both majors.
4. dnf4 `repoquery --upgrades` and `dnf list --upgrades` ignore versionlock; `check-update` does not. dnf5 has no `--disableexcludes`.
5. `check-update`/`list-updates` hide obsoleters, new dependencies and removals. Use `su_plan` when the user must see the real transaction; compare list vs result afterwards because exit codes do not reveal skipped packages (dnf5 skips broken-dependency packages with exit 0; dnf4 fails with exit 1 unless `--nobest`).
6. `rpm --qf '[%{=NAME}...]'`, never `%{NAME}` inside `[...]`.
7. `rpm -q --whatrequires pkg` is name-only; use provides + owned files (`su_rdeps`). Rich deps: members only, conditions dropped.
8. zypper XML: all `<update>` elements are on one line, descriptions multi-line, no xmllint/python on the images, trailing-newline trap with `tr`+`sed`+`read`.
9. zypper `list-updates` exits 0 even with updates; never look for 100. Locked and vendor-change candidates are hidden (`lu --all` shows them).
10. zypper has no `--exclude`: use `ZYPP_CONF` private locks (or snapshot/restore of the real locks file; `removelock` is not an undo). Tumbleweed has no `/etc/zypp/zypp.conf`, only `/usr/etc/zypp/zypp.conf`.
11. Tumbleweed -> `dup`, Leap -> `update`, decided from `ID=` in `/etc/os-release`; `dup`/`update` under `--non-interactive` turn unresolved Problems into exit 4 (cancel) - show the Problem, do not blindly retry.
12. Base images lack `awk`, `find`, `xargs`, `python3`, `xmllint`, `sudo`, `hostname`, `diff`; `rockylinux:9` is a stale 9.3 image (use `rockylinux/rockylinux:9`); `rockylinux:10` does not exist.
13. `dnf remove` of a non-existent package exits 0; zypper exits 104. Verify with `rpm -q`.
14. Packages installed with plain `rpm -i` are never auto-removed by `dnf remove` / `zypper remove --clean-deps`.
## 13. Not verified / limits
* dnf5 was verified on `fedora:latest` (Fedora 44, dnf5 5.4.3.0 - an image outside the requested list), not on an EL10 userland (no dnf5 package in Alma 10; Rocky 10 has `dnf5 5.1.12` only in the disabled `devel` repo and was not exercised).
* zypper exit codes 100-103, 106, 107 were not reproduced (see 7.2). `opensuse-slowroll`, MicroOS/Aeon (transactional), SLES were not tested.
* Multi-arch (x86_64 + i686) and installonly kernels were not exercised; the parser keys on `name.arch` and keeps the highest installed `evr` per key for the "old" column.
* Docker images pulled during this work (all kept, none removed or re-tagged): `almalinux:9`, `almalinux:10`, `rockylinux:9`, `rockylinux/rockylinux:9`, `rockylinux/rockylinux:10`, `quay.io/rockylinux/rockylinux:9`, `quay.io/rockylinux/rockylinux:10`, `opensuse/tumbleweed:latest`, `opensuse/leap:latest`, `fedora:latest`.
## 14. Install reason (`N?` / `info`)
Verified 2026-10-10, fixture `SU_FIXTURE_ISOLATE=1 SU_FIXTURE_ORPHAN=1`, `LC_ALL=C`.
### 14.1 DNF: `dnf -q repoquery --installed --queryformat '%{reason}\n' PKG`
Same answers on almalinux:9 and rockylinux/rockylinux:9 (dnf 4.14.0), almalinux:10 and rockylinux/rockylinux:10 (dnf 4.20.0); fedora:latest (Fedora 44, dnf5 5.4.3.0) uses other words:
| state | dnf4 | dnf5 |
|---|---|---|
| installed with plain `rpm -Uvh` (fixture's a, b, c) | `unknown` | `External User` |
| `su-dummy-orphan` (pulled in by `dnf install su-dummy-orphan-parent`) | `dependency` | `Dependency` |
| `dnf install su-dummy-a` pulling in `su-dummy-b` | a `user`, b `dependency` | a `User`, b `Dependency` |
| not installed (in a repo or unknown) | no output, rc 0 | no output, rc 0 |
* `%{reason}` exists on every target, `--qf` and `--queryformat` both work. dnf4 ends the value with a newline, dnf5 does not (several installed versions would run together), so the format carries its own `\n` and the first non-empty line is used. Other values in the code: `weak-dependency`/`Weak Dependency` (dependency), `group`/`Group` (explicit: asked for with its group); dnf5 `External User` counts as user-installed (`repoquery --userinstalled` lists those packages) -> explicit; dnf4 `unknown` -> unknown.
* Fallback `dnf history userinstalled` not needed and not used: dnf5 has no such command (`Unknown argument "userinstalled"`, rc 2), and on dnf4 it lists the fixture's rpm-installed a, b, c (reason `unknown`) as "Packages installed by user", i.e. it would call b explicit.
* Marks: dnf4 `dnf mark remove PKG` -> `dependency`, `dnf mark install PKG` -> `user` (`mark user`/`mark dependency`: invalid choice, rc 2); dnf5 `dnf -y mark dependency|user PKG` (without `-y`: "Operation aborted", rc 1; `mark remove|install`: unknown argument, rc 2). A mark survives a later `dnf upgrade` and an `rpm -Uvh --oldpackage --replacepkgs` back to 1.0 (the e2e reset).
* Works as `nobody` (setpriv / python3 on almalinux:10), with or without `-C`, in 110-150 ms (dnf4) / 45 ms (dnf5), and does not touch the repositories even with the distro repos re-enabled and their metadata expired.
* Fixture consequence: on dnf the rpm-installed a and c would print `reason: unknown`; `SU_FIXTURE_B_AUTO=1` now also runs `dnf mark install su-dummy-a su-dummy-c` (dnf5: `-y mark user`), and `e2e_reset` re-marks a/c user and b dependency.
### 14.2 ZYPPER: `Installed` line of `zypper --non-interactive --no-refresh --quiet info PKG`
opensuse/tumbleweed (zypper 1.14.101, libzypp 17.38.16) and opensuse/leap 16.0 (same versions), identical, fixture with `SU_FIXTURE_B_AUTO=1`:
```
su-dummy-a Installed : Yes rc 0
su-dummy-b Installed : Yes (automatically) rc 0 (listed in /var/lib/zypp/AutoInstalled)
su-dummy-orphan Installed : Yes (automatically) rc 0 (pulled in by zypper install su-dummy-orphan-parent)
su-dummy-orphan-parent Installed : No rc 0 (Status : not installed)
bash Installed : Yes rc 0 (Repository : @System, repos isolated)
no-such-pkg package 'no-such-pkg' not found. rc 104
```
* The block also has `Installed Size : 15 B`: match `^Installed *:` only.
* Cross-check: the flag is exactly membership in `/var/lib/zypp/AutoInstalled` (bare package names, plus `pattern:...` lines and `#` comments; 0644 root, world-readable). Deleting `su-dummy-b` from it turns the line into `Yes` immediately; an `rpm -Uvh --replacepkgs` reinstall keeps it; `zypper install su-dummy-a` (rpm -e'd first) installs b with `Yes (automatically)` and adds it to the file; naming b on the command line (`zypper update su-dummy-a su-dummy-b`) drops the flag (b becomes user-installed).
* Works as `nobody`. As root, while another process owns the zypp lock (`/run/zypp.pid` with a live pid), `zypper info` exits 7 ("System management is locked by the application with pid N (sleep)") - as `nobody` it still answers. Fallback in that case (and for exit 104): `rpm -q PKG` fails -> not installed; else membership in AutoInstalled -> dependency / explicit; unreadable -> unknown.
* Fixture: a/c (plain rpm, not in AutoInstalled) are `Yes`, b is `Yes (automatically)` with `SU_FIXTURE_B_AUTO=1`; `e2e_reset` re-adds b to AutoInstalled. No change needed.
## 15. Full info (`N??` / `show`): installed record merged with the candidate
Verified 2026-10-10, fixture `SU_FIXTURE_ISOLATE=1 SU_FIXTURE_ORPHAN=1 SU_FIXTURE_B_AUTO=1`, `LC_ALL=C`, plus real packages on almalinux:9 (`glibc` 2.34-275.el9_8 installed, 283.el9_8 in baseos; `bash`, up to date; `zsh`, not installed).
### 15.1 DNF: `dnf -q info --installed PKG` vs `dnf -q info --upgrades PKG`
dnf4 (almalinux:9 4.14.0, almalinux:10 4.20.0 - identical apart from sizes): one heading line (`Installed Packages`, `Available Upgrades`, `Available Packages`), then `Key : value` records, `:` at column 14 on both sides, `Description` continued by ` : text` lines (an empty paragraph is ` : `), a blank line after each record; `Size : 15 ` keeps trailing blanks. Same keys on both sides (Name, Version, Release, Architecture, Size, Source, Repository, `From repo` (installed only, when dnf installed it), Summary, URL, License, Description): Version/Release, Size (installed size vs download size, same key), Source, Repository (`@System` vs the repo id) and changed Summary/Description differ.
dnf5 (fedora:latest, 5.4.3.0): headings `Installed packages` / `Available upgrades` / `Available packages`; installed keys Name, Epoch, Version, Release, Architecture, `Installed size`, Source, `From repository`, Summary, URL, License, Description, Vendor (`:` at column 17); the candidate has `Download size`, `Installed size`, `Repository` instead of `From repository` (`:` at column 16). `Installed size` is the same value with different padding; `Vendor` is `<NULL>` installed and empty in the repo for the fixture.
* `--upgrades`, not `--available`, for an installed package: on almalinux:9 `info --available glibc` prints the **i686** build first and then x86_64 (both not installed), and nothing for the up-to-date bash (`Error: No matching Packages to list`, rc 1); `--upgrades glibc` prints exactly the x86_64 candidate. Not installed (`zsh`): `--installed` fails, `--available` prints the record.
* Both rc 1 with no match (dnf4 `Error: No matching Packages to list`, dnf5 `No matching packages to list`, stderr).
### 15.2 ZYPPER: not merged
```
$ rpm -qi su-dummy-b $ zypper -n --no-refresh info su-dummy-b
Name : su-dummy-b Information for package su-dummy-b:
Version : 1.0 -----------------------------------
Release : 1 Repository : su-dummy
Architecture: noarch Name : su-dummy-b
Install Date: Sat Oct 10 12:02:01 2026 Version : 2.0-1
Group : Unspecified Arch : noarch
Size : 15 Vendor :
License : MIT Installed Size : 15 B
Signature : (none) Installed : Yes (automatically)
Source RPM : su-dummy-b-1.0-1.src.rpm Status : out-of-date (version 1.0-1 installed)
Build Date : Sat Oct 10 12:02:01 2026 Source package : su-dummy-b-2.0-1.src
Build Host : 17756d8ad56d Summary : skeptical-updater dummy su-dummy-b (v2.0)
Summary : skeptical-updater ... Description :
Description : Dummy package su-dummy-b version 2.0 ...
Dummy package su-dummy-b version 1.0 ...
Distribution: (none)
```
* Only Name and Summary pair directly. Version (version vs version-release), Arch/Architecture, Size (bytes) vs `Installed Size` (`15 B`), `Source RPM` vs `Source package` (different file names), the description layout (rpm: unindented lines after `Description :`; zypper: indented 4) would all need translating, and zypper cannot print the installed record in its own layout (`zypper info -r @System PKG`: `Repository '@System' not found by its alias, number, or URI.`, rc 3). `N??` stays `zypper info --requires` (opensuse/tumbleweed, zypper 1.14.101).