Files
skeptical-updater/discovery/apt.md
T
bryanpedini 71d7f87aa3 feat(info): show the install reason and drop the indentation
N? and `info PKG` now print three lines, none indented:

    PKG: <description>
    reason: explicitly installed | installed as a dependency | unknown | not installed
    required by: <installed dependents, comma separated> | nothing

The reason comes from a new <be>_reason backend function: apt-mark
showauto / showmanual (dpkg decides for a package in neither list), the
Install Reason of pacman -Qi, dnf repoquery --installed %{reason} (dnf4
and dnf5 words) and the Installed line of zypper info (rpm plus libzypp's
AutoInstalled when zypper is locked). A row whose old version is "-" is
not installed and the backend is not asked. The "(installed)" qualifier
moves to --help, README and DESIGN.

The rpm E2E fixture and e2e_reset now also mark su-dummy-a and su-dummy-c
as user-installed on dnf, which records plain rpm installs as "unknown",
so 1? and 2? show both reasons on all 12 distros. The verified commands
are recorded in discovery/{apt,pacman,rpm}.md.
2026-10-10 13:32:42 +02:00

54 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):

  1. apt-get update exits 0 when every mirror is unreachable (only W: 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.
  2. apt list / apt show are the "unstable CLI": a warning is printed on stderr whenever stdout is not a tty. Use apt-get / apt-cache / dpkg-query / apt-mark for parsing.
  3. All five images run as root with no sudo, no python3; apt-get update must be run first (lists are empty).
  4. -y upgrade with a locally modified conffile that the new package also changes => dpkg prompts, hits EOF on stdin, fails, exit 100, package left in iU state. Always pass --force-confdef --force-confold.
  5. apt-cache depends --recurse cannot produce an exact installed closure: with --installed it 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 --installed it walks through non-installed alternatives/providers and uses candidate metadata (debian:12 bash: 19 names vs the exact 8). Use the dpkg-database awk (closure-dpkg.awk).
  6. apt-cache rdepends does not follow Provides (nobody is listed as reverse dependency of mawk although base-files needs awk), lists duplicates (one per version), and --installed filters on the package being installed, not the version that has the dependency.
  7. apt-get takes the dpkg lock: a concurrent apt gives exit 100 Could not get lock /var/lib/dpkg/lock-frontend (also apt-mark hold, with a misleading "Are you root?"). -o DPkg::Lock::Timeout=N waits instead.
  8. Holding a package also keeps back everything that needs a newer version of it (hold b => a kept back). apt-mark hold with a mix of valid/invalid names holds the valid ones and exits 100.
  9. stderr noise is not failure: minimal images print debconf: delaying package configuration, since apt-utils is not installed on every dpkg run. Trust exit codes.
  10. Plain apt-get install bats pulls parallel + perl (39 pkgs / 85 MB on debian:12, 60 / 159 MB on ubuntu:24.04) and may upgrade unrelated packages; use --no-install-recommends.
  11. ubuntu:latest is now 26.04 (apt 3.2.0, new solver3): prints Solving dependencies..., and dist-upgrade refuses 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: awk is mawk everywhere and sed is GNU sed 4.8/4.9. Verified on mawk: gensub() -> function gensub never defined; 3-argument match(s,r,arr) -> syntax error; regex brace repetition a{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/lists is empty at start: nothing works (apt-cache policy has no candidates) until apt-get update.
  • Docker-specific apt config shipped in the images: docker-clean (Dir::Cache::pkgcache "" => no binary cache, every apt-get/apt-cache/apt-mark call re-parses all lists), docker-no-languages (Acquire::Languages "none" => descriptions come from the Packages file, 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 bats works on all five (exit 0, 0.7-1.7 s, 1 package, <200 kB; bats --version as in the table; bats --tap works). Helper libs bats-support bats-assert bats-file exist on every image except ubuntu:22.04. Oldest is 1.2.1 (jammy) which lacks run --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 ... and su-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 upgrade will 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/Purg line prefixes are not translated, but the kept back header is, hence LC_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' read collapses 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.
  • Inst order is dependency order (b before a), not alphabetical: sort if 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) gives su-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):

  1. Provides is not followed: apt-cache rdepends --installed ... mawk => empty, although base-files (Pre-)Depends on the virtual awk that mawk provides. Fixture: su-dummy-w depends on su-virtual | su-dummy-alt, su-dummy-v provides su-virtual => rdepends su-dummy-v is empty. To be complete, also query every name in the target's Provides: (apt-cache show --no-all-versions P | sed -n 's/^Provides: //p') - but rdepends <virtual> itself prints only <virtual>, so you must instead scan for the virtual in apt-cache showpkg <virtual> ("Reverse Depends:" section lists su-dummy-w,su-virtual) or use the dpkg database.
  2. --installed filters on the dependent package being installed, not on the installed version having the dependency: rdepends --installed su-dummy-newdep lists su-dummy-d although only the not-yet-installed d 2.0 depends on it.
  3. 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): fixture apt-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 --installed only the installed dependencies ( |Depends: debconf; openssl and <debconf-2.0> disappear), and --recurse --installed yields ca-certificates (the root, although not installed) + debconf.
  • Unknown package: E: No packages found [exit=100]. Purely virtual root: prints <name> [exit=0].
  • depends P uses the candidate version's dependencies (fixture: installed su-dummy-d 1.0 has none, candidate 2.0 needs su-dummy-newdep => apt-cache depends su-dummy-d shows it; apt-cache depends su-dummy-d=1.0 shows nothing). Pin a version with P=VERSION to 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 (|alternatives are therefore all followed, installed or not).
  • substr($1,2,1) !~ /[nc]/: dpkg state is not n(ot-installed)/c(onfig-files only); ii, iU, iF count as installed, rc does not.
  • Use ${Package}:${Architecture} on both sides if foreign architectures matter (apt-cache prints name:arch for non-native only).
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 to dpkg, needs root and the dpkg lock (apt-mark hold while 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 in showhold, and blocks its installation as a new dependency (it made su-dummy-d kept back), but dpkg --get-selections does 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 (while a 2.0 needs b (>= 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-upgrade honours 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: a drags b along; d drags su-dummy-newdep; a not-installed name is skipped with exit 0; if the needed dependency is held it fails E: 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 P leaves conffiles and state rc (rc su-dummy-c=1.0, /etc/su-dummy/su-dummy-c.conf still there); a following apt-get -y purge P prints Purging configuration files for P and 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; coreutils gives the same on debian:13, ubuntu:22.04 and ubuntu:latest). login is not Essential on ubuntu 26.04 and was really purged there (exit 0) - use coreutils for 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-a 1.0 Depends: su-dummy-b; 2.0 Depends: su-dummy-b (>= 2.0); su-dummy-b; su-dummy-c standalone. Arch all (runs on any host arch). Each ships one data file and one conffile (/etc/su-dummy/<name>.conf, content version=<ver>), multi-line Description.
  • dpkg-deb --root-owner-group -Zgzip --build. Index: default SU_FIXTURE_INDEXER=shell (dpkg-deb -f + md5sum/sha256sum + hand-written Release with Origin/Label su-fixture, Suite/Codename su-repo and a valid Date) => no extra packages, no network needed. scanpackages (installs dpkg-dev) and ftparchive (installs apt-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 upgraded perl-base, i.e. the "real-world upgradable" package disappeared from the list) - prefer shell.
  • Missing Date: in Release gives W: 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 filename x6), 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-bad 2.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:12 perl-base, debian:13 liblzma5, ubuntu:22.04 libssl3, ubuntu:24.04 libaudit-common libaudit1 libssl3t64, ubuntu:latest libssl3t64 openssl-provider-legacy. They are time dependent: tests must not hard-code them; assert only on su-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; else apt-mark showmanual PKG -> explicitly installed; in neither list -> dpkg-query -W -f '${db:Status-Abbrev}' PKG: installed (2nd character neither n nor c) 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's lock-frontend and lock (fcntl, like dpkg; checked with apt-mark hold, which fails then with dpkg 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 with dpkg -i is manual (also su-dummy-c reinstalled with dpkg -i after dpkg -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.3 answered for themselves). Hence the tool matches the output line by line against PKG (or PKG without :arch).
  • Fixture: su-dummy-a/-c are manual (dpkg -i), su-dummy-b is auto with SU_FIXTURE_B_AUTO=1 (apt-mark auto su-dummy-b); no change needed.