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

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

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

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

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

618 lines
34 KiB
Markdown

# Discovery: pacman (Arch Linux) and `pman`
Everything below was run in throwaway containers (`docker run --rm -i`, `LC_ALL=C`) on
2026-10-09 against `archlinux:latest` (pacman 7.1.0 / libalpm 16.0.1, bash 5.3.20).
Companion files (all in `discovery/pacman/`):
| file | purpose |
|--------------|---------|
| `fixture.sh` | E2E fixture: dummy packages `su-dummy-{a,b,c}` 1.0 installed, 2.0 offered by a local repo |
| `lib.sh` | proven bash helpers (`pm_upgradable`, `pm_field`, `pm_closure`, `pm_plan`, ...) |
| `checks.sh` | executable proof of this document (`ALL CHECKS PASSED` on `archlinux:latest` and `archlinux:base-devel`) |
Re-run the proof:
```
docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/checks.sh
```
## 0. Summary / chosen commands
| need | command |
|------|---------|
| refresh (root) | `pacman -Sy` (then apply with `pacman -Su`, see section 1) |
| upgradable list | `pacman -Qu` -> `name old -> new [ignored]`; repo from `pacman -Sl` (first repo with that name wins) |
| exact transaction (incl. new deps) | `pacman -Sup --noconfirm [--ignore a,b] --print-format '%r/%n %v'` |
| description | `pacman -Qi -- name` / `pacman -Si -- repo/name`, field `Description` |
| installed reverse deps | `pacman -Qi` fields `Required By` / `Optional For` |
| dependency closure | one `pacman -Qi` dump + awk BFS (`pm_closure`); `pactree` is not in the base image |
| per-run exclusion | `pacman -Su --noconfirm --ignore a,b` (or repeated `--ignore`); globs work |
| upgrade | `pacman -Su --noconfirm` (`-Syu` if you did not refresh before) |
| purge | `pacman -Rns --noconfirm -- pkg...` |
Top gotchas (details below):
1. The fresh image has **no sync databases**: `pacman -Si/-Sl/-Qu` are useless (and `-S bats` says
"not found") until `pacman -Sy` has run.
2. `pacman -Qu` has **no `--ignore`** (`error: invalid option '--ignore'`, exit 1). Per-run exclusions must be
filtered by the tool itself; `[ignored]` is only shown for IgnorePkg/IgnoreGroup from the config.
3. `pacman -Qu` exits **1** when nothing is upgradable (empty output); `-Su`/`-Syu` exit **0** with
` there is nothing to do`.
4. Excluding a package that another upgraded package needs at a higher version makes the **whole
transaction fail** under `--noconfirm` (exit 1, nothing upgraded). Pre-check with `pacman -Sup`.
5. `-Qi`/`-Si` **wrap** long values at `$COLUMNS` (even when piped) if `COLUMNS` is exported. Use `COLUMNS=0` and
a continuation-aware parser (lib.sh does both).
6. `pactree -l -u` prints **dependency strings** (`libncursesw.so=6-64`, `linux-api-headers>=4.10`), not package
names, and `pacman-contrib` is not installed.
7. The image's `/etc/pacman.conf` ends with a second `[options]` block (NoExtract lines). Appending
`Include = /etc/pacman.d/ignorepkg` at EOF works *by accident* here; on a stock Arch host the last section is `[extra]`
and pacman just warns `directive 'IgnorePkg' in section 'extra' not recognized.` and ignores nothing.
8. `makepkg` refuses root (exit 10); `runuser -u <user> --` works, `sudo` is absent.
## 1. Refreshing metadata
```
$ pacman -Sy # root; image has only /var/lib/pacman/{local,sync(empty)} before this
:: Synchronizing package databases...
core downloading...
extra downloading...
exit=0
```
Timing: cold `-Sy` 0.21-0.25 s, warm 0.12 s (fast CDN; core.db 130 KB, extra.db 8.8 MB). A second `-Sy`
prints the same "downloading..." lines (NoProgressBar), so the output cannot tell "up to date" from "refreshed".
On a fresh image (no `-Sy`):
```
$ pacman -Qu ; echo $?
warning: database file for 'core' does not exist (use '-Sy' to download)
warning: database file for 'extra' does not exist (use '-Sy' to download)
1
$ pacman -Si bats
error: package 'bats' was not found
```
How many upgrades does a fresh image have? Today only 6 (image built 5 days earlier):
```
$ pacman -Sy && pacman -Qu
ca-certificates-mozilla 3.130-1 -> 3.131-1
device-mapper 2.03.42-1 -> 2.03.43-2
expat 2.8.5-1 -> 2.9.0-1
glib2 2.88.3-1 -> 2.90.1-1
hwdata 0.411-1 -> 0.412-1 (+ libsysprof-capture)
```
The count is time-dependent; tests must not assert on it. Use `FIXTURE_ISOLATE=1` (section 10) for an exact set.
### Partial-upgrade concern
`pacman -Sy` alone moves the sync DB ahead of the installed packages. That is only dangerous if you then install
something (`pacman -S foo`) without a full upgrade; a `-Sy` immediately followed by `-Su` is the same as `-Syu`.
Two safe designs:
* **A (recommended, simple):** `pacman -Sy`, list with `-Qu`, let the user exclude, then apply with
`pacman -Su --noconfirm --ignore ...` (note `-Su`, not `-Syu`: it applies exactly the reviewed DB snapshot;
`-Syu` would re-sync and could add newer, unreviewed versions). If the user aborts after the refresh, tell them
the DB is refreshed (do not run `pacman -S x` without `-u`).
* **B (no side effects, root, no extra packages):** temporary sync DB. Verified:
```
tmp=$(mktemp -d /tmp/checkdb.XXXXXX); chmod 755 "$tmp" # 755 is REQUIRED (see below)
ln -s /var/lib/pacman/local "$tmp/local"
pacman -Sy --dbpath "$tmp" --logfile /dev/null # exit 0
pacman -Qu --dbpath "$tmp" # same list as design A
pacman -Sup --dbpath "$tmp" --print-format '%r/%n %v' # same transaction view
ls /var/lib/pacman/sync # still empty: system DB untouched
```
With the default `mktemp -d` mode 0700 it fails: `error: could not open file
/tmp/checkdb.X/sync/download-XXXX/core.db.part: Permission denied` (the sandboxed download user `alpm` cannot
traverse it); `chmod 755` or `--disable-sandbox` fixes it. When applying, use `-Syu` (re-sync) since the real
DB was not refreshed. `lib.sh: pm_refresh_tmpdb` sets `PM_OPTS=(--dbpath ...)`.
* **`checkupdates` (pacman-contrib) is NOT in the base image** (`archlinux:latest` and `:base-devel` both lack it
and `pactree`), and it hard-requires `fakeroot` (`Cannot find the fakeroot binary`; also absent from `latest`,
present in `base-devel`) even as root. After `pacman -S pacman-contrib fakeroot` it works: prints the same `-Qu`
lines (drops `[ignored]` rows, per its source `grep -v '\[.*\]'`), exit **0** = updates (verified), **2** = none
(verified), 1 = error (from the source: `Cannot fetch updates`). It is just B with
`fakeroot` instead of `chmod 755`; prefer B.
`pacman -Qu` and `pacman -Sup` need no root (verified as a normal user, exit 0), only the refresh and the real upgrade do.
## 2. Machine-parsable upgradable list
`pacman -Qu` (piped: no colour even with `Color` in pacman.conf; add `--color never` to be safe):
```
$ pacman -Qu
su-dummy-a 1.0-1 -> 2.0-1
su-dummy-b 1.0-1 -> 2.0-1
su-dummy-c 1.0-1 -> 2.0-1
$ pacman -Qu -q # names only
```
With `IgnorePkg` (from `Include = /etc/pacman.d/ignorepkg` inside `[options]`, one `IgnorePkg = x` per line,
several names per line allowed, globs allowed):
```
$ pacman -Qu
ca-certificates-mozilla 3.130-1 -> 3.131-1
expat 2.8.5-1 -> 2.9.0-1 [ignored]
glib2 2.88.3-1 -> 2.90.1-1 [ignored] # after "IgnorePkg = glib2 hwdata"
```
Format: `name SP oldver SP "->" SP newver [SP "[ignored]"]`; versions contain no spaces (epochs look like `1:1.37.2-3`).
Exit 0 if >=1 line, exit 1 and empty output otherwise (`[ignored]` rows count as lines, so exit stays 0 when
only ignored rows remain).
Repo of the candidate: `-Qu` does not print it. Use `pacman -Sl` (`repo name version [installed[: oldver]]`):
```
$ pacman -Sl | grep -E ' (expat|glib2) '
core expat 2.9.0-1 [installed: 2.8.5-1]
core glib2 2.90.1-1 [installed: 2.88.3-1]
```
A name can be in several repos. Verified with `su-dummy-b` 2.0-1 in `[su-dummy]` and 3.0-1 in a later
`[su-dummy2]`: `-Qu` reports `1.0-1 -> 2.0-1` (the **first repo in pacman.conf order** wins), `-Sl` lists both
rows in config order, and `-Si su-dummy-b` prints *both* blocks, so take the first `-Sl` row per name and use
`repo/name` for `-Si`. Exact parse (this is `lib.sh: pm_upgradable`, output
`name<TAB>old<TAB>new<TAB>repo<TAB>ignored(0|1)`):
```bash
awk '
FNR == NR { if (!($2 in repo)) repo[$2] = $1; next } # pacman -Sl
{ printf "%s\t%s\t%s\t%s\t%d\n", $1, $2, $4, repo[$1], ($5 == "[ignored]") } # pacman -Qu
' <(LC_ALL=C pacman -Sl) <(LC_ALL=C pacman -Qu)
```
```
su-dummy-a 1.0-1 2.0-1 su-dummy 0
ca-certificates-mozilla 3.130-1 3.131-1 core 0
```
Alternative that also yields the repo directly and is authoritative for what `-Su` will do:
`pacman -Sup --print-format '%r|%n|%v|%s|%l'` (`%s` download size, `%l` URL; works as non-root; dependency order;
**includes new dependencies and skips ignored packages**, which `-Qu` does not):
```
$ pacman -Sup --print-format '%r|%n|%v|%s'
core|ca-certificates-mozilla|3.131-1|391549
...
```
Verified with `su-dummy-a` 3.0 depending on a brand-new `su-dummy-new`: `-Qu` listed 3 packages, `-Sup`
listed 4 (`su-dummy-new 1.0-1` appears before `su-dummy-a`), and the new package is installed "as a dependency".
So a skeptical UI should show `-Qu` rows plus "also installs: ..." from `-Sup` minus `-Qu`.
Not covered by `-Qu`: packages pulled in via `replaces`.
## 3. Description and full info
```
$ pacman -Qi bash | head -5
Name : bash
Version : 5.3.20-1
Description : The GNU Bourne Again shell
Architecture : x86_64
URL : https://www.gnu.org/software/bash/bash.html
$ pacman -Si su-dummy/su-dummy-a | head -4 # the candidate (new version)
Repository : su-dummy
Name : su-dummy-a
Version : 2.0-1
Description : Skeptical-updater dummy package A
```
* Installed: `pacman -Qi -- name`; candidate: `pacman -Si -- repo/name` (always qualify with the repo, see the
two-repo note above). `-Si` has `Repository`, `Download Size`, `Build Date`, new `Depends On`; it has **no**
`Required By`/`Install Reason` (those only exist for `-Qi`).
* Description only (simple, single target, piped, no `COLUMNS` exported):
`pacman -Qi -- bash | sed -n 's/^Description *: //p'`.
* Robust version (any number of targets, wrapped lines joined, field split at the first colon) is
`lib.sh: pm_field -Qi|-Si Field target...` -> `name<TAB>value`:
```bash
pm_field() { local op=$1 field=$2; shift 2
LC_ALL=C COLUMNS=0 pacman "$op" -- "$@" 2>/dev/null | awk -v f="$field" '
function flush() { if (name != "") print name "\t" val; name = ""; val = ""; infield = 0 }
/^[ \t]*$/ { flush(); next }
/^[^ \t]/ { infield = 0; i = index($0, ":"); k = substr($0, 1, i-1); sub(/[ \t]+$/, "", k)
v = substr($0, i+1); sub(/^[ \t]+/, "", v)
if (k == "Name") name = v; else if (k == f) { val = v; infield = 1 }; next }
infield { sub(/^[ \t]+/, ""); val = val " " $0 }
END { flush() }'; }
```
`pm_field -Qi Description bash` -> `bash<TAB>The GNU Bourne Again shell`; also unaffected by `COLUMNS=20`
(verified: raw `COLUMNS=20 pacman -Qi bash` does wrap).
* Full dump = the raw command output, never wrapped when `COLUMNS=0` or unset and stdout is not a tty
(wrapping is driven by the `COLUMNS` env var, not by a real tty: `stty cols 100` + pipe did not wrap, `COLUMNS=80` +
pipe did). `Optional Deps` is the exception: one entry per line (continuation lines are *new entries*, not wraps):
```
Optional Deps : diffutils: for pacdiff
fakeroot: for checkupdates
```
* Missing target: `error: package 'x' was not found` on stderr, exit 1, other targets still printed.
## 4. Installed reverse dependencies
`pacman -Qi` fields (names only, no versions, two-space separated, `None` when empty; computed over installed
packages and already resolved through provides, e.g. `bash` is listed as a dependent of `readline` although it
depends on `libreadline.so=8-64`):
```
$ pacman -Qi bash | grep -E '^(Required By|Optional For)'
Required By : audit base bzip2 ca-certificates-utils device-mapper e2fsprogs gawk ...
Optional For : ncurses pcre2 tzdata
```
```bash
pm_required_by() { pm_field -Qi 'Required By' "$1" | cut -f2- | tr -s ' ' '\n' | grep -vxE 'None|' | sort -u; }
pm_optional_for() { pm_field -Qi 'Optional For' "$1" | cut -f2- | tr -s ' ' '\n' | grep -vxE 'None|' | sort -u; }
```
Proven: `pm_required_by su-dummy-b` -> `su-dummy-a`; `pm_required_by su-dummy-a` -> empty;
`pm_optional_for bash` -> `ncurses pcre2 tzdata`; `pm_required_by glibc | wc -l` -> 111 (a 1021-char line).
Note `-Si` does not have these fields; for the candidate, reverse deps = the installed ones.
## 5. Recursive dependency closure restricted to installed packages
Premise check: **`pacman -Qq`/`-Qi`/`-Q` DO resolve provides and version-constrained dependency strings in
pacman 7.1** (they use the satisfier lookup), so no special handling is needed on this version:
```
$ pacman -Qq sh -> bash (exit 0)
$ pacman -Qq 'glibc>=2.38' -> glibc (exit 0)
$ pacman -Qq libreadline.so=8-64 -> readline (exit 0)
$ pacman -Qq 'glibc>=99' -> error: package 'glibc>=99' was not found (exit 1)
$ pacman -T sh ; echo $? -> (nothing) 0 ; pacman -T nonexist -> "nonexist", exit 127
$ pacman -Qqs '^sh$' -> bash (-Qs also searches provides, but it is a regex search; do not use)
```
(Unquoted `>=` is a shell redirect, always quote dependency strings. I did not verify older pacman releases, so
`pm_closure` below does not rely on this.)
`pactree` (pacman-contrib, **absent** from `latest` and `base-devel`): after installing it,
`pactree -l -u bash` prints dependency strings, not names:
```
$ pactree -l -u bash
bash
readline
glibc
linux-api-headers>=4.10
tzdata
filesystem
iana-etc
libncursesw.so=6-64 <- provided name, the package "ncurses" itself never appears
libgcc
libstdc++
$ pactree -l -u pacman | wc -l -> 108 (contains libcrypt.so=2-64, libp11-kit=0.26.5-1, ca-certificates-utils>=20181109-3, ...)
```
So to use it you must resolve every line: `pactree -l -u P | xargs -r pacman -Qq -- | sort -u` (works on 7.1).
`pactree -r -l -u P` is the reverse closure. `pactree -l -u nonexist` -> `error: package 'nonexist' not found`, exit 1.
**Pure-pacman implementation** (`lib.sh: pm_closure`): a single `pacman -Qi` dump (all installed packages, ~10 ms)
parsed by one awk process that builds `name`, `Provides` -> provider map, `Depends On`, `Required By`,
`Optional For`, then BFS. A dependency string `d` is resolved as: strip the constraint (`sub(/[<>=].*/,"")`);
if it is a real installed package use it; otherwise use every installed package whose `Provides` has that base
name. Usage:
```
pm_closure bash # forward closure, roots excluded, sorted, one real name per line
pm_closure -r glibc # reverse closure via "Required By" (add -o to follow "Optional For" too)
pm_closure sh # roots may be provided names -> same as bash
```
Sample: `pm_closure bash` -> `filesystem glibc iana-etc libgcc libstdc++ linux-api-headers ncurses readline tzdata`
(9 packages; pactree printed 10 strings, including `libncursesw.so=6-64` for `ncurses`).
Proof (`checks.sh`, all PASS): for `bash` (9), `pacman` (107) and `su-dummy-a` (1), the output equals
`pactree -l -u P | xargs -r pacman -Qq -- | sort -u` minus the root; reverse closures of `glibc` and `bash` equal
`pactree -r -l -u`. If several installed packages provide the same name the closure is a superset of pactree's
single pick (does not happen in the base image). Optional deps are excluded (as in pactree's default).
## 6. Per-run exclusion
Both forms work (verified, `--noconfirm`, fixture):
```
pacman -Su --noconfirm --ignore su-dummy-a,su-dummy-c # comma separated
pacman -Su --noconfirm --ignore su-dummy-a --ignore su-dummy-c # repeated
pacman -Su --noconfirm --ignore 'su-dummy-[ab]' # globs work, like IgnorePkg
```
Result: `exit=0`, only `su-dummy-b` upgraded; the warning goes to **stderr**:
```
warning: su-dummy-a: ignoring package upgrade (1.0-1 => 2.0-1)
warning: su-dummy-c: ignoring package upgrade (1.0-1 => 2.0-1)
```
Ignoring everything: stdout `:: Starting full system upgrade...` + ` there is nothing to do`, warnings on stderr, exit 0.
* `pacman -Qu --ignore x` is **invalid** (`error: invalid option '--ignore'`, exit 1): `[ignored]` marks only
`IgnorePkg`/`IgnoreGroup` from the configuration. Effective list wherever it was configured:
`pacman-conf IgnorePkg` (one pattern per line; `IgnorePkg = glib2 hwdata` -> two lines).
* Include placement: `Include = /etc/pacman.d/ignorepkg` must be inside `[options]`. Inserted right after the first
header with `sed -i '0,/^\[options\]/s||[options]\nInclude = /etc/pacman.d/ignorepkg|' /etc/pacman.conf`
(that is what `fixture.sh` does). Appended at EOF of a stock config (last section `[extra]`):
`warning: config file /etc/pacman.d/ignorepkg, line 1: directive 'IgnorePkg' in section 'extra' not recognized.`
and the package is still upgraded. After writing, verify with `pacman-conf IgnorePkg`.
* **Dependency trap** (`FIXTURE_STRICT_DEP=1`: `su-dummy-a` 2.0 needs `su-dummy-b>=2.0`), `--ignore su-dummy-b`
(or `IgnorePkg = su-dummy-b`):
```
warning: su-dummy-b: ignoring package upgrade (1.0-1 => 2.0-1)
warning: cannot resolve "su-dummy-b>=2.0", a dependency of "su-dummy-a"
:: The following package cannot be upgraded due to unresolvable dependencies:
su-dummy-a
:: Do you want to skip the above package for this upgrade? [y/N]
:: unable to satisfy dependency 'su-dummy-b>=2.0' required by su-dummy-a
error: failed to prepare transaction (could not satisfy dependencies) exit=1
```
With `--noconfirm` the default answer is N: the whole run aborts and **nothing** (not even `su-dummy-c`) is
upgraded. Same for excluding a *new dependency* (`--ignore su-dummy-new`). Pre-check without root/side effects:
`pacman -Sup --noconfirm --ignore su-dummy-b --print-format '%r/%n %v' </dev/null` -> exit 1, stdout
`:: unable to satisfy dependency ...`, stderr `error: failed to prepare transaction ...`
(`lib.sh: pm_plan [skip...]`; exit 0 and an empty list when everything is ignored).
`--ask 16` (verified) answers that prompt "yes": `pacman -Su --noconfirm --ask 16 --ignore su-dummy-b` exits 0, upgrades
only `su-dummy-c` and silently leaves `su-dummy-a` behind. Probably not what a skeptical user wants; at most offer it
as an explicit choice after showing the `pm_plan` failure.
## 7. Non-interactive upgrade, exit codes
```
$ pacman -Syu --noconfirm; echo $? # upgrades; prints the ":: Proceed with installation? [Y/n]" line but auto-answers
0
$ pacman -Syu --noconfirm # nothing left
:: Synchronizing package databases...
su-dummy downloading...
:: Starting full system upgrade...
there is nothing to do <- leading space; exit 0
$ pacman -Qu; echo $? # nothing upgradable: no output
1
```
| situation | exit | message |
|-----------|------|---------|
| upgrade ok / nothing to do | 0 | ` there is nothing to do` (stdout) |
| `pacman -Qu` nothing upgradable | **1** | (empty) |
| not root (`-Sy`, `-Su`, `-Rns`) | 1 | `error: you cannot perform this operation unless you are root.` |
| db locked (`/var/lib/pacman/db.lck`) | 1 | `error: failed to synchronize all databases (unable to lock database)` (`-Qu`/`-Qi` still work, exit 0) |
| excluded dep unsatisfiable | 1 | see section 6 |
| `checkupdates` | 0 updates / 2 none / 1 error | |
`--noconfirm` answers every question with its default: install prompts are Y, but "skip unresolvable package?",
conflict removal and HoldPkg prompts are N (they abort with exit 1).
Messages are split over stdout and stderr (`warning:`/`error:` -> stderr, `:: unable to satisfy ...` -> stdout),
so capture both.
## 8. Purge
```
$ pacman -Rns --noconfirm su-dummy-a ; echo $? # a explicit, b installed --asdeps and only needed by a
removing su-dummy-a...
removing su-dummy-b... # -s also removed the now-unneeded dependency
0
$ pacman -Rns --noconfirm su-dummy-b ; echo $? # required by a
checking dependencies... (stdout)
:: removing su-dummy-b breaks dependency 'su-dummy-b' required by su-dummy-a
error: failed to prepare transaction (could not satisfy dependencies) (stderr)
1 # nothing removed
$ pacman -Rns --noconfirm nope ; echo $? -> error: target not found: nope 1
$ pacman -Rns --noconfirm su-dummy-c nope -> same error, exit 1, su-dummy-c NOT removed (all or nothing)
$ pacman -Rns --noconfirm su-dummy-b su-dummy-a -> 0 (order irrelevant, both removed)
$ pacman -Rns --noconfirm pacman -> breaks dependency 'pacman' required by base, exit 1
$ pacman -Rdd --noconfirm pacman -> ":: HoldPkg was found in target list. Do you want to continue? [y/N]" exit 1
```
`-R` without `-s` leaves the dependency as an orphan (`pacman -Qdtq` -> `su-dummy-b`).
## 9. Base image facts
| item | `archlinux:latest` (= `archlinux:base`, same manifest digest) | `archlinux:base-devel` |
|------|-----|-----|
| exists | yes; also `archlinux:multilib-devel` | yes (pulled for this test, 950 MB vs 399 MB) |
| bash | 5.3.20 | 5.3.20 |
| pacman | 7.1.0 (libalpm 16.0.1) | 7.1.0 |
| installed packages | 137 | 170 |
| sudo | **no** | yes (`/etc/sudoers` has only `root ALL=(ALL:ALL) ALL`) |
| python3 | **no** (core/python 3.14.7-1, 13.5 MB download) | yes |
| fakeroot | **no** (core/fakeroot 1:1.37.2-3, 77 KB) | yes |
| makepkg | yes (`/usr/sbin/makepkg`) but unusable without fakeroot | yes, works |
| gcc/make/debugedit | no | yes |
| pacman-contrib (`checkupdates`, `pactree`) | no (extra/pacman-contrib 1.13.1-1) | no |
| bats / git | no / no | no / no |
| su, runuser, bsdtar, repo-add, pacman-conf, vercmp, curl, awk(gawk) | yes | yes |
| default `$LANG` | `C.UTF-8` (set `LC_ALL=C` anyway) | same |
| sync DBs | **absent** until `pacman -Sy` | absent |
`which` is not installed; use `command -v`. All binaries live in `/usr/bin` (`/usr/sbin` is a symlink).
**bats**: `extra/bats 1.14.0-1` (installs `parallel`, `perl`, `db5.3` as deps). After `pacman -Sy`:
`pacman -S --noconfirm bats` took 6.3 s, `bats --version` -> `Bats 1.14.0`, a smoke test printed `1..1` / `ok 1 hello`.
Without a prior `-Sy`: `pacman -Si bats` -> `error: package 'bats' was not found`.
(Installing straight after `-Sy` works today; the proper form on a stale image is `pacman -Syu` first.)
**makepkg as root** (exit 10):
```
==> ERROR: Running makepkg as root is not allowed as it can cause permanent,
catastrophic damage to your system.
```
(`--asroot` no longer exists: `makepkg: invalid option '--asroot'`.) Recipe, verified in both images
(on `latest` first `pacman -Sy --noconfirm fakeroot`; the base-devel image already has it):
```
useradd -m builder
runuser -u builder -- bash -c 'cd /home/builder/pkg && makepkg --force --nodeps --noconfirm' # exit 0
su -s /bin/bash builder -c 'cd /home/builder/pkg && makepkg --force --nodeps' # also works
sudo -u builder ... # works in base-devel (verified); sudo does not exist in archlinux:latest
```
Use `PKGDEST=/dir` (writable by the build user!) to choose where the `.pkg.tar.zst` goes and
`options=('!debug' '!strip')` in the PKGBUILD for `arch=('any')` payloads: without it makepkg 7 aborts on `latest` with
`==> ERROR: Cannot find the debugedit binary required for including source files in debug packages.`
(debugedit is only in base-devel). `--nodeps` skips the dependency check (no sudo needed).
## 10. E2E fixture (`discovery/pacman/fixture.sh`)
```
docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
fixture: builder=tar isolate=0 repo=/srv/su-repo
fixture: installed: su-dummy-a 1.0-1;su-dummy-b 1.0-1;su-dummy-c 1.0-1;
fixture: repo list: core extra su-dummy
exit 0
```
What it does (root, inside the container):
1. Builds `su-dummy-{a,b,c}` at 1.0-1 (to install) and 2.0-1 (to offer). `su-dummy-a` has `depend = su-dummy-b`.
Each ships `/usr/share/su-dummy/<name>/VERSION` (contains the version) so tests can check an upgrade really
happened. Descriptions: `Skeptical-updater dummy package A` (B has "(dependency of A)", C "(independent)").
2. **Default builder = `tar`**: hand-made `.pkg.tar.zst` containing only `.PKGINFO` + payload
(`bsdtar --zstd -cf ... .PKGINFO usr`). Verified that `pacman -U`, `repo-add`, `-Qi`, `-Qk` and `-Syu` accept
it without `.MTREE`/`.BUILDINFO`. No network, no fakeroot, no build user.
`FIXTURE_BUILDER=makepkg` uses real makepkg as user `su-builder` via `runuser` (installs fakeroot with
`pacman -Sy fakeroot` if missing, so network; sets `PKGDEST`, `SOURCE_DATE_EPOCH`, `options=('!debug' '!strip')`).
Both verified on `latest` and `base-devel`; both produce identical `-Qu` results.
3. `repo-add` creates `/srv/su-repo/su-dummy.db.tar.gz` (+`.db` symlink) containing the 2.0 packages only;
appends to `/etc/pacman.conf`:
```
[su-dummy]
SigLevel = Optional TrustAll
Server = file:///srv/su-repo
```
4. Installs the 1.0 packages with `pacman -U`, then `pacman -D --asdeps su-dummy-b` (a and c explicit, b "Installed as
a dependency for another package", so `pacman -Rns su-dummy-a` removes b too).
5. Creates an empty `/etc/pacman.d/ignorepkg` and inserts `Include = /etc/pacman.d/ignorepkg` after the first
`[options]` header (pman layout).
6. Does **not** sync (unless `FIXTURE_SYNC=1`), so the tool under test performs the refresh itself.
Result after `pacman -Sy`:
```
$ pacman -Qu | tail -3
su-dummy-a 1.0-1 -> 2.0-1
su-dummy-b 1.0-1 -> 2.0-1
su-dummy-c 1.0-1 -> 2.0-1
$ pacman -Sl | grep dummy
su-dummy su-dummy-a 2.0-1 [installed: 1.0-1]
```
Knobs: `FIXTURE_ISOLATE=1` comments out `[core]`/`[extra]` so `-Qu` lists **exactly** the 3 dummies, works offline
and is deterministic (default mode additionally lists whatever real upgrades the image has, 6 today; filter with
`grep '^su-dummy-'`); `FIXTURE_STRICT_DEP=1` makes a-2.0 depend on `su-dummy-b>=2.0` (the section 6 trap);
`FIXTURE_REPO`, `FIXTURE_SYNC`. The file can be `source`d: it defines `fx_build_tar <outdir> <name> <ver> <desc> [dep...]`
and does not enable `set -e` in the caller (used to publish extra packages, e.g. the `su-dummy-new` and two-repo
experiments above).
Typical flow in a test: `FIXTURE_ISOLATE=1 bash fixture.sh; pacman -Sy; <tool>; pacman -Q su-dummy-a` ->
`su-dummy-a 2.0-1`, `cat /usr/share/su-dummy/su-dummy-a/VERSION` -> `2.0`.
Re-running the fixture in the same container is safe (verified): `[su-dummy]` and the `Include` are not duplicated,
the repo is rebuilt, and if the packages were already upgraded to 2.0 they are *downgraded* back to 1.0 by `pacman -U`
(so it also works as a reset between test cases).
## 11. Timings (this host, fast CDN, three runs agree within 0.05 s)
| step | time |
|------|------|
| container start (`docker run --rm archlinux:latest true`) | 0.30 s |
| `pacman -Sy` cold / warm | 0.21-0.25 s / 0.12 s |
| fixture (tar builder, includes 3 installs + repo-add) | 0.23 s |
| fixture (makepkg builder, includes `pacman -Sy fakeroot`, 6 makepkg runs) | 7.3-7.7 s (one cold run: 12.9 s) |
| `pm_upgradable` (`-Sl` + `-Qu` + awk) | 0.15 s |
| `pm_closure pacman` (107 packages) | 0.02 s |
| `pacman -S --noconfirm bats` | 6.3 s |
| `pacman -S pacman-contrib` | 0.4 s |
| whole `checks.sh` incl. container start, contrib install and a full real `-Su` | ~5-8 s |
| image pull `archlinux:latest` | ~10 s (399 MB); `base-devel` 950 MB |
## 12. pman mapping and bugs found
pman (`/home/bryanpedini/projects/sources/pman/pman.sh`) is a bash `case` over verbs; it prepends `sudo` when not
root and the verb needs it (`config`, `install`, `update`, `upgrade`, `remove`, `purge`, `clean`, `provides`,
`providefiles`, `reflector`, `ignore`, `unignore` when `$UID != 0`; it exits 1 if sudo is absent, as in the base image
when not root: verified `pman upgrade` as an unprivileged user in `archlinux:latest` -> exit 1; in that container the
`sudo command needed but not found` message itself was swallowed by `echo ... > /dev/stderr` failing with
`Permission denied`, a side effect of `runuser` + docker's root-owned stderr pipe).
| pman verb | runs | skeptical-updater uses |
|-----------|------|------------------------|
| `update` | `pacman -Sy` then prints `N packages are available for update` (`pacman -Qu \| wc -l`, **counts `[ignored]` rows**) | refresh |
| `upgradable` | `pacman -Qu` | list |
| `localinfo` / `info` | `pacman -Qi` / `-Si` | info (`info` shows both repos if duplicated) |
| `upgrade` | `pacman -Syu` + extra args (`pman upgrade --noconfirm --ignore a,b` works, verified) | apply (prefer `-Su` after a refresh) |
| `purge` / `remove` | `pacman -Rns` / `-Rs` | purge |
| `ignored` | `echo "$(sed 's/IgnorePkg = //' /etc/pacman.d/ignorepkg \| sort)"` | read |
| `ignore p...` | appends `IgnorePkg = p` to `/etc/pacman.d/ignorepkg` | write |
| `unignore p...` | `sed -i "/IgnorePkg = p/d"` | write |
Verified end to end with the fixture as root: `pman update` -> `3 packages are available for update`;
`pman ignore su-dummy-c` + `pman upgradable` -> `su-dummy-c 1.0-1 -> 2.0-1 [ignored]`; `pman upgrade --noconfirm`
printed `warning: su-dummy-c: ignoring package upgrade (1.0-1 => 2.0-1)` and upgraded a and b (exit 0);
`pman purge --noconfirm su-dummy-a` removed a and b.
pman bugs (verified; relevant if the tool reuses `/etc/pacman.d/ignorepkg`):
* `pman ignore foo` after `pman ignore barfoo` prints `foo is already ignored, skipping...` (the check is
`grep -e "$pkg$"`, an unanchored suffix match) and `foo` is never added.
* `pman unignore su-dummy` deletes the line `IgnorePkg = su-dummy-c` (`sed "/IgnorePkg = su-dummy/d"` is an
unanchored prefix/substring match); regex characters in names are not escaped either.
* `ignored`/`ignore`/`unignore` assume one package per line in `IgnorePkg = <pkg>` form and never check that
`pacman.conf` actually includes the file in `[options]` (silently ineffective, section 6).
* The tool should read the effective list with `pacman-conf IgnorePkg` and write with anchored matches
(`grep -qxF "IgnorePkg = $pkg"`, `sed -i "/^IgnorePkg = ${pkg}\$/d"` after escaping).
## 13. Install reason (`N?` / `info`): `Install Reason` of `pacman -Qi`
Verified 2026-10-10 on archlinux:latest (pacman 7.1.0), fixture `FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1 FIXTURE_SYNC=1`, `COLUMNS=0 LC_ALL=C pacman -Qi -- PKG`:
```
su-dummy-a Install Reason : Explicitly installed rc 0
su-dummy-b Install Reason : Installed as a dependency for another package rc 0 (-D --asdeps)
su-dummy-c Install Reason : Explicitly installed rc 0
su-dummy-orphan Install Reason : Installed as a dependency for another package rc 0 (-U --asdeps)
no-such-pkg error: package 'no-such-pkg' was not found rc 1 (also for a package only in a sync db)
```
* `pacman -D --asexplicit su-dummy-b` / `--asdeps` flip the line at once. Works as `nobody` and with `db.lck` present (-Qi takes no lock). One line, no wrapping with `COLUMNS=0`.
* `pacman -Qi sh` answers for the provider (`bash`), like the description and `Required By` already do.
* Mapping: `Explicitly installed` -> explicit, `Installed as a dependency for another package` -> dependency, -Qi failing -> not installed, anything else (or no such line) -> unknown.
* Fixture: `su-dummy-b` is installed `--asdeps`, `a`/`c` explicitly; `e2e_reset` re-applies both after its `pacman -U`. No change needed.
## 14. Full info (`N??` / `show`): `-Qi` merged with `-Si REPO/PKG`
Verified 2026-10-10 on archlinux:latest (pacman 7.1.0), `COLUMNS=0 LC_ALL=C`: the fixture (`FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1 FIXTURE_SYNC=1`) and a real upgradable package after `pacman -Sy` (`glib2` 2.88.3-1 installed, core 2.90.1-1).
```
$ pacman -Qi su-dummy-b $ pacman -Si su-dummy/su-dummy-b
Name : su-dummy-b Repository : su-dummy
Version : 1.0-1 Name : su-dummy-b
Description : Skeptical-updater dummy ... Version : 2.0-1
Architecture : any Description : Skeptical-updater dummy ...
URL : https://example.invalid/... Architecture : any
Licenses : custom URL / Licenses / Groups / Provides / Depends On /
Groups / Provides / Depends On / Optional Deps Optional Deps / Conflicts With / Replaces
Required By : su-dummy-a Download Size : 388.00 B
Optional For : None Installed Size : 4.00 B
Conflicts With / Replaces Packager : su-fixture <...>
Installed Size : 4.00 B Build Date : Tue Nov 14 22:13:20 2023
Packager : su-fixture <...> Validated By : SHA-256 Sum
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Sat Oct 10 11:55:39 2026
Install Reason : Installed as a dependency for another package
Install Script : No
Validated By : None
```
* Both are one record per target (blank line after it), every key padded to the same column (`:` at column 17 in both, C locale), so the keys pair one to one. `-Qi` only: `Required By`, `Optional For`, `Install Date`, `Install Reason`, `Install Script`. `-Si` only: `Repository`, `Download Size`. Both, usually different: `Version`, `Installed Size`, `Build Date`, `Validated By` (`None`/`Signature` installed, `SHA-256 Sum Signature` in the repo), `Depends On`/`Provides`/`Optional Deps` when they changed.
* Multi-line: only `Optional Deps` (continuation lines indented to the value column, one entry each - glib2: `dconf: ...` / `glib2-devel: ...` / `gvfs: ...`); with `COLUMNS=0` nothing else wraps (`Provides` of glib2, 6 entries, stays one line).
* The candidate is `-Si REPO/PKG` with REPO = the first repository `-Sl` lists PKG in (pacman's choice, the join `pacman_upgradable` uses); plain `-Si PKG` prints one record per repository that has PKG.
* Missing sides: `-Qi` of a package only in a sync database fails (not installed: the candidate alone); a local-only package has no `-Si` record (the plain `-Qi` stays).