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.
57 KiB
APT family (Debian / Ubuntu) - discovery for skeptical-updater
Everything below was verified empirically in throwaway containers (docker run --rm -i <image> bash ...) on
2026-10-09, always with LC_ALL=C DEBIAN_FRONTEND=noninteractive. Exit codes are quoted as [exit=N].
Sample outputs are real; real-world package versions drift daily, the su-dummy-* fixture ones do not.
Companion files in discovery/apt/ (all runnable, all proven on the five images):
| file | purpose |
|---|---|
fixture.sh |
builds the local flat repo + dummy packages (item 10) |
smoke.sh |
asserts every recipe in this document on top of the fixture (32 checks, passes on all five images) |
parse-inst.awk |
Inst line -> name<TAB>old<TAB>new<TAB>repo (item 2) |
closure-dpkg.awk |
exact installed dependency closure from the dpkg database (item 5) |
0. TL;DR - chosen commands
| need | command (all with LC_ALL=C DEBIAN_FRONTEND=noninteractive) |
|---|---|
| refresh | apt-get update -qq -o APT::Update::Error-Mode=any (without the -o, unreachable mirrors still give exit 0!) |
| list what the upgrade will do | apt-get -s --with-new-pkgs upgrade | awk -f parse-inst.awk (same verb + flags as the real upgrade) |
| list kept-back/held | same output, block The following packages have been kept back: (do NOT use -qq, it drops it); apt-mark showhold for real holds |
| cross-check / "everything newer" | apt list --upgradable 2>/dev/null (stderr carries a warning, stdout has a Listing... header) |
| one-liner description | apt-cache show --no-all-versions P | sed -n 's/^Description: //p' |
| full description (candidate) | apt-cache show --no-all-versions P | awk '<see item 3>' |
| installed reverse deps | apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P | awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ |]+/,"");print}' | sort -u |
| installed dep closure | exact: dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' | awk -F'\t' -v roots="P" -f closure-dpkg.awk (== apt's own parser, verified). The single-call apt-cache depends --recurse ... P is inexact in both modes (with --installed it loses virtual-provided deps, without it over-approximates 2-8x): see item 5 |
| per-run exclusion | apt-mark hold P for packages not already held, restore from a persisted snapshot with comm (item 6) |
| upgrade | apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade |
| purge | apt-get -y purge P, then apt-get -y autoremove --purge |
| bats | apt-get install -y --no-install-recommends bats (1.2.1 .. 1.13.0 depending on image) |
Top gotchas (details in the sections):
apt-get updateexits 0 when every mirror is unreachable (onlyW:lines). Use-o APT::Update::Error-Mode=any(or--error-on=any) to get exit 100. Hard errors (unsigned repo, no root) are exit 100 regardless.apt list/apt showare the "unstable CLI": a warning is printed on stderr whenever stdout is not a tty. Useapt-get/apt-cache/dpkg-query/apt-markfor parsing.- All five images run as root with no sudo, no python3;
apt-get updatemust be run first (lists are empty). -yupgrade with a locally modified conffile that the new package also changes => dpkg prompts, hits EOF on stdin, fails, exit 100, package left iniUstate. Always pass--force-confdef --force-confold.apt-cache depends --recursecannot produce an exact installed closure: with--installedit silently loses everything reachable only through a virtual package (e.g.base-files->awk->mawk: closure of 1 instead of 5 on debian:12) and may still print non-installed provider names; without--installedit walks through non-installed alternatives/providers and uses candidate metadata (debian:12bash: 19 names vs the exact 8). Use the dpkg-database awk (closure-dpkg.awk).apt-cache rdependsdoes not followProvides(nobody is listed as reverse dependency ofmawkalthoughbase-filesneedsawk), lists duplicates (one per version), and--installedfilters on the package being installed, not the version that has the dependency.apt-gettakes the dpkg lock: a concurrent apt gives exit 100Could not get lock /var/lib/dpkg/lock-frontend(alsoapt-mark hold, with a misleading "Are you root?").-o DPkg::Lock::Timeout=Nwaits instead.- Holding a package also keeps back everything that needs a newer version of it (hold
b=>akept back).apt-mark holdwith a mix of valid/invalid names holds the valid ones and exits 100. - stderr noise is not failure: minimal images print
debconf: delaying package configuration, since apt-utils is not installedon every dpkg run. Trust exit codes. - Plain
apt-get install batspullsparallel+ perl (39 pkgs / 85 MB on debian:12, 60 / 159 MB on ubuntu:24.04) and may upgrade unrelated packages; use--no-install-recommends. ubuntu:latestis now 26.04 (apt 3.2.0, new solver3): printsSolving dependencies..., anddist-upgraderefuses to remove manually installed packages (keeps them back instead).
1. Test matrix and base-image facts
| image | resolves to | image id | apt | dpkg | bash | awk | bats (apt) |
|---|---|---|---|---|---|---|---|
| debian:12 | Debian 12 bookworm | 94926f0fd968 | 2.6.1 | 1.21.23 | 5.2.15 | mawk 1.3.4 20200120 | 1.8.2-1 |
| debian:13 | Debian 13 trixie | 99339bdd341a | 3.0.3 | 1.22.22 | 5.2.37 | mawk 1.3.4 20250131 | 1.11.1-1 |
| ubuntu:22.04 | Ubuntu 22.04.5 jammy | e8df6c74f650 | 2.4.14 | 1.21.1 | 5.1.16 | mawk 1.3.4 20200120 | 1.2.1-3build1 |
| ubuntu:24.04 | Ubuntu 24.04.5 noble | 69f919497cad | 2.8.3 | 1.22.6 | 5.2.21 | mawk 1.3.4 20240123 | 1.10.0-1 |
| ubuntu:latest | Ubuntu 26.04.1 resolute | 6e92c4fd8dfd | 3.2.0 | 1.23.7 | 5.3.9 | mawk 1.3.4 20260129 | 1.13.0-1 |
Identical on all five (item 9):
$ id -u; whoami -> 0, root (docker default user; no USER directive)
$ command -v sudo -> (nothing) sudo is NOT installed
$ command -v python3 -> (nothing) python3 is NOT installed
$ command -v bash -> /usr/bin/bash bash present (5.1 .. 5.3); /bin/sh is dash
perl, flock, sed (GNU), grep, awk (mawk), dpkg-deb: present. curl, wget, gpg, dpkg-scanpackages, apt-ftparchive: absent.
- Do not rely on gawk features:
awkis mawk everywhere andsedis GNU sed 4.8/4.9. Verified on mawk:gensub()->function gensub never defined; 3-argumentmatch(s,r,arr)-> syntax error; regex brace repetitiona{2}is matched literally on mawk 1.3.4 20200120 (debian:12, ubuntu:22.04) and works on 20240123+ (debian:13, ubuntu:24.04, ubuntu:latest) - so avoid{n}in awk regexes. /var/lib/apt/listsis empty at start: nothing works (apt-cache policyhas no candidates) untilapt-get update.- Docker-specific apt config shipped in the images:
docker-clean(Dir::Cache::pkgcache ""=> no binary cache, everyapt-get/apt-cache/apt-markcall re-parses all lists),docker-no-languages(Acquire::Languages "none"=> descriptions come from thePackagesfile, no Translation files),docker-gzip-indexes,docker-autoremove-suggests(SuggestsImportant "false"),docker-disable-periodic-update(ubuntu),/usr/sbin/policy-rc.d(exit 101, services are not started by maintainer scripts). - Sources point to the live mirrors (
deb.debian.org,archive.ubuntu.com,security.ubuntu.com) although the debian images carry a commented snapshot date. Therefore real-world upgradable packages exist in the images and change over time. Seen on 2026-10-09:
| image | real-world upgradable right now |
|---|---|
| debian:12 | perl-base 5.36.0-7+deb12u3 -> u4 (oldstable-security) |
| debian:13 | liblzma5 5.8.1-1+deb13u1 -> u2 (stable-security) |
| ubuntu:22.04 | libssl3 3.0.2-0ubuntu1.29 -> .30 |
| ubuntu:24.04 | libaudit-common, libaudit1, libssl3t64 |
| ubuntu:latest | libssl3t64, openssl-provider-legacy 3.5.5-1ubuntu3.5 -> .7 |
- bats (item 9):
apt-get install -y --no-install-recommends batsworks on all five (exit 0, 0.7-1.7 s, 1 package, <200 kB;bats --versionas in the table;bats --tapworks). Helper libsbats-support bats-assert bats-fileexist on every image except ubuntu:22.04. Oldest is 1.2.1 (jammy) which lacksrun --separate-stderr,bats_require_minimum_version,setup_suite: restrict tests to the 1.2.1 feature set or vendor bats-core.Recommends: parallel(that is what bloats a plain install).
2. Item 1 - refreshing metadata: apt-get update
$ apt-get update (debian:12, root, network ok) [exit=0]
Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB]
Get:2 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]
Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [34.8 kB]
Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8790 kB]
Get:5 http://deb.debian.org/debian bookworm-updates/main amd64 Packages [6924 B]
...
Fetched 9388 kB in 2s (5412 kB/s)
Reading package lists...
Second run: Hit:1 ... InRelease lines, exit 0. -qq prints nothing on success (still exit 0); warnings/errors still go to stderr under -qq.
| situation | exit | where |
|---|---|---|
success / nothing new (Hit:) |
0 | |
| not root | 100 | E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied) + E: Unable to lock directory /var/lib/apt/lists/ |
all mirrors unreachable (--network none) |
0 (!) | stdout Err:1 ... + Temporary failure resolving 'deb.debian.org'; stderr W: Failed to fetch ... and W: Some index files failed to download. They have been ignored, or old ones used instead. |
| one of several sources unreachable | 0 (!) | same W: lines |
same two cases with -o APT::Update::Error-Mode=any (or --error-on=any) |
100 | the W: become E: (same text) |
unsigned repo (no [trusted=yes]) |
100 | E: The repository 'file:/srv/su-repo ./ Release' is not signed. |
The "unreachable => exit 0" row and the Error-Mode=any fix are identical on all five images. After a failed update the old lists are kept (verified with a dead Acquire::http::Proxy: exit 0, W: Failed to fetch ..., the 3 Packages files and the candidate versions are unchanged = stale data) - so a tool must either use Error-Mode=any or parse stderr for ^W: Failed to fetch.
Other notes: apt-get update --print-uris lists the index URLs without fetching. A second update of a file: repo regenerated within the same second can be seen as unchanged (mtime resolution): fixture.sh removes the repo's list files first.
3. Item 2 - listing upgradable packages
3.1 The candidates compared (real data, ubuntu:24.04)
apt list --upgradable (stdout and stderr captured separately) [exit=0]:
stdout:
Listing...
libaudit-common/noble-updates 1:3.1.2-2.1ubuntu0.1 all [upgradable from: 1:3.1.2-2.1build1.1]
libaudit1/noble-updates 1:3.1.2-2.1ubuntu0.1 amd64 [upgradable from: 1:3.1.2-2.1build1.1]
libssl3t64/noble-updates,noble-security 3.0.13-0ubuntu3.16 amd64 [upgradable from: 3.0.13-0ubuntu3.15]
stderr:
(empty line)
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
- stderr warning on all five images whenever stdout is not a tty;
Listing...header on stdout; origin column is only the suite(s) comma-separated (no Label/Version); the name has no arch qualifier (with multiarch the same name appears twice:su-dummy-m/su-repo 2.0 amd64 ...andsu-dummy-m/su-repo 2.0 i386 ...); it lists every package whose candidate is newer, including held and kept-back ones (it is not "what the upgrade will do").
apt-get -s --with-new-pkgs upgrade (ubuntu:24.04) [exit=0]:
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
libaudit-common libaudit1 libssl3t64
3 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst libaudit-common [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
Conf libaudit-common (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
Inst libaudit1 [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [amd64])
Inst libssl3t64 [3.0.13-0ubuntu3.15] (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
debian:12: Inst perl-base [5.36.0-7+deb12u3] (5.36.0-7+deb12u4 Debian-Security:12/oldstable-security [amd64]).
apt-get -s dist-upgrade prints the identical Inst lines when no removals/new packages are involved (all three verbs gave the same count on the real data).
3.2 Decision
Use LC_ALL=C apt-get -s --with-new-pkgs upgrade and parse the Inst lines:
- it lists exactly what the real
apt-get --with-new-pkgs upgradewill do (same resolver, same holds/pins), including new dependency packages and the correct order; - no stderr warning, works as non-root (prints a
NOTE: This is only a simulation!block on stdout, harmless), needs no lock; - carries old version, new version, full origin and arch;
Inst/Conf/Remv/Purgline prefixes are not translated, but thekept backheader is, henceLC_ALL=C.
Keep apt list --upgradable 2>/dev/null only as a cross-check (it is the superset: includes held/kept-back).
3.3 Exact parse -> name<TAB>old<TAB>new<TAB>repo (discovery/apt/parse-inst.awk, POSIX awk, tested with mawk)
/^Inst / {
name = $2
rest = $0
sub(/^Inst [^ ]+ /, "", rest)
old = "-"
if (substr(rest, 1, 1) == "[") { # "[old] (new origins [arch])"
old = rest
sub(/^\[/, "", old)
sub(/\].*$/, "", old)
sub(/^\[[^]]*\] /, "", rest)
}
sub(/^\(/, "", rest) # "new origins [arch]) ..."
new = rest
sub(/ .*$/, "", new)
sub(/^[^ ]+ ?/, "", rest) # "origins [arch]) ..."
i = index(rest, "])")
repo = substr(rest, 1, i - 1) # "origins [arch"
sub(/ ?\[[^[]*$/, "", repo) # drop " [arch"
printf "%s\t%s\t%s\t%s\n", name, old, new, repo
}
Usage: LC_ALL=C apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk -f parse-inst.awk. Real output:
perl-base 5.36.0-7+deb12u3 5.36.0-7+deb12u4 Debian-Security:12/oldstable-security
libssl3t64 3.0.13-0ubuntu3.15 3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security
libaudit-common 1:3.1.2-2.1build1.1 1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates
su-dummy-newdep - 1.0 su-fixture:su-repo
Edge cases, proven with a unit test on literal lines (real lines plus two hand-written ones modelled on them, the libc6:i386 line and the Inst su-dummy-slow line with an empty origin) - input on the left, TSV output on the right:
Inst su-dummy-newdep (1.0 su-fixture:su-repo [all]) -> su-dummy-newdep - 1.0 su-fixture:su-repo
Inst su-dummy-slow [0.9] (1.0 [all]) -> su-dummy-slow 0.9 1.0 (empty repo)
Inst su-dummy-m [1.0] (2.0 su-fixture:su-repo [amd64]) [su-dummy-m:amd64 on su-dummy-m:i386] [su-dummy-m:i386 ]
-> su-dummy-m 1.0 2.0 su-fixture:su-repo
Inst libc6:i386 [2.36-9] (2.36-9+deb12u10 Debian-Security:12/oldstable-security, Debian:12.12/oldstable [i386]) []
-> libc6:i386 2.36-9 2.36-9+deb12u10 Debian-Security:12/oldstable-security, Debian:12.12/oldstable
Conf ... / Remv su-dummy-f [1.0] -> (ignored)
(smoke.sh asserts the plain fixture line and the new-package - case on every image; the real-data lines above come from the images themselves.)
- new package (pulled in as dependency):
Inst su-dummy-newdep (1.0 su-fixture:su-repo [all])has no[old]=> old column is-(not empty:IFS=$'\t' readcollapses empty tab fields because tab is IFS-whitespace). - multiple origins:
Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security(comma+space, kept verbatim in the repo column). - empty origin (package from a local .deb):
(1.0 [all])-> repo is empty. - foreign arch: name carries the qualifier,
libc6:i386; native arch has none. - trailing junk after the closing paren (multiarch pairs):
Inst su-dummy-m [1.0] (2.0 su-fixture:su-repo [amd64]) [su-dummy-m:amd64 on su-dummy-m:i386] [su-dummy-m:i386 ]- hence the parser cuts at the first]), never anchors on$. A bare[]may also trail. Instorder is dependency order (b before a), not alphabetical:sortif a stable order is needed.- Repo string format is
Label:Version/Suite(from the Release file;Debian-Security:12/oldstable-security,Ubuntu:24.04/noble-updates); fixture repo (no Version) givessu-fixture:su-repo; with neither Label nor Suite apt prints the host (localhost).
Secondary parse of apt list --upgradable (if ever needed), same 4 columns (repo = suite list):
apt list --upgradable 2>/dev/null | sed -nE 's#^([^/]+)/([^ ]+) ([^ ]+) ([^ ]+) \[upgradable from: ([^]]+)\]$#\1\t\5\t\3\t\2#p'
(Listing... is skipped by the anchored pattern; no arch disambiguation.)
3.4 Held and kept-back packages
Held packages never appear as Inst. They show up in the kept back block together with packages kept back for dependency reasons, and in the "N not upgraded" count; apt list --upgradable still lists them. Real output (fixture, su-dummy-c held, -d needs a new package, -e needs a removal):
$ apt-mark hold su-dummy-c ; apt-get -s upgrade
The following packages have been kept back:
su-dummy-c su-dummy-d su-dummy-e
The following packages will be upgraded:
su-dummy-a su-dummy-b su-dummy-w
3 upgraded, 0 newly installed, 0 to remove and 3 not upgraded.
Parse of the block (names are space separated, indented by two spaces, wrapped at 80 columns):
awk '/^The following packages have been kept back:/{k=1;next} k&&/^ /{for(i=1;i<=NF;i++)print $i;next} {k=0}'
Distinguish "held" from "kept back for other reasons" with apt-mark showhold. Do not use -qq with -s when you need this block: apt-get -qq -s ... prints only the Inst/Conf lines (verified).
Behaviour of the three upgrade verbs on the same state (fixture SU_FIXTURE_EXTRA=1: d needs a NEW dependency, e conflicts with installed f; debian:12/13, ubuntu:22.04/24.04 identical):
| verb | upgraded | newly installed | removed | kept back |
|---|---|---|---|---|
apt-get upgrade |
a b c w | - | - | d e |
apt-get --with-new-pkgs upgrade |
a b c d w | su-dummy-newdep | - | e |
apt-get dist-upgrade |
a b c d e w | su-dummy-newdep | su-dummy-f | - |
(ubuntu:latest/apt 3.2 solver3: dist-upgrade does not remove the manually installed su-dummy-f, so e stays kept back there: 5 upgraded, 1 new, 0 removed.) --with-new-pkgs upgrade is the safe choice: it never removes anything.
4. Item 3 - description of a package
Candidate vs installed matters. Real data (debian:12, perl-base installed u3, candidate u4):
$ apt-cache policy perl-base
perl-base:
Installed: 5.36.0-7+deb12u3
Candidate: 5.36.0-7+deb12u4
Version table:
5.36.0-7+deb12u4 500
500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages
*** 5.36.0-7+deb12u3 500
500 http://deb.debian.org/debian bookworm/main amd64 Packages
100 /var/lib/dpkg/status
| command | content | notes |
|---|---|---|
apt-cache policy P |
Installed / Candidate / version table with pins and origins | no description at all; exit 0 and empty output for an unknown package |
apt-cache show P |
full control record(s) of all versions (default --all-versions: 2 stanzas for perl-base) |
--no-all-versions => only the candidate (1 stanza); P=1.0 selects one version; unknown package: E: No packages found exit 100; purely virtual: no output, exit 0 |
dpkg -s P / dpkg-query -W -f '${Description}' P |
record of the installed version only | non-installed (known or unknown) package: dpkg-query: package 'x' is not installed and no information is available, exit 1 (dpkg-query -W -f '${binary:Summary}' prints (no description available) with exit 0) |
apt show P |
like apt-cache show plus formatting |
stderr warning, not for scripts |
Short description (one line):
apt-cache show --no-all-versions "$pkg" | sed -n 's/^Description: //p' # candidate -> minimal Perl system
dpkg-query -W -f='${binary:Summary}\n' "$pkg" # installed version
apt-cache search --names-only "^$pkg\$" # "perl-base - minimal Perl system"
Full Description paragraph of the candidate (first line, continuation lines with the single leading space stripped, . turned into an empty line, stops at the next field):
apt-cache show --no-all-versions "$pkg" | awk '
/^Description(-[a-z][a-z](_[A-Z][A-Z])?)?:/ { p=1; sub(/^[^:]*: ?/,""); print; next }
p && /^ / { l=substr($0,2); print (l=="." ? "" : l); next }
p { exit }'
Verified output for the fixture package (identical on debian:12 and ubuntu:24.04; Description-md5: is correctly not matched):
dummy package su-dummy-a for skeptical-updater tests
This package only exists to exercise the package manager.
It ships one data file and one conffile (version 2.0).
Raw record order differs per distro (Description is followed by Description-md5, Homepage, ... on Debian; Ubuntu 24.04+/Debian 13 print an empty Description-md5: for installed-only stanzas) - that is why the awk stops at the first non-continuation line instead of counting lines. dpkg -s shows the same text for the installed version (... (version 1.0) in the fixture, 2.0 for the candidate).
5. Item 4 - installed reverse dependencies
Flags: apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P.
Without the --no-conflicts/--no-breaks/... flags Conflicts/Breaks/Replaces are included (fixture: su-dummy-e shows up as "reverse dependency" of su-dummy-f only because of Conflicts; on debian:12 libc6: 76 entries default vs 75 filtered). Pre-Depends are always included (good).
Output format (fixture, debian:12) [exit=0]:
su-dummy-b
Reverse Depends:
su-dummy-a
su-dummy-a
real data (gpgv, debian:12, an alternative gpgv | gpgv2 | gpgv1 in apt's Depends):
gpgv
Reverse Depends:
|apt
- line 1 = the package, line 2 =
Reverse Depends:, then one dependent per line, 2 spaces indent; a leading|marks "I depend on this or an alternative" (the|replaces the first space). - duplicates: one entry per package version that has the dependency (installed 1.0 and candidate 2.0 both => twice; ubuntu repeats per pocket) =>
sort -u. - unknown package:
E: No packages found[exit=100]. Purely virtual name (awk,su-virtual): prints only<awk>[exit=0], never the dependents. - Not-installed package with
--installed: still answers (lists installed dependents).
Parse to plain names (one package per call):
apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "$pkg" \
| awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ |]+/,""); print}' | sort -u
Real: libgcrypt20 -> gpgv libapt-pkg6.0 libsystemd0 (debian:12), gpgv libapt-pkg6.0t64 libsystemd0 (ubuntu:24.04); without --installed the same query returns 155 / 171 names.
For several packages in one call the output is a repeated block (pkg / Reverse Depends: / entries); track the block header (first non-indented line) if needed.
Known inexactness (all verified):
Providesis not followed:apt-cache rdepends --installed ... mawk=> empty, althoughbase-files(Pre-)Depends on the virtualawkthatmawkprovides. Fixture:su-dummy-wdepends onsu-virtual | su-dummy-alt,su-dummy-vprovidessu-virtual=>rdepends su-dummy-vis empty. To be complete, also query every name in the target'sProvides:(apt-cache show --no-all-versions P | sed -n 's/^Provides: //p') - butrdepends <virtual>itself prints only<virtual>, so you must instead scan for the virtual inapt-cache showpkg <virtual>("Reverse Depends:" section listssu-dummy-w,su-virtual) or use the dpkg database.--installedfilters on the dependent package being installed, not on the installed version having the dependency:rdepends --installed su-dummy-newdeplistssu-dummy-dalthough only the not-yet-installed d 2.0 depends on it.- Exact alternative for "what would break if I removed P":
apt-get -s purge P | awk '/^Purg /{print $2}'(transitive, uses the installed versions, the output includes P itself): fixtureapt-get -s purge su-dummy-b=>Purg su-dummy-a [1.0],Purg su-dummy-b [1.0][exit=0]. For Essential chains it errors (E: Error, pkgProblemResolver::Resolve generated breaks,apt-get -s remove libgcrypt20).
6. Item 5 - recursive dependency closure restricted to installed packages
Requested form: apt-cache depends --recurse --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P.
6.1 Output format (debian:12, apt)
apt <- column 0: a package header (one stanza per reachable package; --recurse output is FLAT, not a tree)
Depends: adduser <- 2 spaces: dependency ("PreDepends:" for Pre-Depends)
|Depends: gpgv <- leading " |": member of an alternative group "gpgv | gpgv2 | gpgv1"; the last member has no '|'
Depends: libapt-pkg6.0
Depends: libsystemd0
libelogind0 <- 4 spaces: packages that PROVIDE the dependency name above (not filtered by --installed!)
adduser
Depends: passwd
gpgv
Depends: libbz2-1.0
...
Without --installed the group is |Depends: gpgv, |Depends: gpgv2, Depends: gpgv1. A purely virtual dependency is printed in angle brackets with its providers below it, and later as its own (empty) stanza:
ca-certificates (NOT installed; plain depends) [exit=0]
Depends: openssl
|Depends: debconf
Depends: <debconf-2.0>
cdebconf
debconf
- Package not installed: exit 0, the header and its dependencies are printed anyway; with
--installedonly the installed dependencies (|Depends: debconf;openssland<debconf-2.0>disappear), and--recurse --installedyieldsca-certificates(the root, although not installed) +debconf. - Unknown package:
E: No packages found[exit=100]. Purely virtual root: prints<name>[exit=0]. depends Puses the candidate version's dependencies (fixture: installedsu-dummy-d 1.0has none, candidate 2.0 needssu-dummy-newdep=>apt-cache depends su-dummy-dshows it;apt-cache depends su-dummy-d=1.0shows nothing). Pin a version withP=VERSIONto get the installed version's dependencies.
6.2 What the single-call forms really return (real lists, all five images)
Reference ("exact") = the installed versions' Pre-Depends/Depends, expanded only through installed packages, every installed alternative followed, virtuals resolved through installed providers. It was computed two independent ways that agree 100% (comm diff empty) for 5 roots x 5 images: (D) a BFS driving apt-cache depends --no-... pkg=INSTALLED_VERSION (apt's own parser), and (C) closure-dpkg.awk over the dpkg database (6.4).
A. with --installed (as requested) - under-approximates, and is not even purely "installed":
$ apt-cache depends --recurse --installed <flags> base-files (debian:12)
base-files <- the whole closure is ONE package (exact: 5)
Virtual packages are not "installed", so --installed prunes every dependency satisfied through Provides and the recursion stops there (base-files has PreDepends: <awk>, provider mawk). Sizes --installed vs exact: base-files 1 vs 5 (debian:12/13), 5 vs 6 (ubuntu 22.04/24.04), 6 vs 7 (ubuntu:latest); bash 7 vs 8, 7 vs 8, 8 vs 9, 8 vs 9, 9 vs 10 (always missing mawk); fixture su-dummy-w -> <su-virtual> -> su-dummy-v lost the same way (1 vs 2). Conversely the provider lines are not filtered and get recursed: non-installed names such as libelogind0 (debian:12 apt: 46 vs 45) or gpgv-from-sq (ubuntu:latest apt: 27 vs 26) appear as column-0 headers. A non-installed root is still printed as the first stanza.
B. without --installed, then intersect with dpkg-installed names - over-approximates, because apt-cache walks into non-installed alternatives/providers (debconf-2.0 -> cdebconf, gpgv2, ...) and uses the candidate version's dependencies:
| root | exact | B | root | exact | B | |
|---|---|---|---|---|---|---|
debian:12 apt |
45 | 50 | debian:13 apt |
27 | 46 | |
debian:12 base-files |
5 | 17 | ubuntu:24.04 apt |
35 | 49 | |
debian:12 bash |
8 | 19 | ubuntu:latest apt |
26 | 45 | |
debian:12 tzdata |
2 | 16 | ubuntu:22.04 bash |
9 | 19 |
(the extras are installed packages such as dpkg libacl1 tar libselinux1 libtinfo6 ... that are reached only by recursing into non-installed providers/alternatives: cdebconf (+ libnewt0.52 libslang2 ...) for tzdata's debconf | debconf-2.0, gawk/original-awk (+ libmpfr6 libreadline8 ...) for the virtual awk of base-files/bash; verified by listing the non-installed column-0 stanzas of the recursion). In addition depends uses the candidate version's dependencies (fixture su-dummy-d), which differ from the installed ones whenever an upgrade changes dependencies. B is a superset of the exact closure, A is neither a subset nor a superset. With the offline fixture (no real lists, apt only knows installed versions) A, B and exact differ only by the virtual case.
Other facts: cost of the recursion itself is nil (0.35 s with and without --installed on debian:12, dominated by re-parsing the lists); the foreign-arch names come as name:arch.
6.3 If a single apt-cache call is preferred anyway (superset semantics, e.g. "candidate/post-upgrade closure")
FLAGS="--no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances"
installed_names() { dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\n' | awk -F'\t' 'substr($1,2,1) !~ /[nc]/ {print $2}' | sort -u; }
comm -12 \
<(apt-cache depends --recurse $FLAGS "$pkg" | awk '/^[^ <]/' | sort -u) \
<(installed_names)
awk '/^[^ <]/'= column-0 lines = every package that was reached, minus<virtual>headers; the|markers (|Depends: x) and the 4-space provider lines are indented, hence ignored (|alternativesare therefore all followed, installed or not).substr($1,2,1) !~ /[nc]/: dpkg state is notn(ot-installed)/c(onfig-files only);ii,iU,iFcount as installed,rcdoes not.- Use
${Package}:${Architecture}on both sides if foreign architectures matter (apt-cache printsname:archfor non-native only).
6.4 Recommended (exact, no apt call): discovery/apt/closure-dpkg.awk
dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' \
| awk -F'\t' -v roots="pkg1 pkg2" -f closure-dpkg.awk | sort -u
Follows Pre-Depends + Depends of the installed versions, every installed alternative of an a | b group, virtual names through the Provides of installed packages (versioned Provides: x (= 1.2) and :any qualifiers handled); prints plain names, roots included if installed (a non-installed root yields nothing). ~0 s (single dpkg-query). Output verified identical to the apt-cache BFS reference on apt base-files bash libpam-runtime tzdata on all five images (e.g. debian:12 apt 45 = 45, bash 8 = 8, base-files 5 = 5 incl. mawk; ubuntu:24.04 tzdata not installed => empty).
7. Item 6 - per-run exclusion with apt-mark hold / unhold / showhold
Real outputs (fixture, debian:12):
$ apt-mark hold su-dummy-c su-dummy-c set on hold. [exit=0]
$ apt-mark hold su-dummy-c su-dummy-c was already set on hold. [exit=0]
$ apt-mark showhold su-dummy-c [exit=0] (works as non-root too)
$ apt-mark unhold su-dummy-c Canceled hold on su-dummy-c. [exit=0]
$ apt-mark unhold su-dummy-a su-dummy-a was already not on hold. [exit=0]
$ apt-mark hold no-such-pkg-xyz E: Unable to locate package no-such-pkg-xyz / E: No packages found [exit=100]
$ apt-mark hold su-dummy-newdep no-such-pkg-xyz su-dummy-newdep set on hold. / E: Unable to locate package ... [exit=100] <- the valid one IS held
$ (non-root) apt-mark hold X dpkg: error: required read/write access to the dpkg database directory / E: Executing dpkg failed. Are you root? [exit=100]
$ dpkg-query -W -f '${Package} ${Status}\n' su-dummy-c su-dummy-c hold ok installed
$ dpkg --get-selections | grep hold su-dummy-c<TABs>hold
- A hold is a dpkg selection state (stored in
/var/lib/dpkg/status): persistent, visible todpkg, needs root and the dpkg lock (apt-mark holdwhile another apt runs:dpkg: error: dpkg frontend lock was locked by another process, exit 100). - Holding a package that is not installed works (
set on hold), shows inshowhold, and blocks its installation as a new dependency (it madesu-dummy-dkept back), butdpkg --get-selectionsdoes not list it. - Foreign arch is shown as
name:i386.
apt-get --with-new-pkgs upgrade skips held packages - proven (fixture, real run, su-dummy-c held):
$ apt-get -y --with-new-pkgs upgrade [exit=0]
The following NEW packages will be installed: su-dummy-newdep
The following packages have been kept back: su-dummy-c su-dummy-e
The following packages will be upgraded: su-dummy-a su-dummy-b su-dummy-d su-dummy-w
4 upgraded, 1 newly installed, 0 to remove and 2 not upgraded.
=> a=2.0 b=2.0 c=1.0 (held, untouched) d=2.0 e=1.0 (kept back)
- The hold cascades: holding
b(whilea2.0 needsb (>= 2.0)) keeps a back too (kept back: su-dummy-a su-dummy-b su-dummy-c ..., exit 0). Always re-run the simulation after holding and show the user the real result. dist-upgradehonours holds the same way.apt-get -y purge <held>fails:E: Held packages were changed and -y was used without --allow-change-held-packages.[exit=100].- No-state alternative (inclusion list instead of exclusion):
apt-get -y --only-upgrade install P1 P2 ...upgrades only those (plus whatever they need:adragsbalong;ddragssu-dummy-newdep; a not-installed name is skipped with exit 0; if the needed dependency is held it failsE: Unable to correct problems, you have held broken packages.exit 100).
Recipe: hold temporarily and restore the previous hold set EXACTLY (verified, incl. crash recovery)
Idea: never touch what the admin already holds; journal (write-ahead) every hold we add to a state file; restore = unhold exactly the journaled packages that are still held. The journal survives kill -9, so the next run can undo a leaked hold without disturbing holds the admin added/removed in the meantime (a snapshot-and-diff restore would revert such admin edits - tested, rejected).
SU_STATE_DIR=${SU_STATE_DIR:-/var/lib/skeptical-updater}
SU_HOLDS_ADDED=$SU_STATE_DIR/holds.added # write-ahead journal: the holds WE put (one name per line)
su_holds_begin() { # su_holds_begin PKG... (packages to exclude from this run)
mkdir -p "$SU_STATE_DIR" || return 1
local held p; held=$(apt-mark showhold) || return 1
: > "$SU_HOLDS_ADDED"
for p in "$@"; do
grep -qxF -- "$p" <<<"$held" && continue # already held by the admin: not ours, never touched
echo "$p" >> "$SU_HOLDS_ADDED" # journal first (write-ahead) ...
apt-mark hold "$p" >/dev/null || return 1 # ... then hold (on failure the caller runs su_holds_restore)
done
}
su_holds_restore() { # idempotent; also call it at the START of every run (crash recovery)
[ -f "$SU_HOLDS_ADDED" ] || return 0
apt-mark showhold | sort -u | comm -12 - <(sort -u "$SU_HOLDS_ADDED") | xargs -r apt-mark unhold >/dev/null
rm -f "$SU_HOLDS_ADDED"
}
trap su_holds_restore EXIT # plus INT/TERM; SIGKILL cannot be trapped -> journal + start-of-run call
Verified scenarios (debian:12; the same logic is asserted by smoke.sh on all five images):
admin before : [su-dummy-a su-dummy-alt ] <- a installed, alt NOT installed
su_holds_begin su-dummy-a su-dummy-c journal: [su-dummy-c ] (a is the admin's: not journaled)
during : [su-dummy-a su-dummy-alt su-dummy-c ]
upgrade exit=0 a=1.0 (held) c=1.0 (held), b/d/w upgraded
after restore: [su-dummy-a su-dummy-alt ] <- identical to "before"; a 2nd restore is a no-op (rc=0)
crash: su_holds_begin su-dummy-c su-dummy-b ; kill -9 ; admin: hold su-dummy-e, unhold su-dummy-alt
leaked : [su-dummy-a su-dummy-alt su-dummy-b su-dummy-c ]
admin edits : [su-dummy-a su-dummy-b su-dummy-c su-dummy-e ]
recovered : [su-dummy-a su-dummy-e ] <- only b,c released; the admin's new hold e kept, the admin's removal of alt respected
Notes: each apt-mark call costs 0.3-0.6 s on these images (no cache) - pass many names to one call where possible (a bad name in a multi-name call holds the valid ones and exits 100, so validate names first: apt-cache show --no-all-versions N >/dev/null); apt-mark/xargs -r needs root and the dpkg lock (use the same DPkg::Lock::Timeout discipline: apt-mark hold has no timeout option, retry on exit 100 when the message is frontend lock was locked).
8. Item 7 - upgrading non-interactively
$ apt-get -y --with-new-pkgs upgrade (fixture; held c) [exit=0]
...
Need to get 0 B/4188 B of archives.
Get:1 file:/srv/su-repo ./ su-dummy-b 2.0 [826 B]
(Reading database ... 6114 files and directories currently installed.)
Preparing to unpack ..././su-dummy-b_2.0_all.deb ...
Unpacking su-dummy-b (2.0) over (1.0) ...
Setting up su-dummy-b (2.0) ...
Installing new version of config file /etc/su-dummy/su-dummy-b.conf ...
Exit codes (identical on the five images unless noted):
| case | exit | evidence |
|---|---|---|
| success | 0 | |
| nothing to upgrade (all three verbs) | 0, ends with 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. |
apt-get -qq -s prints nothing |
| kept back / held packages remain | 0 | summary ... and 2 not upgraded. |
dpkg fails (postinst exit 1, fixture SU_FIXTURE_BAD=1) |
100 | stdout Errors were encountered while processing: + pkg; stderr E: Sub-process /usr/bin/dpkg returned an error code (1); other packages in the same run are still configured; the bad package is left iF (half-configured) and every later apt-get upgrade/install -f fails again with 100 until fixed |
conffile conflict, no --force-conf* |
100 | see below |
| lock held by another apt | 100 | E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 191 (apt-get) + E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), is another process using it? |
-o DPkg::Lock::Timeout=30 |
waits (7 s in the test) then proceeds, exit 0 | |
| not root (real run) | 100 | E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied) + E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root? (-s works as non-root) |
unknown package (install/purge) |
100 | E: Unable to locate package no-such-pkg-xyz |
| unsatisfiable (held dependency) | 100 | E: Unable to correct problems, you have held broken packages. |
Conffile prompt (fixture: /etc/su-dummy/su-dummy-c.conf edited locally, 2.0 ships a different one, stdin = /dev/null, -y only):
Configuration file '/etc/su-dummy/su-dummy-c.conf'
==> Modified (by you or by a script) since installation.
==> Package distributor has shipped an updated version.
What would you like to do about it ? Your options are: ...
*** su-dummy-c.conf (Y/I/N/O/D/Z) [default=N] ? dpkg: error processing package su-dummy-c (--configure):
end of file on stdin at conffile prompt [exit=100], state iU, su-dummy-c.conf.dpkg-new left behind
With a tty it would block waiting for input instead. Results of the fix options (same scenario, separate containers, all exit 0, state ii):
-o Dpkg::Options::=... |
conf after | leftover |
|---|---|---|
--force-confold |
local-edit=1 (kept) |
su-dummy-c.conf.dpkg-dist (new version) |
--force-confnew |
version=2.0 (replaced) |
su-dummy-c.conf.dpkg-old (your edit) |
--force-confdef --force-confold |
kept | .dpkg-dist |
Use --force-confdef --force-confold (never silently overwrite admin edits; unmodified conffiles are still updated: Installing new version of config file ...).
upgrade vs dist-upgrade: see the table in 3.4. apt-get -y dist-upgrade on the fixture removed su-dummy-f (Removing su-dummy-f (1.0) ... - note it stays rc in dpkg, dpkg-query -W still prints its version; always check ${db:Status-Abbrev}) with no extra confirmation. Recommendation: --with-new-pkgs upgrade; use dist-upgrade only when explicitly asked, and then show the Remv lines first.
Output noise (non-tty): (Reading database ... 5%(Reading database ... progress is in stdout, -qq drops the apt summary but keeps dpkg's output; -o Dpkg::Use-Pty=0 / Dpkg::Progress-Fancy=0 change nothing in non-tty runs. stderr always has debconf: delaying package configuration, since apt-utils is not installed on the minimal images (harmless).
9. Item 8 - purge and autoremove
$ apt-get -s purge su-dummy-b (a depends on b) [exit=0]
The following packages will be REMOVED:
su-dummy-a* su-dummy-b* <- '*' = purge
0 upgraded, 0 newly installed, 2 to remove and 4 not upgraded.
Purg su-dummy-a [1.0]
Purg su-dummy-b [1.0]
$ apt-get -y purge su-dummy-b [exit=0]
Removing su-dummy-a (1.0) ... <- reverse dependencies are removed too, NO extra confirmation with -y
Removing su-dummy-b (1.0) ...
Purging configuration files for su-dummy-b (1.0) ...
Purging configuration files for su-dummy-a (1.0) ...
apt-get -y remove Pleaves conffiles and staterc(rc su-dummy-c=1.0,/etc/su-dummy/su-dummy-c.confstill there); a followingapt-get -y purge PprintsPurging configuration files for Pand deletes them (state: no dpkg-query entry).- Not installed / already purged / known-but-not-installed:
Package 'su-dummy-a' is not installed, so not removed[exit=0]. Unknown name:E: Unable to locate package no-such-pkg-xyz[exit=100]. - Essential protection:
apt-get -y purge login->WARNING: The following essential packages will be removed. This should NOT be done unless you know exactly what you are doing!...E: Essential packages were removed and -y was used without --allow-remove-essential.[exit=100], nothing is removed (debian:12/13, ubuntu:22.04/24.04;coreutilsgives the same on debian:13, ubuntu:22.04 and ubuntu:latest).loginis not Essential on ubuntu 26.04 and was really purged there (exit 0) - usecoreutilsfor a cross-image Essential test. - Held package:
E: Held packages were changed and -y was used without --allow-change-held-packages.[exit=100] (with the flag it works).
Autoremove (fixture: su-dummy-d 2.0 pulled su-dummy-newdep as automatically installed):
$ apt-get -y purge su-dummy-d [exit=0]
The following package was automatically installed and is no longer required:
su-dummy-newdep
Use 'apt autoremove' to remove it.
The following packages will be REMOVED:
su-dummy-d*
$ apt-get -s autoremove --purge -> Purg su-dummy-newdep [1.0]
$ apt-get -y autoremove --purge [exit=0]
Removing su-dummy-newdep (1.0) ...
Purging configuration files for su-dummy-newdep (1.0) ...
$ apt-get -y autoremove --purge (nothing left) ... 0 to remove ... [exit=0]
apt-get autopurge is an equivalent alias (verified: Purg su-dummy-newdep [1.0] / Removing ... / Purging configuration files ..., exit 0). Packages installed with dpkg -i are manual; apt-mark auto P / apt-mark showauto control the auto flag. The images set APT::AutoRemove::SuggestsImportant "false".
Simulation lines to preview: Purg name [ver] for purge/autoremove --purge, Remv name [ver] for plain remove.
10. Item 10 - E2E fixture: discovery/apt/fixture.sh
Builds /srv/su-repo (flat repo: *.deb, Packages, Release), source line deb [trusted=yes] file:/srv/su-repo ./ in /etc/apt/sources.list.d/su-repo.list, installs the 1.0 packages with dpkg -i (from /srv/su-old, deliberately not published), runs apt-get update and asserts that su-dummy-a/b/c are upgradable via both apt list --upgradable and apt-get -s --with-new-pkgs upgrade (exit 1 otherwise).
su-dummy-a1.0Depends: su-dummy-b; 2.0Depends: su-dummy-b (>= 2.0);su-dummy-b;su-dummy-cstandalone. Archall(runs on any host arch). Each ships one data file and one conffile (/etc/su-dummy/<name>.conf, contentversion=<ver>), multi-line Description.dpkg-deb --root-owner-group -Zgzip --build. Index: defaultSU_FIXTURE_INDEXER=shell(dpkg-deb -f + md5sum/sha256sum + hand-writtenReleasewith Origin/Labelsu-fixture, Suite/Codenamesu-repoand a validDate) => no extra packages, no network needed.scanpackages(installsdpkg-dev) andftparchive(installsapt-utils) are also implemented and verified; both need the real mirrors, and installing the tool can upgrade packages as a side effect (on debian:12 installing dpkg-dev upgradedperl-base, i.e. the "real-world upgradable" package disappeared from the list) - prefershell.- Missing
Date:in Release givesW: Invalid 'Date' entry in Release file; the generated Release has one. A Release without any file also works with[trusted=yes]but is noisy (Method gave a blank filenamex6), hence the generated Release. - Optional knobs:
SU_FIXTURE_OFFLINE=1(moves the distro's real sources to/srv/su-disabled-sources=> hermetic, 0.4 s, works with--network none),SU_FIXTURE_EXTRA=1(su-dummy-d/newdep/e/f/v/w/alt scenarios for kept-back, removal, virtual and alternatives),SU_FIXTURE_BAD=1(su-dummy-bad2.0 with failing postinst). Wipe guard: repo/old dirs must match/srv/su-*,/tmp/su-*,/var/tmp/su-*. Re-running in a dirty container works (uses--force-confnew,</dev/null; a downgrade is reported on stderr). - Real-world upgradable packages of the base image are kept (not touched) and printed as a
NOTE:line, e.g. debian:12perl-base, debian:13liblzma5, ubuntu:22.04libssl3, ubuntu:24.04libaudit-common libaudit1 libssl3t64, ubuntu:latestlibssl3t64 openssl-provider-legacy. They are time dependent: tests must not hard-code them; assert only onsu-dummy-*.
Proof, exact required invocation (docker run --rm -i -v <project>/discovery/apt:/d:ro IMAGE bash /d/fixture.sh), all [exit=0], no stderr output:
== debian:12 [fixture] repo /srv/su-repo indexed with 'shell': 3 packages
[fixture] installed (old): su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=1.0
[fixture] upgradable dummy packages: su-dummy-a su-dummy-b su-dummy-c
[fixture] NOTE: real-world upgradable packages present in this base image: perl-base
[fixture] OK in 3s (image: Debian GNU/Linux 12 (bookworm))
== ubuntu:24.04 ... upgradable dummy packages: su-dummy-a su-dummy-b su-dummy-c
NOTE: ... libaudit-common libaudit1 libssl3t64 ; OK in 5s (image: Ubuntu 24.04.5 LTS)
== debian:13, ubuntu:22.04, ubuntu:latest(26.04.1): same, OK in 3s / 7s / 6s
Also verified: SU_FIXTURE_INDEXER=scanpackages|ftparchive on debian:12, debian:13, ubuntu:24.04, ubuntu:latest; SU_FIXTURE_OFFLINE=1 SU_FIXTURE_EXTRA=1 SU_FIXTURE_BAD=1 --network none on all five; smoke.sh (32 checks: Inst parse, kept-back parse, description awk, rdepends, three closure methods, hold/restore, upgrade, purge, autoremove) ALL PASSED on all five images.
Fixture-state facts useful for tests: apt list --upgradable => su-dummy-a/su-repo 2.0 all [upgradable from: 1.0]; Inst su-dummy-a [1.0] (2.0 su-fixture:su-repo [all]); apt-cache policy su-dummy-a => Installed 1.0 / Candidate 2.0, table 2.0 500 / 500 file:/srv/su-repo ./ Packages and *** 1.0 100 / 100 /var/lib/dpkg/status.
11. Item 11 - timing (rough, this host, 3 runs each, network OK)
| image | apt-get update cold (lists empty) |
lists size | fixture.sh default (incl. real update + 3 apt calls) |
fixture.sh OFFLINE (--network none) |
one apt-get -s upgrade / apt list / apt-cache policy |
|---|---|---|---|---|---|
| debian:12 | 1.1 s | 19 MB | 2.8-3.1 s (docker wall 3.2-3.4) | 0.4 s | 0.72 / 0.35 / 0.31 s |
| debian:13 | 1.0-1.1 s | 21 MB | 3.0-3.3 s | 0.4 s | 0.82 / 0.44 / 0.31 s |
| ubuntu:22.04 | 2.5-3.4 s | 78 MB | 5.7-5.8 s | 0.4 s | 1.62 / 0.92 / 0.71 s |
| ubuntu:24.04 | 2.2-3.3 s | 52 MB | 4.9 s | 0.3-0.4 s | 1.11 / 0.63 / 0.47 s |
| ubuntu:latest | 1.7-3.1 s | 42 MB | 4.1-4.4 s | 0.5 s | 1.00 / 0.56 / 0.40 s |
Container start overhead is ~0.2-0.3 s; docker pull of the five images took 3-8 s each. Network variance dominates update. smoke.sh (hermetic) takes a few seconds per image. Because the images have no apt binary cache, budget ~0.3-1.6 s per apt invocation in tests, and prefer one batched call over per-package loops. apt-get install bats (no recommends) +0.7-1.7 s; with recommends 5.7 s (debian:12) / 13 s (ubuntu:24.04).
12. Differences between the five images (summary)
| aspect | debian:12 | debian:13 | ubuntu:22.04 | ubuntu:24.04 | ubuntu:latest (26.04) |
|---|---|---|---|---|---|
| apt / solver | 2.6.1 classic | 3.0.3 classic (behaves like 12) | 2.4.14 classic | 2.8.3 classic | 3.2.0, solver3 default |
| sources format | debian.sources (deb822) |
deb822 | sources.list (one-line) |
ubuntu.sources (deb822) |
ubuntu.sources |
repo string in Inst (real) |
Debian:12.15/oldstable, Debian-Security:12/oldstable-security |
Debian:13.7/stable, Debian-Security:13/stable-security |
Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security |
Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security |
Ubuntu:26.04/resolute-updates, Ubuntu:26.04/resolute-security |
Solving dependencies... line |
no | no | no | no | yes (in simulations/removals, stdout) |
dist-upgrade removes a manually-installed conflicting pkg |
yes | yes | yes | yes | no (kept back) |
Description-md5: in apt-cache show of installed-only |
value | empty | value | empty | empty |
Essential login (apt-get -y purge login) |
protected | protected | protected | protected | not Essential: purged without error |
| bats | 1.8.2 | 1.11.1 | 1.2.1, no helper libs | 1.10.0 | 1.13.0 |
Everything else compared with diff over a 385-line transcript of ~60 commands (update, list, simulate, hold, show/policy/dpkg -s, depends/rdepends, real upgrade, dist-upgrade, purge, autoremove, error paths): identical wording and exit codes across the five images apart from the rows above and checksums/sizes. Specifically identical: apt list stderr warning, Inst/Conf/Remv/Purg formats, kept back block, apt-mark messages, apt-cache depends/rdepends formats, E:/W: texts and exit codes (0 / 100), lock message, conffile failure, Essential protection message.
Pre-existing docker images were not touched (images-before.tsv compared: nothing missing or changed); only debian:12, debian:13, ubuntu:22.04, ubuntu:24.04, ubuntu:latest were pulled and only docker run --rm containers were used (one hung throwaway container of mine, caused by a test that re-ran the fixture over a locally modified conffile, was docker killed).
13. Item 13 - install reason (N? / info): apt-mark showauto / showmanual
Verified 2026-10-10 on debian:12 (apt 2.6.1), debian:13 (3.0.3), ubuntu:22.04 (2.4.14), ubuntu:24.04 (2.8.3) and ubuntu:latest (26.04.1, apt 3.2.0), fixture SU_FIXTURE_OFFLINE=1 SU_FIXTURE_ORPHAN=1 SU_FIXTURE_B_AUTO=1, LC_ALL=C. Identical on all five:
$ apt-mark showmanual su-dummy-a -> su-dummy-a (rc 0) $ apt-mark showauto su-dummy-a -> (empty, rc 0)
$ apt-mark showmanual su-dummy-b -> (empty, rc 0) $ apt-mark showauto su-dummy-b -> su-dummy-b (rc 0)
$ apt-mark showauto su-dummy-orphan -> su-dummy-orphan $ apt-mark showmanual no-such-pkg -> (empty, rc 0)
$ apt-mark showmanual su-dummy-a su-dummy-b su-dummy-c -> su-dummy-a (only the named packages)
- Chosen:
apt-mark showauto PKG-> dependency; elseapt-mark showmanual PKG-> explicitly installed; in neither list ->dpkg-query -W -f '${db:Status-Abbrev}' PKG: installed (2nd character neithernnorc) counts as manual, else not installed. No image had an installed package in neither list (installed = manual + auto exactly: debian:12 92 = 2 + 90, ubuntu:24.04 96 = 93 + 3, ...), so that branch is a safety net. - Read-only: both work as
nobody(setpriv --reuid=65534) and while another process holds dpkg'slock-frontendandlock(fcntl, like dpkg; checked withapt-mark hold, which fails then withdpkg frontend lock was locked by another process with pid N, exit 100): 0 s wait, same answers. - A removed package with its configuration files kept (
rc) is in neither list: not installed. A package installed withdpkg -iis manual (alsosu-dummy-creinstalled withdpkg -iafterdpkg -r). - apt-mark prints a native package without its architecture qualifier (
apt-mark showauto libc6:amd64->libc6) and nothing for an arch that is not installed (libc6:i386-> empty); a name that is not a package is treated as a pattern (apt-mark showmanual 'su-dummy-?'and'su-dummy-.'->su-dummy-a), exact names win (libapt-pkg6.0,libbz2-1.0,libdb5.3answered for themselves). Hence the tool matches the output line by line againstPKG(orPKGwithout:arch). - Fixture:
su-dummy-a/-care manual (dpkg -i),su-dummy-bis auto withSU_FIXTURE_B_AUTO=1(apt-mark auto su-dummy-b); no change needed.
14. Item 14 - full info (N?? / show): dpkg-query -s merged with apt-cache show --no-all-versions
Verified 2026-10-10 on debian:12 (apt 2.6.1, dpkg 1.21.23), LC_ALL=C: the fixture (SU_FIXTURE_OFFLINE=1 SU_FIXTURE_ORPHAN=1 SU_FIXTURE_B_AUTO=1) and the real archive after apt-get update (perl-base 5.36.0-7+deb12u3 installed, +deb12u4 in bookworm-security; bash, up to date).
- Installed record, two candidates compared:
dpkg-query -s PKG: Package, Essential, Status, Priority, Section, Installed-Size, Maintainer, Architecture, Multi-Arch, Source, Version, Replaces, Provides, (Pre-)Depends, Recommends, Suggests, Breaks, Conflicts, Conffiles (multi-line), Description (short + long, always: dpkg keeps it), Homepage. rc 1 andpackage 'x' is not installed and no information is availablefor an unknown name; a removed package with config files left still answers (Status: deinstall ok config-files).apt-cache show PKG=<installed version>: when that version is only in dpkg's status (the fixture's 1.0, installed withdpkg -i) it is the same record plus a computedDescription-md5; when the version is still in the lists (bash) it is the archive's record instead: no Status, no Conffiles, only the short description (Debian ships the long text in Translation files), plus Tag, Filename, Size, MD5sum, SHA256.- Chosen:
dpkg-query -s- the record of what is installed, with its local fields, independent of the lists.Statusthird wordnot-installed/config-files= not installed.
- Candidate:
apt-cache show --no-all-versions PKG(as before). Same RFC-822 layout (Key: value, continuation lines start with a space,.= empty line), keys in another order. Against the installed record: Version, Installed-Size and changed relations differ; Status, Conffiles, Multi-Arch (sometimes) only installed; Description-md5, Tag, Filename, Size, MD5sum, SHA256 only in the candidate. - Description: on the real archive the candidate's
Descriptionis one line (perl-base:Description: minimal Perl system) while dpkg has the long text; with Translation files downloaded it isDescription-en:with the long text (see item 3). SoDescription-xxpairs withDescription, and a one-line candidate equal to the installed first line counts as the same description. The fixture's local repository carries the long text inPackages, so there the two blocks really differ ((version 1.0)vs(version 2.0)). - Several installed architectures of one name (M-A: same) give several
dpkg-query -srecords: not paired, the plain candidate is shown.