diff --git a/README.md b/README.md index 35117f1..4b9477c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ commands: trust PKG... add PKG and its installed dependency closure to the statefile untrust PKG... remove rows for PKG (and rows whose reason is dep:PKG) refresh recompute dependency closures for every 'user' root - info PKG one-line description + installed reverse dependencies (as N?) + info PKG description, install reason and installed dependents (as N?) show PKG full native package information (as N??) options: @@ -80,7 +80,8 @@ Refreshing package lists... done. upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever > 2? su-dummy-b: dummy package su-dummy-b for skeptical-updater tests - required by (installed): su-dummy-a +reason: installed as a dependency +required by: su-dummy-a > 1+ trusted su-dummy-a (+1 dependency: su-dummy-b) > 3- @@ -126,7 +127,7 @@ listed prints an error, runs nothing, and the prompt comes back. | token | action | |-------|--------| | empty line | proceed: run the native upgrade with the current exclusions | -| `N?` | one-line description and the installed packages that depend on it | +| `N?` | what is it: the one-line description, `reason:` how it got installed (`explicitly installed`, `installed as a dependency`, `unknown` when the package manager cannot tell, `not installed` for a new dependency) and `required by:` the packages that depend on it, or `nothing`. That list covers installed packages only | | `N??` | full native info (`apt-cache show`, `pacman -Qi` + `-Si`, `dnf info`, `zypper info --requires`) | | `N-` | exclude from this run (toggle; `N-` again re-includes); shown with a `-` marker | | `Nx` | purge now, with its now-unneeded dependencies: the purge is simulated first and the prompt lists everything it removes (`Purge su-dummy-a now? This also removes: su-dummy-b [y/N]`); the answer is read from the same stdin; the rows of every removed package leave the list. `x` refuses when other installed packages depend on the target, like `pacman -Rns`; purge the dependents first (`not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first`). Rows with old version `-` (new dependencies, not installed) cannot be purged | @@ -220,7 +221,11 @@ upgrade` (Inst lines + the "kept back" block), `pacman -Qu` joined with `pacman `zypper --xmlout list-updates`, or on Tumbleweed (as root) the real plan `zypper --xmlout --no-refresh dist-upgrade --dry-run`. Reverse dependencies and dependency closures use `apt-cache rdepends` / the dpkg database, `pacman -Qi`, and plain `rpm` queries on -dnf and zypper systems (see the discovery reports for why). +dnf and zypper systems (see the discovery reports for why). The install reason `N?` shows comes +from `apt-mark showauto` / `showmanual`, the `Install Reason` of `pacman -Qi`, +`dnf repoquery --installed --queryformat '%{reason}'` (packages installed with plain `rpm` are +`unknown` to dnf4, user-installed to dnf5) and the `Installed` line of `zypper info` +(`Yes (automatically)`; libzypp's `/var/lib/zypp/AutoInstalled` when zypper is locked). ## Testing @@ -250,7 +255,8 @@ in front of symlinks to the few system tools it needs. The fakes replay the real outputs recorded in `discovery/*.md` (data in `tests/unit/data/`), keep their state (holds, purged packages) under the test's temp dir and log every invocation, so the tests assert the exact commands issued. Covered: CLI parsing and exit codes, backend -detection and override, root handling and the sudo re-exec, every parser, the +detection and override, root handling and the sudo re-exec, every parser (install reasons +included: explicitly installed, dependency, unknown and not installed on each backend), the statefile, the whole interactive grammar, the per-backend exclusion mechanics (apt hold journal incl. crash recovery and SIGTERM, pacman `--ignore`, dnf `--exclude`, zypper private lock file contents incl. drop-ins), the purge plans, the metadata freeze flags, @@ -273,9 +279,12 @@ plus `su-dummy-orphan`, an unrelated automatically-installed package nothing dep with the distro's own repositories disabled (apt `SU_FIXTURE_OFFLINE=1`, pacman `FIXTURE_ISOLATE=1`, rpm `SU_FIXTURE_ISOLATE=1` after the build tools are installed), so the review list is exactly the three dummies and the result does not depend on -mirror state. Then `tests/e2e/*.bats` exercise the real package manager: banner, -list, `N?`, `N??`, `N+` and the statefile, `trust`/`untrust`/`refresh`, excluding -`su-dummy-b` (with and without a pre-existing hold/lock/IgnorePkg/exclude on +mirror state. `su-dummy-b` is recorded as installed as a dependency on every backend (pacman +`--asdeps`, `apt-mark auto`, `dnf mark remove`, zypper's `AutoInstalled`) and `su-dummy-a` as +explicitly installed (dnf needs `dnf mark install`: it records plain `rpm` installs as +`unknown`). Then `tests/e2e/*.bats` exercise the real package manager: banner, list, `N?` (the +reason and `required by:` of both), `N??`, `N+` and the statefile, `trust`/`untrust`/`refresh`, +excluding `su-dummy-b` (with and without a pre-existing hold/lock/IgnorePkg/exclude on `su-dummy-c`, which must survive byte for byte), trusting re-including an excluded dependency, full upgrade, `Nx` (refused for su-dummy-b, which su-dummy-a needs; su-dummy-a taking its dependency su-dummy-b along, shown in the prompt, and the unrelated orphan surviving), diff --git a/discovery/apt.md b/discovery/apt.md index b96f83b..5af45f3 100644 --- a/discovery/apt.md +++ b/discovery/apt.md @@ -605,3 +605,20 @@ Container start overhead is ~0.2-0.3 s; `docker pull` of the five images took 3- 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. diff --git a/discovery/pacman.md b/discovery/pacman.md index fd11d53..df6592a 100644 --- a/discovery/pacman.md +++ b/discovery/pacman.md @@ -568,3 +568,20 @@ pman bugs (verified; relevant if the tool reuses `/etc/pacman.d/ignorepkg`): `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. diff --git a/discovery/rpm.md b/discovery/rpm.md index a5091c9..2a441b2 100644 --- a/discovery/rpm.md +++ b/discovery/rpm.md @@ -704,3 +704,42 @@ Inside `proof.sh` (warm containers): forced refresh (`su_refresh`) 0.5-2.6 s (up * zypper exit codes 100-103, 106, 107 were not reproduced (see 7.2). `opensuse-slowroll`, MicroOS/Aeon (transactional), SLES were not tested. * Multi-arch (x86_64 + i686) and installonly kernels were not exercised; the parser keys on `name.arch` and keeps the highest installed `evr` per key for the "old" column. * Docker images pulled during this work (all kept, none removed or re-tagged): `almalinux:9`, `almalinux:10`, `rockylinux:9`, `rockylinux/rockylinux:9`, `rockylinux/rockylinux:10`, `quay.io/rockylinux/rockylinux:9`, `quay.io/rockylinux/rockylinux:10`, `opensuse/tumbleweed:latest`, `opensuse/leap:latest`, `fedora:latest`. + +## 14. Install reason (`N?` / `info`) + +Verified 2026-10-10, fixture `SU_FIXTURE_ISOLATE=1 SU_FIXTURE_ORPHAN=1`, `LC_ALL=C`. + +### 14.1 DNF: `dnf -q repoquery --installed --queryformat '%{reason}\n' PKG` + +Same answers on almalinux:9 and rockylinux/rockylinux:9 (dnf 4.14.0), almalinux:10 and rockylinux/rockylinux:10 (dnf 4.20.0); fedora:latest (Fedora 44, dnf5 5.4.3.0) uses other words: + +| state | dnf4 | dnf5 | +|---|---|---| +| installed with plain `rpm -Uvh` (fixture's a, b, c) | `unknown` | `External User` | +| `su-dummy-orphan` (pulled in by `dnf install su-dummy-orphan-parent`) | `dependency` | `Dependency` | +| `dnf install su-dummy-a` pulling in `su-dummy-b` | a `user`, b `dependency` | a `User`, b `Dependency` | +| not installed (in a repo or unknown) | no output, rc 0 | no output, rc 0 | + +* `%{reason}` exists on every target, `--qf` and `--queryformat` both work. dnf4 ends the value with a newline, dnf5 does not (several installed versions would run together), so the format carries its own `\n` and the first non-empty line is used. Other values in the code: `weak-dependency`/`Weak Dependency` (dependency), `group`/`Group` (explicit: asked for with its group); dnf5 `External User` counts as user-installed (`repoquery --userinstalled` lists those packages) -> explicit; dnf4 `unknown` -> unknown. +* Fallback `dnf history userinstalled` not needed and not used: dnf5 has no such command (`Unknown argument "userinstalled"`, rc 2), and on dnf4 it lists the fixture's rpm-installed a, b, c (reason `unknown`) as "Packages installed by user", i.e. it would call b explicit. +* Marks: dnf4 `dnf mark remove PKG` -> `dependency`, `dnf mark install PKG` -> `user` (`mark user`/`mark dependency`: invalid choice, rc 2); dnf5 `dnf -y mark dependency|user PKG` (without `-y`: "Operation aborted", rc 1; `mark remove|install`: unknown argument, rc 2). A mark survives a later `dnf upgrade` and an `rpm -Uvh --oldpackage --replacepkgs` back to 1.0 (the e2e reset). +* Works as `nobody` (setpriv / python3 on almalinux:10), with or without `-C`, in 110-150 ms (dnf4) / 45 ms (dnf5), and does not touch the repositories even with the distro repos re-enabled and their metadata expired. +* Fixture consequence: on dnf the rpm-installed a and c would print `reason: unknown`; `SU_FIXTURE_B_AUTO=1` now also runs `dnf mark install su-dummy-a su-dummy-c` (dnf5: `-y mark user`), and `e2e_reset` re-marks a/c user and b dependency. + +### 14.2 ZYPPER: `Installed` line of `zypper --non-interactive --no-refresh --quiet info PKG` + +opensuse/tumbleweed (zypper 1.14.101, libzypp 17.38.16) and opensuse/leap 16.0 (same versions), identical, fixture with `SU_FIXTURE_B_AUTO=1`: + +``` +su-dummy-a Installed : Yes rc 0 +su-dummy-b Installed : Yes (automatically) rc 0 (listed in /var/lib/zypp/AutoInstalled) +su-dummy-orphan Installed : Yes (automatically) rc 0 (pulled in by zypper install su-dummy-orphan-parent) +su-dummy-orphan-parent Installed : No rc 0 (Status : not installed) +bash Installed : Yes rc 0 (Repository : @System, repos isolated) +no-such-pkg package 'no-such-pkg' not found. rc 104 +``` + +* The block also has `Installed Size : 15 B`: match `^Installed *:` only. +* Cross-check: the flag is exactly membership in `/var/lib/zypp/AutoInstalled` (bare package names, plus `pattern:...` lines and `#` comments; 0644 root, world-readable). Deleting `su-dummy-b` from it turns the line into `Yes` immediately; an `rpm -Uvh --replacepkgs` reinstall keeps it; `zypper install su-dummy-a` (rpm -e'd first) installs b with `Yes (automatically)` and adds it to the file; naming b on the command line (`zypper update su-dummy-a su-dummy-b`) drops the flag (b becomes user-installed). +* Works as `nobody`. As root, while another process owns the zypp lock (`/run/zypp.pid` with a live pid), `zypper info` exits 7 ("System management is locked by the application with pid N (sleep)") - as `nobody` it still answers. Fallback in that case (and for exit 104): `rpm -q PKG` fails -> not installed; else membership in AutoInstalled -> dependency / explicit; unreadable -> unknown. +* Fixture: a/c (plain rpm, not in AutoInstalled) are `Yes`, b is `Yes (automatically)` with `SU_FIXTURE_B_AUTO=1`; `e2e_reset` re-adds b to AutoInstalled. No change needed. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fb017b9..17592a9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -98,7 +98,7 @@ Command grammar (one line, whitespace-separated tokens, each `N` is the number i | token | action | |--------|---------------------------------------------------------------------------| | empty line | proceed: run the native upgrade with the current exclusions | -| `N?` | print the one-line description and the list of installed packages that depend on it | +| `N?` | print three lines, none indented: `PKG: `, `reason: ` with `` = `explicitly installed`, `installed as a dependency`, `unknown` (the backend cannot tell) or `not installed` (also, without asking the backend, for a row whose old version is `-`), and `required by: ` - the reverse dependencies, installed packages only - or `required by: nothing` | | `N??` | print the full native info dump (`apt-cache show` / `pacman -Qi` / `dnf info` / `zypper info --requires`) | | `N-` | exclude from this run (toggle; `N-` again re-includes); shown with `-` marker | | `Nx` | purge it now, following pacman's `-Rns` principle on every backend: if any installed package still depends on it, refuse and print who (the reverse dependencies `N?` shows) - never cascade; otherwise simulate the native purge of it together with its now-unneeded dependencies, show that removal set ("This also removes: ...") and ask `y/N`. As a safety check, a simulated set containing anything that is neither the target nor in its installed dependency closure is refused too. Only a purge that ran removes rows (of every package it removed). A row whose old version is `-` (a new dependency, not installed) cannot be purged | @@ -202,6 +202,7 @@ Every backend is a set of bash functions with a common prefix. All output is _describe PKG # stdout: one-line summary _full_info PKG # stdout: native full dump (candidate version if available) _rdepends PKG # stdout: installed packages requiring PKG, one per line +_reason PKG # stdout: explicit | dependency | unknown | not-installed (how PKG got installed) _closure PKG # stdout: installed recursive dependency closure of PKG (excluding PKG), one per line _purge_plan PKG # stdout: every package a purge of PKG and its now-unneeded dependencies removes # (simulation, no cascade); non-zero = refused @@ -213,6 +214,15 @@ Every backend is a set of bash functions with a common prefix. All output is Mutating calls go through `run_cmd` which honours `--dry-run` (prints `+ cmd` to stdout instead of executing). +`_reason` sources (read-only, unprivileged, verified in `discovery/*.md`): apt +`apt-mark showauto PKG` (dependency) / `apt-mark showmanual PKG` (explicit), a package in neither +list is explicit when dpkg has it installed; pacman the `Install Reason` of `pacman -Qi`; dnf +`repoquery --installed --queryformat '%{reason}'` (`user`/`group`/dnf5 `External User` = explicit, +`dependency`/`weak-dependency` = dependency, dnf4 `unknown` - plain `rpm -i` - stays unknown, no +output = not installed); zypper the `Installed` line of `zypper --no-refresh info` +(`Yes (automatically)` / `Yes` / `No`), and when zypper gives no answer (its lock is busy) `rpm -q` +plus libzypp's `/var/lib/zypp/AutoInstalled`. + ## Layout ``` diff --git a/skeptical-updater b/skeptical-updater index 59b39d8..df62932 100755 --- a/skeptical-updater +++ b/skeptical-updater @@ -87,7 +87,7 @@ commands: trust PKG... add PKG and its installed dependency closure to the statefile untrust PKG... remove rows for PKG (and rows whose reason is dep:PKG) refresh recompute dependency closures for every 'user' root - info PKG one-line description + installed reverse dependencies (as N?) + info PKG description, install reason and installed dependents (as N?) show PKG full native package information (as N??) options: @@ -102,7 +102,9 @@ options: interactive commands (one line, whitespace-separated tokens, N = number in brackets): proceed: run the native upgrade with the current exclusions - N? one-line description and installed packages that depend on it + N? what is it: the description, 'reason:' (explicitly installed, installed as + a dependency, unknown or not installed) and 'required by:', the packages + that depend on it - installed packages only N?? full native info dump N- exclude from this run (toggle); shown with a '-' marker Nx purge it now with its now-unneeded dependencies (shown, asks y/N); refused @@ -122,7 +124,7 @@ EOF session_help() { cat <<'EOF' upgrade now, skipping the rows marked '-' - N? what is it: one-line description + installed packages that need it + N? what is it: description, install reason + installed packages that need it N?? full native info N- exclude N from this run (toggle) Nx purge N now: lists every package the purge removes, then asks y/N. @@ -883,6 +885,41 @@ apt_rdepends() { apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "$1" /dev/null | awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ |]+/,""); print}' | sort -u } +# apt_reason PKG: apt's auto flag (discovery/apt.md item 13): listed by `apt-mark showauto` = +# dependency, by `showmanual` = explicit; in neither list but installed (dpkg) = explicit, else not +# installed. Both are read-only: no root, no dpkg lock (verified). apt-mark prints native packages +# without their architecture qualifier and treats a name that is no package as a pattern, hence +# the exact-line match. +apt_reason() { + local out st + out=$(apt-mark showauto "$1" /dev/null) || { + printf 'unknown\n' + return 0 + } + if apt_mark_lists "$1" "$out"; then + printf 'dependency\n' + return 0 + fi + out=$(apt-mark showmanual "$1" /dev/null) || { + printf 'unknown\n' + return 0 + } + if apt_mark_lists "$1" "$out"; then + printf 'explicit\n' + return 0 + fi + # ${db:Status-Abbrev}: 2nd character = state; n(ot-installed) and c(onfig-files) = not installed + st=$(dpkg-query -W -f '${db:Status-Abbrev}' "$1" /dev/null) || st= + case ${st:1:1} in + '' | n | c) printf 'not-installed\n' ;; + *) printf 'explicit\n' ;; + esac +} +apt_mark_lists() { # apt_mark_lists PKG OUTPUT: does OUTPUT (apt-mark show*) name PKG (name:arch too)? + local o=$'\n'$2$'\n' + [[ $o == *$'\n'"$1"$'\n'* ]] && return 0 + [[ $1 == *:* && $o == *$'\n'"${1%:*}"$'\n'* ]] +} apt_closure() { dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' /dev/null | awk -F'\t' -v roots="$1" "$AWK_APT_CLOSURE" | awk -v r="$1" '$0 != r' | sort -u @@ -1197,6 +1234,22 @@ pacman_rdepends() { read -r -a words <<<"$out" || true for w in ${words[@]+"${words[@]}"}; do [[ $w == None ]] || printf '%s\n' "$w"; done | sort -u } +# pacman_reason PKG: the "Install Reason" of `pacman -Qi` (discovery/pacman.md section 13); -Qi +# fails (exit 1, "package 'x' was not found") for a package that is not installed +pacman_reason() { + local out rc=0 + out=$(pm_field -Qi 'Install Reason' "$1") || rc=$? + out=${out%%$'\n'*} + if ((rc)) && [[ -z $out ]]; then + printf 'not-installed\n' + return 0 + fi + case ${out#*$'\t'} in + 'Explicitly installed') printf 'explicit\n' ;; + 'Installed as a dependency for another package') printf 'dependency\n' ;; + *) printf 'unknown\n' ;; + esac +} # discovery/pacman/lib.sh pm_closure (forward mode): one pacman -Qi dump + awk BFS, roots excluded pacman_closure() { @@ -1565,6 +1618,25 @@ dnf_describe() { dnf_full_info() { "$DNF" -q info "$1" /dev/null) || { + printf 'unknown\n' + return 0 + } + while IFS= read -r l; do [[ -z $l ]] || break; done <<<"$out" + case $l in + user | User | group | Group | 'External User') printf 'explicit\n' ;; + dependency | Dependency | weak-dependency | 'Weak Dependency') printf 'dependency\n' ;; + '') printf 'not-installed\n' ;; + *) printf 'unknown\n' ;; + esac +} # dnf_purge_plan PKG: the "Removing..." sections of `dnf --assumeno remove PKG` (dnf4 and dnf5 # tables; --assumeno always exits 1, so success = a "Transaction Summary" or "Nothing to do") dnf_purge_plan() { @@ -1723,6 +1795,35 @@ zypper_describe() { zypper_full_info() { zypper --non-interactive --quiet info --requires "$1" /dev/null) || rc=$? + if ((rc == 0)); then + while IFS= read -r l; do + [[ $l =~ ^Installed[[:space:]]*:[[:space:]]*(.*)$ ]] || continue # not "Installed Size :" + case ${BASH_REMATCH[1]} in + 'Yes (automatically)') printf 'dependency\n' ;; + Yes) printf 'explicit\n' ;; + No) printf 'not-installed\n' ;; + *) printf 'unknown\n' ;; + esac + return 0 + done <<<"$out" + fi + if ! rpm -q "$1" /dev/null 2>&1; then + printf 'not-installed\n' + elif [[ ! -r $auto ]]; then + printf 'unknown\n' + elif grep -qxF -e "$1" -- "$auto"; then + printf 'dependency\n' + else + printf 'explicit\n' + fi +} zypper_purge_plan() { local xml rc=0 sec name rest xml=$(zypper --non-interactive --no-refresh --xmlout remove --dry-run --clean-deps "$1" &1) || rc=$? @@ -1945,22 +2046,32 @@ print_list() { fi } -print_info() { # print_info PKG: description + installed reverse dependencies (N? / info) - local desc rd rc=0 +# print_info PKG [OLD]: description, install reason and the installed packages that depend on PKG +# (N? / info). OLD "-" (a row of a new dependency) is not installed: the backend is not asked. +print_info() { + local desc rd reason rc=0 desc=$(be describe "$1") || rc=$? if ((rc)); then say "$1: (no description available: unknown package?)" else say "$C_BOLD$1$C_RESET: $desc" fi + if [[ ${2:-} == - ]]; then + reason=not-installed + else + reason=$(be reason "$1" 2>/dev/null) || reason=unknown + fi + case $reason in + explicit) say 'reason: explicitly installed' ;; + dependency) say 'reason: installed as a dependency' ;; + not-installed) say 'reason: not installed' ;; + *) say 'reason: unknown' ;; + esac rd=$(be rdepends "$1") || rd='' rd=$(printf '%s\n' "$rd" | sed '/^$/d' | tr '\n' ' ') rd=${rd% } - if [[ -n $rd ]]; then - say " required by (installed): ${rd// /, }" - else - say ' required by (installed): nothing' - fi + rd=${rd// /, } + say "required by: ${rd:-nothing}" return "$rc" } @@ -2248,7 +2359,7 @@ session_line() { fi i=$REPLY case $op in - '?') print_info "${R_NAME[i]}" || true ;; + '?') print_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;; '??') print_full_info "${R_NAME[i]}" || true ;; -) if ((R_EXCL[i])); then diff --git a/tests/e2e/fixtures/rpm.sh b/tests/e2e/fixtures/rpm.sh index bd79875..1c68433 100755 --- a/tests/e2e/fixtures/rpm.sh +++ b/tests/e2e/fixtures/rpm.sh @@ -31,7 +31,10 @@ # so it is a pre-existing unneeded package (dnf repoquery --unneeded / zypper packages --unneeded). # # Optional (SU_FIXTURE_B_AUTO=1): su-dummy-b counts as installed as a dependency (dnf: `dnf mark -# remove`, zypper: /var/lib/zypp/AutoInstalled), like the pacman fixture's --asdeps. +# remove`, zypper: /var/lib/zypp/AutoInstalled), like the pacman fixture's --asdeps; on dnf +# su-dummy-a and su-dummy-c also count as installed by the user (`dnf mark install`): dnf4 reports +# packages installed with plain rpm as reason "unknown" (dnf5: "External User"). zypper needs +# nothing for them: a package that is not in AutoInstalled is user-installed. # # Re-runnable: re-running resets everything to the "1.0 installed, 2.0 available" state. # @@ -198,7 +201,11 @@ fi if [ "${SU_FIXTURE_B_AUTO:-0}" = 1 ]; then case $BACKEND in zypper) grep -qx su-dummy-b /var/lib/zypp/AutoInstalled 2>/dev/null || echo su-dummy-b >>/var/lib/zypp/AutoInstalled ;; - dnf|yum) "$BACKEND" -q mark remove su-dummy-b >/dev/null ;; + dnf|yum) + # dnf4 spelling first, then dnf5's (which asks for confirmation) + "$BACKEND" -q mark remove su-dummy-b >/dev/null 2>&1 || "$BACKEND" -q -y mark dependency su-dummy-b >/dev/null + "$BACKEND" -q mark install su-dummy-a su-dummy-c >/dev/null 2>&1 || "$BACKEND" -q -y mark user su-dummy-a su-dummy-c >/dev/null + ;; esac log "su-dummy-b marked as installed as a dependency" fi diff --git a/tests/e2e/helpers.bash b/tests/e2e/helpers.bash index 4e10377..65ab7ff 100644 --- a/tests/e2e/helpers.bash +++ b/tests/e2e/helpers.bash @@ -132,10 +132,14 @@ e2e_reset() { rpm -Uvh --oldpackage --replacepkgs /var/tmp/su-fixture/RPMS/*/su-dummy-[abc]-1.0-1.*.rpm >/dev/null 2>&1 ;; esac - # su-dummy-b is a dependency of su-dummy-a (installed as such), on every backend + # su-dummy-b is a dependency of su-dummy-a (installed as such), on every backend; su-dummy-a and + # su-dummy-c were installed by the user (dnf records plain rpm installs as reason unknown) case $BE in apt) apt-mark auto su-dummy-orphan su-dummy-b >/dev/null ;; - dnf) dnf -q mark remove su-dummy-b >/dev/null 2>&1 ;; + dnf) + dnf -q mark remove su-dummy-b >/dev/null 2>&1 || dnf -q -y mark dependency su-dummy-b >/dev/null 2>&1 + dnf -q mark install su-dummy-a su-dummy-c >/dev/null 2>&1 || dnf -q -y mark user su-dummy-a su-dummy-c >/dev/null 2>&1 + ;; zypper) grep -qx su-dummy-b /var/lib/zypp/AutoInstalled 2>/dev/null || echo su-dummy-b >>/var/lib/zypp/AutoInstalled ;; esac [[ $(versions) == "a=1.0 b=1.0 c=1.0" && $(pkg_ver su-dummy-orphan) == 1.0 ]] || { diff --git a/tests/e2e/skeptical-updater.bats b/tests/e2e/skeptical-updater.bats index e87ee4b..d19c79f 100644 --- a/tests/e2e/skeptical-updater.bats +++ b/tests/e2e/skeptical-updater.bats @@ -32,14 +32,27 @@ zypper_verb() { case $(. /etc/os-release && printf '%s' "$ID") in opensuse-tumbl refute_line --regexp '^.\[4\]' } -@test "N? prints the description; su-dummy-b is required by su-dummy-a" { +@test "N? prints the description, the install reason and who requires it" { + local -a lines_=() + local i a=-1 b=-1 run skeptical-updater --no-refresh <<<$'1?\n2?\nq' assert_success assert_line --regexp '^su-dummy-a: .*([Dd]ummy|skeptical-updater).*$' assert_line --regexp '^su-dummy-b: .*([Dd]ummy|skeptical-updater).*$' - # [1] su-dummy-a has no installed reverse dependency, [2] su-dummy-b is needed by su-dummy-a - assert_line ' required by (installed): nothing' - assert_line ' required by (installed): su-dummy-a' + # [1] su-dummy-a was installed explicitly and nothing needs it; [2] su-dummy-b was installed as + # a dependency of su-dummy-a - the two lines right under each description, no indentation + mapfile -t lines_ <<<"$output" + for i in "${!lines_[@]}"; do + case ${lines_[i]} in + 'su-dummy-a: '*) a=$i ;; + 'su-dummy-b: '*) b=$i ;; + esac + done + [[ ${lines_[a + 1]} == 'reason: explicitly installed' && ${lines_[a + 2]} == 'required by: nothing' ]] || + fail "1? (su-dummy-a): got '${lines_[a + 1]}' / '${lines_[a + 2]}'" + [[ ${lines_[b + 1]} == 'reason: installed as a dependency' && ${lines_[b + 2]} == 'required by: su-dummy-a' ]] || + fail "2? (su-dummy-b): got '${lines_[b + 1]}' / '${lines_[b + 2]}'" + refute_output --partial 'required by (installed)' assert_line 'Quit: nothing was upgraded.' } @@ -250,7 +263,11 @@ zypper_verb() { case $(. /etc/os-release && printf '%s' "$ID") in opensuse-tumbl assert_line --partial "$BE"$'\t'"su-dummy-c"$'\t'"user" run asuser skeptical-updater info su-dummy-b assert_success - assert_line ' required by (installed): su-dummy-a' + assert_line 'reason: installed as a dependency' + assert_line 'required by: su-dummy-a' + run asuser skeptical-updater info su-dummy-a + assert_success + assert_line 'reason: explicitly installed' run asuser skeptical-updater --dry-run --no-refresh <<<'' assert_success assert_line --regexp "$(native_upgrade_regex)" diff --git a/tests/unit/data/apt/auto.txt b/tests/unit/data/apt/auto.txt new file mode 100644 index 0000000..b9c84c1 --- /dev/null +++ b/tests/unit/data/apt/auto.txt @@ -0,0 +1,6 @@ +gcc-12-base +libc6 +libgcc-s1 +mawk +su-dummy-b +su-dummy-orphan diff --git a/tests/unit/data/rpm/db.bash b/tests/unit/data/rpm/db.bash index cbf52bf..5a37adf 100644 --- a/tests/unit/data/rpm/db.bash +++ b/tests/unit/data/rpm/db.bash @@ -51,6 +51,13 @@ declare -A PROVIDES=( [ncurses-libs]=$'ncurses-libs\nlibtinfo.so.6()(64bit)' [redhat-rpm-config]=redhat-rpm-config [rpm]=rpm ) +# what dnf4 records as the install reason (`repoquery --installed --qf %{reason}`; the fake dnf +# speaks dnf5's words with FAKE_DNF_MAJOR=5): plain `rpm -i` leaves "unknown" +declare -A REASON=( + [su-dummy-a]=user [su-dummy-b]=dependency [su-dummy-c]=unknown [su-dummy-d]=user + [bash]=user [glibc]=dependency [glibc-common]=dependency [glibc-gconv-extra]=weak-dependency + [basesystem]=group [setup]=dependency [filesystem]=dependency +) # FAKE_RPM_NO_D=1: su-dummy-d (which requires every dummy) is not installed declare -A FILES=( [su-dummy-a]=/usr/share/su-dummy/su-dummy-a [su-dummy-b]=/usr/share/su-dummy/su-dummy-b diff --git a/tests/unit/data/zypper/info-su-dummy-b.txt b/tests/unit/data/zypper/info-su-dummy-b.txt new file mode 100644 index 0000000..79c65ab --- /dev/null +++ b/tests/unit/data/zypper/info-su-dummy-b.txt @@ -0,0 +1,19 @@ + + +Information for package su-dummy-b: +----------------------------------- +Repository : su-dummy +Name : su-dummy-b +Version : 2.0-1 +Arch : noarch +Vendor : +Installed Size : 15 B +Installed : Yes (automatically) +Status : out-of-date (version 1.0-1 installed) +Source package : su-dummy-b-2.0-1.src +Summary : skeptical-updater dummy su-dummy-b (v2.0) +Description : + Dummy package su-dummy-b version 2.0 for skeptical-updater tests. + + Second paragraph of su-dummy-b 2.0 (blank line above on purpose). + diff --git a/tests/unit/data/zypper/info-su-dummy-orphan-parent.txt b/tests/unit/data/zypper/info-su-dummy-orphan-parent.txt new file mode 100644 index 0000000..804c435 --- /dev/null +++ b/tests/unit/data/zypper/info-su-dummy-orphan-parent.txt @@ -0,0 +1,19 @@ + + +Information for package su-dummy-orphan-parent: +----------------------------------------------- +Repository : su-dummy +Name : su-dummy-orphan-parent +Version : 1.0-1 +Arch : noarch +Vendor : +Installed Size : 27 B +Installed : No +Status : not installed +Source package : su-dummy-orphan-parent-1.0-1.src +Summary : skeptical-updater dummy su-dummy-orphan-parent (v1.0) +Description : + Dummy package su-dummy-orphan-parent version 1.0 for skeptical-updater tests. + + Second paragraph of su-dummy-orphan-parent 1.0 (blank line above on purpose). + diff --git a/tests/unit/fakes/apt/apt-mark b/tests/unit/fakes/apt/apt-mark index 60923b2..02ba40c 100755 --- a/tests/unit/fakes/apt/apt-mark +++ b/tests/unit/fakes/apt/apt-mark @@ -7,6 +7,10 @@ # FAKE_APT_MARK_UNHOLD_SIGNAL=SIG: send SIG to the applet before an unhold (a second Ctrl-C). # FAKE_APT_MARK_UNHOLD_RC=N: unhold fails (nothing released). FAKE_APT_MARK_FAIL_MSG: the message a # failing hold prints. Every unhold logs whether $STATE_DIR/holds.lock is held by someone. +# showauto/showmanual: installed = the "?i" rows of data/apt/dpkg-status.tsv (not purged), auto = those +# listed in data/apt/auto.txt, manual = the others; FAKE_APT_MARK_NEITHER="pkg ..." are in neither +# list, FAKE_APT_MARK_SHOW_RC=N makes both fail. Like the real ones they print only the named +# packages (all of them without a name), native packages without their ":arch" qualifier. # shellcheck source=../fakelib.bash . "${BASH_SOURCE[0]%/*}/../fakelib.bash" fake_log "$@" @@ -27,8 +31,30 @@ case $verb in hold | unhold) fi ;; esac +# show_marked auto|manual NAME... +show_marked() { + local want=$1 n st name mark + shift + local -A pick=() + for n in "$@"; do pick[${n%:all}]=1; done + if [[ -n ${FAKE_APT_MARK_SHOW_RC:-} ]]; then + echo "E: Unable to determine the package states" >&2 + exit "$FAKE_APT_MARK_SHOW_RC" + fi + while IFS=$'\t' read -r st name _; do + [[ $st == ?i* ]] || continue + fake_purged "$name" && continue + [[ " ${FAKE_APT_MARK_NEITHER:-} " == *" $name "* ]] && continue + if grep -qxF -- "$name" "$FAKE_DATA/apt/auto.txt"; then mark=auto; else mark=manual; fi + [[ $mark == "$want" ]] || continue + if (($# == 0)) || [[ -n ${pick[$name]:-} || -n ${pick[$name:amd64]:-} ]]; then printf '%s\n' "$name"; fi + done <"$FAKE_DATA/apt/dpkg-status.tsv" | sort +} + case $verb in showhold) sort "$h" ;; +showauto) show_marked auto "$@" ;; +showmanual) show_marked manual "$@" ;; hold) for p in "$@"; do if grep -qxF -- "$p" "$h"; then echo "$p was already set on hold."; else diff --git a/tests/unit/fakes/apt/dpkg-query b/tests/unit/fakes/apt/dpkg-query index 3045ade..5bccbb5 100755 --- a/tests/unit/fakes/apt/dpkg-query +++ b/tests/unit/fakes/apt/dpkg-query @@ -2,7 +2,8 @@ # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2026 Bryan Joshua Pedini -# fake dpkg-query (unit tests): the dpkg database dump used for dependency closures +# fake dpkg-query (unit tests): the dpkg database dump used for dependency closures, and +# `-W -f '${db:Status-Abbrev}' NAME` (the status of one package; purged = unknown to dpkg) # shellcheck source=../fakelib.bash . "${BASH_SOURCE[0]%/*}/../fakelib.bash" fake_log "$@" @@ -10,7 +11,30 @@ fake_log "$@" st=${SKEPTICAL_UPDATER_STATE_DIR:-}/trusted.tsv if [[ -n ${FAKE_STATE_APPEND:-} ]]; then printf '%s\n' "$FAKE_STATE_APPEND" >>"$st"; fi if [[ -n ${FAKE_STATE_SED:-} ]]; then sed -i "$FAKE_STATE_SED" "$st"; fi -case " $* " in +args=" $* " +names=() +while (($#)); do + case $1 in + -f) shift ;; + -*) ;; + *) names+=("$1") ;; + esac + shift +done +if ((${#names[@]})); then + rc=0 + for n in "${names[@]}"; do + st=$(awk -F'\t' -v n="${n%:amd64}" '$2 == n { print $1; exit }' "$FAKE_DATA/apt/dpkg-status.tsv") + if [[ -z $st ]] || fake_purged "${n%:amd64}"; then + echo "dpkg-query: no packages found matching $n" >&2 + rc=1 + continue + fi + printf '%s' "$st" + done + exit "$rc" +fi +case $args in *' -W '*) cat "$FAKE_DATA/apt/dpkg-status.tsv" ;; *) echo "fake dpkg-query: unhandled: $*" >&2 diff --git a/tests/unit/fakes/dnf/dnf b/tests/unit/fakes/dnf/dnf index 61a4074..453ebc4 100755 --- a/tests/unit/fakes/dnf/dnf +++ b/tests/unit/fakes/dnf/dnf @@ -5,7 +5,9 @@ # fake dnf (unit tests). FAKE_DNF_MAJOR=4|5 picks the output dialect; FAKE_DNF_CHECK_RC=0|1 simulates # "no updates" / an error; FAKE_DNF_CHECK=obsoletes replays a check-update with only an # "Obsoleting Packages" section; FAKE_DNF_REMOVE_REFUSE=1 makes `--assumeno remove` fail; -# removal tables: see the --assumeno remove branch. +# removal tables: see the --assumeno remove branch. `repoquery --installed --qf %{reason}`: REASON of +# data/rpm/db.bash (dnf5's words with FAKE_DNF_MAJOR=5), FAKE_DNF_REASON="pkg=raw value" overrides +# it, FAKE_DNF_REPOQUERY_RC=N makes repoquery fail. # shellcheck source=../fakelib.bash . "${BASH_SOURCE[0]%/*}/../fakelib.bash" # shellcheck source=../../data/rpm/db.bash @@ -41,7 +43,28 @@ case " $* " in ;; *' repoquery '*) p=${pos[-1]} + rc=$(fake_rc FAKE_DNF_REPOQUERY_RC 0) + if ((rc)); then + echo "Error: rpmdb open failed" >&2 + exit "$rc" + fi case " $* " in + *' --installed '*'%{reason}'*) + installed "$p" || exit 0 + r=${REASON[$p]:-unknown} + if ((major == 5)); then + case $r in + user) r=User ;; + dependency) r=Dependency ;; + weak-dependency) r='Weak Dependency' ;; + group) r=Group ;; + unknown) r='External User' ;; + esac + fi + [[ ${FAKE_DNF_REASON:-} != "$p="* ]] || r=${FAKE_DNF_REASON#*=} + printf '%s\n' "$r" + ((major == 5)) || echo + ;; *' --upgrades '*) case $p in su-dummy-[abc]) printf 'skeptical-updater dummy %s (v2.0)\n' "$p"; ((major == 5)) || echo ;; esac ;; *) case $p in su-dummy-new) printf 'skeptical-updater dummy su-dummy-new (v1.0)\n' ;; esac ;; esac diff --git a/tests/unit/fakes/pacman/pacman b/tests/unit/fakes/pacman/pacman index 3563d72..407fe9a 100755 --- a/tests/unit/fakes/pacman/pacman +++ b/tests/unit/fakes/pacman/pacman @@ -10,6 +10,7 @@ # FAKE_PACMAN_STDERR=TEXT: -Sl and -Qu also print TEXT on stderr (an unreadable database). # -Rs --print: FAKE_PACMAN_RS_REFUSE=1 makes pacman refuse ("breaks dependency"); # FAKE_PACMAN_RS_EXTRA="pkg:other" adds other to the plan of pkg. +# FAKE_PACMAN_REASON="pkg=text" replaces the "Install Reason" of pkg in -Qi (empty text: no such line). # FAKE_PACMAN_QU=fixture|real|none selects the -Qu sample, # FAKE_PACMAN_SL=empty makes -Sl print nothing, FAKE_PACMAN_SUP_EXTRA="a b" adds packages that # only -Sup (the real transaction) shows. Every -Q*/-S[il] call records its COLUMNS value, every @@ -107,7 +108,19 @@ case $op in [[ -z ${FAKE_PACMAN_STDERR:-} ]] || echo "$FAKE_PACMAN_STDERR" >&2 [[ ${FAKE_PACMAN_SL:-} == empty ]] || cat "$D/Sl.txt" ;; --Qi) blocks "$D/Qi.txt" "${targets[@]}" ;; +-Qi) + if [[ -z ${FAKE_PACMAN_REASON:-} ]]; then + blocks "$D/Qi.txt" "${targets[@]}" + exit + fi + rc=0 + out=$(blocks "$D/Qi.txt" "${targets[@]}") || rc=$? + awk -v n="${FAKE_PACMAN_REASON%%=*}" -v r="${FAKE_PACMAN_REASON#*=}" ' + /^Name +: / { cur = $0; sub(/^Name +: /, "", cur) } + /^Install Reason +: / && cur == n { if (r != "") print "Install Reason : " r; next } + { print }' <<<"$out" + exit "$rc" + ;; -Si) t=() for a in "${targets[@]}"; do t+=("${a#*/}"); done diff --git a/tests/unit/fakes/zypper/zypper b/tests/unit/fakes/zypper/zypper index 2c68a58..43d6f09 100755 --- a/tests/unit/fakes/zypper/zypper +++ b/tests/unit/fakes/zypper/zypper @@ -6,6 +6,8 @@ # FAKE_ZYPPER_DUP=fixture|empty|removes the `dist-upgrade --dry-run` plan (data/zypper/dup-*.xml). # addlock writes real zypp lock entries into the locksfile.path of $ZYPP_CONF (default # /etc/zypp/locks is never touched: the fake refuses); update/dist-upgrade log that file. +# info replays data/zypper/info-NAME.txt (exit 104 without one); FAKE_ZYPPER_INFO_RC=7 makes it fail +# like a zypp lock held by another process. # shellcheck source=../fakelib.bash . "${BASH_SOURCE[0]%/*}/../fakelib.bash" fake_log "${ZYPP_CONF:+ZYPP_CONF=$ZYPP_CONF }$*" @@ -43,6 +45,11 @@ refresh) list-updates) cat "$D/lu-${FAKE_ZYPPER_LU:-fixture}.xml" ;; info) p=${args[-1]} + if [[ -n ${FAKE_ZYPPER_INFO_RC:-} ]]; then + echo "System management is locked by the application with pid 4242 (zypper)." + echo "Close this application before trying again." + exit "$FAKE_ZYPPER_INFO_RC" + fi if [[ -f $D/info-$p.txt ]]; then cat "$D/info-$p.txt"; else echo "package '$p' not found." exit 104 diff --git a/tests/unit/interactive.bats b/tests/unit/interactive.bats index 94c40ea..e3d7b33 100644 --- a/tests/unit/interactive.bats +++ b/tests/unit/interactive.bats @@ -64,11 +64,30 @@ UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--forc assert_line 'excluded from this run: su-dummy-b' } -@test "N? prints the description and the installed packages that depend on it" { - run skup --no-refresh <<<$'2?\nq' +@test "N? prints the description, the install reason and the installed packages that depend on it" { + run skup --no-refresh <<<$'1?\n2?\nq' assert_success - assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests' - assert_line ' required by (installed): su-dummy-a' + assert_output --partial "$(printf '%s\n' '> 1?' \ + 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests' \ + 'reason: explicitly installed' \ + 'required by: nothing' \ + '> 2?' \ + 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests' \ + 'reason: installed as a dependency' \ + 'required by: su-dummy-a')" + refute_output --partial 'required by (installed)' +} + +@test "N? on a new dependency (old version -) says 'not installed' without asking the package manager" { + FAKE_APT_SIM=real run skup --no-refresh <<<$'9?\nq' + assert_success + assert_line --regexp '^ \[9\] su-dummy-newdep +- +-> +1\.0 +su-fixture:su-repo$' + assert_output --partial "$(printf '%s\n' '> 9?' \ + 'su-dummy-newdep: (no description available: unknown package?)' \ + 'reason: not installed' \ + 'required by: nothing')" + log_lacks '^apt-mark show' + log_lacks '^dpkg-query -W -f \$\{db:Status-Abbrev\} ' } @test "N?? prints the native full info" { diff --git a/tests/unit/parsers.bats b/tests/unit/parsers.bats index 08aaa97..620213c 100644 --- a/tests/unit/parsers.bats +++ b/tests/unit/parsers.bats @@ -50,13 +50,43 @@ setup() { common_setup; } @test "apt: description = candidate Description; reverse deps deduplicated, '|' stripped" { run skup info su-dummy-b assert_success - assert_output "$(printf '%s\n' 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests' ' required by (installed): su-dummy-a')" + assert_output "$(printf '%s\n' 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests' 'reason: installed as a dependency' 'required by: su-dummy-a')" log_has 'apt-cache show --no-all-versions su-dummy-b' log_has 'apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances su-dummy-b' run skup info gpgv assert_success assert_line 'gpgv: GNU privacy guard - signature verification tool' - assert_line ' required by (installed): apt' + assert_line 'required by: apt' +} + +@test "apt: install reason from apt-mark showauto / showmanual, dpkg for a package in neither list" { + run skup info su-dummy-a + assert_success + assert_output "$(printf '%s\n' 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests' 'reason: explicitly installed' 'required by: nothing')" + log_has 'apt-mark showauto su-dummy-a' + log_has 'apt-mark showmanual su-dummy-a' + log_lacks '^dpkg-query -W -f \$\{db:Status-Abbrev\} ' + run skup info su-dummy-b + assert_line 'reason: installed as a dependency' + # apt-mark prints a native package without the ":arch" it was asked for + run skup info libc6:amd64 + assert_line 'reason: installed as a dependency' + # installed but in neither list: manual + FAKE_APT_MARK_NEITHER=su-dummy-c run skup info su-dummy-c + assert_success + assert_line 'reason: explicitly installed' + # shellcheck disable=SC2016 # dpkg-query's own ${...} format, literally + log_has 'dpkg-query -W -f ${db:Status-Abbrev} su-dummy-c' + # removed with its configuration files kept (rc), or unknown to dpkg: not installed + run skup info su-dummy-f + assert_line 'reason: not installed' + run skup info gpgv + assert_success + assert_line 'reason: not installed' + # apt-mark cannot tell + FAKE_APT_MARK_SHOW_RC=100 run skup info su-dummy-a + assert_success + assert_line 'reason: unknown' } @test "apt: the description also comes from Description- fields (Translation files)" { @@ -141,7 +171,7 @@ setup() { common_setup; } run skup info su-dummy-a assert_success assert_line 'su-dummy-a: Skeptical-updater dummy package A' - assert_line ' required by (installed): nothing' + assert_line 'required by: nothing' run skup info su-dummy-new assert_success assert_line 'su-dummy-new: Skeptical-updater dummy package NEW' @@ -152,9 +182,32 @@ setup() { common_setup; } run skup info glibc assert_success assert_line 'glibc: GNU C Library' - assert_line ' required by (installed): bash, gcc-libs, ncurses, readline' + assert_line 'required by: bash, gcc-libs, ncurses, readline' run skup info su-dummy-b - assert_line ' required by (installed): su-dummy-a' + assert_line 'required by: su-dummy-a' +} + +@test "pacman: install reason from the 'Install Reason' of -Qi; -Qi failing = not installed" { + use_fakes pacman + run skup info su-dummy-a + assert_success + assert_output "$(printf '%s\n' 'su-dummy-a: Skeptical-updater dummy package A' 'reason: explicitly installed' 'required by: nothing')" + run skup info su-dummy-b + assert_success + assert_output "$(printf '%s\n' 'su-dummy-b: Skeptical-updater dummy package B (dependency of A)' 'reason: installed as a dependency' 'required by: su-dummy-a')" + run skup info glibc + assert_line 'reason: installed as a dependency' + # in a sync database only + run skup info su-dummy-new + assert_success + assert_line 'reason: not installed' + # a value this tool does not know, or no such field + FAKE_PACMAN_REASON='su-dummy-c=Installed for a reason pacman 9 invented' run skup info su-dummy-c + assert_success + assert_line 'reason: unknown' + FAKE_PACMAN_REASON='su-dummy-c=' run skup info su-dummy-c + assert_line 'reason: unknown' + log_has 'pacman -Qi -- su-dummy-c' } @test "pacman: the closure resolves provides (sonames, sh) and version constraints" { @@ -249,7 +302,7 @@ setup() { common_setup; } run skup info su-dummy-d assert_success assert_line 'su-dummy-d: skeptical-updater dummy su-dummy-d (v1.0)' - assert_line ' required by (installed): nothing' + assert_line 'required by: nothing' FAKE_DNF_MAJOR=5 run skup info su-dummy-a assert_line 'su-dummy-a: skeptical-updater dummy su-dummy-a (v2.0)' } @@ -257,11 +310,55 @@ setup() { common_setup; } @test "rpm reverse deps: provides + owned files joined with every requirement (rich deps too)" { use_fakes dnf rpm run skup info su-dummy-b - assert_line ' required by (installed): su-dummy-a, su-dummy-d' + assert_line 'required by: su-dummy-a, su-dummy-d' run skup info su-dummy-c - assert_line ' required by (installed): su-dummy-d' + assert_line 'required by: su-dummy-d' run skup info bash - assert_line ' required by (installed): filesystem, glibc-common, redhat-rpm-config, rpm, su-dummy-d' + assert_line 'required by: filesystem, glibc-common, redhat-rpm-config, rpm, su-dummy-d' +} + +@test "dnf4: install reason from repoquery --installed %{reason}; plain rpm -i = unknown" { + use_fakes dnf rpm + run skup info su-dummy-a + assert_success + assert_output "$(printf '%s\n' 'su-dummy-a: skeptical-updater dummy su-dummy-a (v2.0)' 'reason: explicitly installed' 'required by: su-dummy-d')" + log_has 'dnf -q repoquery --installed --queryformat %{reason}' + run skup info su-dummy-b + assert_line 'reason: installed as a dependency' + run skup info su-dummy-c + assert_line 'reason: unknown' + # weak-dependency counts as a dependency, a group member as asked for + run skup info glibc-gconv-extra + assert_line 'reason: installed as a dependency' + run skup info basesystem + assert_line 'reason: explicitly installed' + # only in a repository: repoquery --installed prints nothing + run skup info su-dummy-new + assert_success + assert_line 'su-dummy-new: skeptical-updater dummy su-dummy-new (v1.0)' + assert_line 'reason: not installed' + # repoquery failing: unknown + FAKE_DNF_REPOQUERY_RC=1 run skup info su-dummy-a + assert_success + assert_line 'reason: unknown' +} + +@test "dnf5: install reason in dnf5's words (External User = plain rpm -i, counted as user-installed)" { + use_fakes dnf rpm + export FAKE_DNF_MAJOR=5 + run skup info su-dummy-a + assert_success + assert_line 'reason: explicitly installed' + run skup info su-dummy-b + assert_line 'reason: installed as a dependency' + run skup info su-dummy-c + assert_line 'reason: explicitly installed' + run skup info glibc-gconv-extra + assert_line 'reason: installed as a dependency' + FAKE_DNF_REASON='su-dummy-c=None' run skup info su-dummy-c + assert_line 'reason: unknown' + run skup info su-dummy-new + assert_line 'reason: not installed' } @test "rpm closure: file deps, rich deps without their condition, rpmlib() dropped" { @@ -393,9 +490,58 @@ setup() { common_setup; } @test "zypper: reverse deps and closure use the same pure-rpm code" { use_fakes zypper rpm run skup info su-dummy-c - assert_line ' required by (installed): su-dummy-d' + assert_line 'required by: su-dummy-d' run skup trust su-dummy-a assert_output 'trusted su-dummy-a (+1 dependency: su-dummy-b)' run cut -f1 "$STATE_FILE" assert_line zypper } + +@test "zypper: install reason from the Installed line of zypper info (not 'Installed Size')" { + use_fakes zypper rpm + run skup info su-dummy-a + assert_success + assert_output "$(printf '%s\n' 'su-dummy-a: skeptical-updater dummy su-dummy-a (v2.0)' 'reason: explicitly installed' 'required by: su-dummy-d')" + log_has 'zypper --non-interactive --no-refresh --quiet info su-dummy-a' + run skup info su-dummy-b + assert_success + assert_output "$(printf '%s\n' 'su-dummy-b: skeptical-updater dummy su-dummy-b (v2.0)' 'reason: installed as a dependency' 'required by: su-dummy-a, su-dummy-d')" + # only in a repository + run skup info su-dummy-orphan-parent + assert_success + assert_line 'su-dummy-orphan-parent: skeptical-updater dummy su-dummy-orphan-parent (v1.0)' + assert_line 'reason: not installed' + # unknown to zypper (exit 104) and to rpm + run skup info nothing-like-this + assert_failure 1 + assert_line 'reason: not installed' +} + +@test "zypper: without an answer from zypper (lock held: exit 7) rpm + libzypp's AutoInstalled decide" { + use_fakes zypper rpm + export SKEPTICAL_UPDATER_ZYPP_ROOT=$BATS_TEST_TMPDIR/zroot FAKE_ZYPPER_INFO_RC=7 + # no readable AutoInstalled: the tool cannot tell + run skup info su-dummy-b + assert_success + assert_line 'su-dummy-b: skeptical-updater dummy su-dummy-b (v1.0)' + assert_line 'reason: unknown' + mkdir -p "$SKEPTICAL_UPDATER_ZYPP_ROOT/var/lib/zypp" + printf '# AutoInstalled generated Sat Oct 10 11:14:20 2026\n#\nsu-dummy-b\npattern:minimal_base\n#\n' >"$SKEPTICAL_UPDATER_ZYPP_ROOT/var/lib/zypp/AutoInstalled" + run skup info su-dummy-b + assert_line 'reason: installed as a dependency' + run skup info su-dummy-a + assert_line 'reason: explicitly installed' + run skup info su-dummy-orphan-parent + assert_line 'reason: not installed' +} + +@test "zypper on Tumbleweed: N? on a new dependency (old -) says 'not installed' without asking" { + use_fakes zypper rpm + export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed + FAKE_ZYPPER_DUP=removes run skup --no-refresh <<<$'2?\nq' + assert_success + assert_line ' [2] su-dummy-newdep - -> 1.0-1 su-dummy' + assert_line 'reason: not installed' + assert_line 'required by: nothing' + log_lacks 'zypper --non-interactive --no-refresh --quiet info su-dummy-newdep' +} diff --git a/tests/unit/root.bats b/tests/unit/root.bats index efa0176..9bdd090 100644 --- a/tests/unit/root.bats +++ b/tests/unit/root.bats @@ -39,7 +39,8 @@ setup() { assert_line "apt${T}su-dummy-a${T}user${T}2026-10-09T09:40:12Z" run skup info su-dummy-b assert_success - assert_line ' required by (installed): su-dummy-a' + assert_line 'reason: installed as a dependency' + assert_line 'required by: su-dummy-a' run skup show su-dummy-a assert_success assert_line 'Package: su-dummy-a'