Files
skeptical-updater/discovery/pacman.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

588 lines
31 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.