# APT family (Debian / Ubuntu) - discovery for skeptical-updater Everything below was verified empirically in throwaway containers (`docker run --rm -i 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 -> `nameoldnewrepo` (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 ''` | | 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 -> `nameoldnewrepo` (`discovery/apt/parse-inst.awk`, POSIX awk, tested with mawk) ```awk /^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): ```sh 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): ```sh 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): ```sh 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): ```sh 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 `` [exit=0], never the dependents. - Not-installed package with `--installed`: still answers (lists installed dependents). Parse to plain names (one package per call): ```sh 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 ` itself prints only ``, so you must instead scan for the virtual in `apt-cache showpkg ` ("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: 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 `` 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 `` [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 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: `, 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-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") ```sh 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 `` 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). ### 6.4 Recommended (exact, no apt call): `discovery/apt/closure-dpkg.awk` ```sh 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-chold ``` - 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 ` 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). ```bash 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/.conf`, content `version=`), 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`, `/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 kill`ed). ## 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. ## 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 and `package 'x' is not installed and no information is available` for an unknown name; a removed package with config files left still answers (`Status: deinstall ok config-files`). * `apt-cache show PKG=`: when that version is only in dpkg's status (the fixture's 1.0, installed with `dpkg -i`) it is the same record plus a computed `Description-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. `Status` third word `not-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 `Description` is one line (perl-base: `Description: minimal Perl system`) while dpkg has the long text; with Translation files downloaded it is `Description-en:` with the long text (see item 3). So `Description-xx` pairs with `Description`, 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 in `Packages`, 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 -s` records: not paired, the plain candidate is shown.