first commit

This commit is contained in:
bryanpedini committed 2026-10-10 10:37:53 +02:00
commit 1354db6c39
90 files changed
+13123

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# vendored bats-core / bats-support / bats-assert (fetched by `make vendor`)
tests/vendor/
# per-distro E2E logs (tests/docker/run-e2e.sh)
tests/docker/logs/
tests/docker/images-after*.tsv
*.tmp
.bats-tmp/
# host-specific docker image baseline written by tests/docker/snapshot.sh
tests/docker/images-before.tsv
tests/docker/image-ids-before.txt
+5
View File
@@ -0,0 +1,5 @@
# shellcheck configuration used by `make lint` (run from the repository root).
# Follow `source`d files (fakes -> tests/unit/fakes/fakelib.bash, ...), resolved
# relative to the sourcing script.
external-sources=true
source-path=SCRIPTDIR
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+384
View File
@@ -0,0 +1,384 @@
# skeptical-updater
A distro-agnostic, single-file bash applet for a *manual, conscious* update cycle.
It wraps the native package manager (apt, dnf/yum, pacman/pman, zypper), lists
upgradable packages, lets the user interrogate each one, and only then runs the
native upgrade. Packages the user has explicitly acknowledged are remembered in a
statefile and never questioned again. It is one file with no runtime dependencies
beyond bash >= 4.3 and the distro's package manager, and it never reimplements
package management: every action shells out to the native tool.
The specification is [docs/DESIGN.md](docs/DESIGN.md); the per-family command
research it is built on lives in [discovery/](discovery/).
## Install
```sh
sudo make install # /usr/local/bin/skeptical-updater
sudo make install PREFIX=/usr # /usr/bin/skeptical-updater
make install DESTDIR=/tmp/pkg PREFIX=/usr # staged install, e.g. for packaging
sudo make uninstall
```
Or just copy the `skeptical-updater` file somewhere in `PATH` and make it executable.
The statefile directory (`/var/lib/skeptical-updater/`) is created by the applet
on first use, not by `make install`; `make uninstall` leaves it in place.
Runtime needs: bash >= 4.3, the package manager, coreutils, sed and grep (plus awk,
which the apt and pacman backends use and which those distros always ship). `sudo`
is optional: when the tool needs root and you are not root, it re-runs itself
through `sudo` with the same arguments, or fails with a clear message.
## Usage
```
skeptical-updater [options] [command]
commands:
(none) interactive review + upgrade
list print the review list and exit (no prompt)
trusted print the statefile
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?)
show PKG full native package information (as N??)
options:
--backend=NAME force apt|dnf|pacman|zypper (default: detect pacman > apt > dnf/yum > zypper)
--all also list trusted packages (marked with '=')
--no-refresh skip the metadata refresh step
--dry-run print native commands instead of running mutating ones
--state-dir=DIR override /var/lib/skeptical-updater (env: SKEPTICAL_UPDATER_STATE_DIR)
--no-color disable ANSI colours (also honours NO_COLOR and non-tty stdout)
-h, --help this help
-V, --version print the version
```
Exit status: `0` ok, `1` usage or backend error, `2` aborted (end of input),
otherwise the exit status of the package manager's upgrade step.
What needs root: the metadata refresh, the upgrade, a purge, and writing a state
directory you cannot write. `--dry-run`, `list --no-refresh`, `trusted`, `info` and
`show` work unprivileged (as long as the state directory is readable).
### Example session
A Debian 12 box with three pending upgrades; `su-dummy-c` is on hold
(`apt-mark hold`) and `su-dummy-a` depends on `su-dummy-b`:
```
$ sudo skeptical-updater
Backend: apt (Debian GNU/Linux 12 (bookworm))
Refreshing package lists... done.
3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:
[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo
[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo
[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
<enter> 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
> 1+
trusted su-dummy-a (+1 dependency: su-dummy-b)
> 3-
excluded from this run: su-dummy-c
> l
3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:
-[3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]
>
Excluded from this run: su-dummy-c
+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade
...
The following packages have been kept back:
su-dummy-c
The following packages will be upgraded:
su-dummy-a su-dummy-b
...
Upgrade finished.
$ skeptical-updater trusted
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
apt su-dummy-a user 2026-10-09T19:24:13Z
apt su-dummy-b dep:su-dummy-a 2026-10-09T19:24:13Z
```
Next time `su-dummy-a` and `su-dummy-b` are upgraded silently; only new packages
are questioned. The prompt reads stdin, so the same session can be scripted:
`printf '3-\n\n' | sudo skeptical-updater` (an empty line is what triggers the
upgrade; end of input without one aborts with exit status 2).
`[held]` marks packages the package manager will not upgrade on its own: apt
holds and "kept back" packages, pacman `IgnorePkg` (`[ignored]` in `pacman -Qu`).
dnf `exclude=`/versionlock and zypper locks hide packages from the native listing,
so they do not appear at all.
### Command grammar
One line, whitespace-separated tokens; `N` is the number in brackets. Numbers are
stable for the whole session (rows that leave the list leave a gap). Every token
of a line is validated first: a line with an unknown token or a number that is not
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??` | 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 |
| `N+` | trust forever: write it and its installed dependency closure to the statefile; the row leaves the list. Rows this hides are re-included if they were excluded (a trusted package is upgraded) |
| `*+` / `*-` | `+` / `-` for every listed package (`*-` is an all-or-nothing toggle: when all are excluded it re-includes them) |
| `l` | redraw the list |
| `q` | quit without upgrading (exit 0) |
| `h`, `?` | help |
| end of input | abort without upgrading (exit 2) |
Several tokens may share a line (`1? 3- 4+`); they run left to right. With `--all`
trusted packages are listed too, marked with `=`. When purges leave nothing to upgrade the
session ends by itself (exit 0).
Under the rows, `Note:` lines report pending work that is not a row: new dependencies or
replacements only the pacman transaction (`-Sup`) shows, packages dnf installs to obsolete
others, packages a Tumbleweed `dist-upgrade` removes. With no row but such work, the list
says "0 to review, but ... reports pending changes" and `<enter>` still runs the upgrade.
### Dry run
`--dry-run` prints every mutating command as `+ <command>` instead of running it:
the upgrade, purges, apt holds/unholds, zypper lock files, the installation of the reviewed
pacman databases and statefile writes. The
metadata refresh still runs when you are root (it is printed when you are not), and
read-only queries run normally, so the list is real:
```
$ skeptical-updater --dry-run <<<$'2-\n'
...
Excluded from this run: su-dummy-b
+ apt-mark hold su-dummy-b
+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade
+ apt-mark unhold su-dummy-b
Dry run: nothing was changed.
```
## Statefile
`/var/lib/skeptical-updater/trusted.tsv` (directory override: `--state-dir=DIR` or
`SKEPTICAL_UPDATER_STATE_DIR`). The directory is created `0755` on the first write.
```
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
apt nodejs user 2026-10-09T09:40:12Z
apt libnode115 dep:nodejs 2026-10-09T09:40:12Z
```
- Tab-separated, one record per line, sorted and unique on (backend, package);
lines starting with `#` are comments and are preserved (kept at the top).
Malformed lines are kept verbatim with a warning.
- `reason` is `user` for a package you confirmed (`N+` / `trust`), or
`dep:<root>` for a package that was part of the installed dependency closure of
`<root>` when you trusted it. A `user` row is never downgraded to `dep:`.
- A package is trusted when a row with the current backend and its name exists;
the reason does not matter. Rows of other backends are kept untouched.
- Writes are atomic: `trusted.tsv.tmp.<pid>` in the same directory, renamed over
the old file. They run under `trusted.lock` (`flock`, or an atomic `mkdir` lock with stale
detection where `flock` is missing): the file is re-read and this session's changes are
replayed onto it, so a `trust` in another terminal during a session is kept.
- A state directory you cannot enter, or a statefile you cannot read, is an error (never
silently "nothing trusted").
- `untrust PKG` removes the rows for `PKG` and every `dep:PKG` row; `refresh`
recomputes the closure of every `user` root (adds new dependencies, drops rows
that are no longer needed, keeps the dates of the others).
The other files are recovery aids that only exist while an upgrade runs (or after one was
killed with `kill -9` or a power loss): `holds.added`, the apt hold journal (used under
`holds.lock`), and `zypper-private`, the path of the private zypper configuration. At the start
of every root run, before anything is listed, a left-over journal is replayed (the holds it
recorded are released, a message says which) and a left-over private configuration is removed.
## How exclusions work
`N-` exclusions are per run and never persisted: you are asked again next time.
Permanent holds belong to the package manager (`apt-mark hold`, `IgnorePkg`,
`zypper addlock`, dnf `exclude=`), and skeptical-updater never changes them.
| backend | refresh | upgrade | per-run exclusion | purge |
|---------|---------|---------|-------------------|-------|
| apt | `apt-get update -qq -o APT::Update::Error-Mode=any` | `apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade` | temporary `apt-mark hold` of the excluded packages that are *not already held*, journalled in `holds.added` before holding; afterwards exactly those are released (also on failure, Ctrl-C, TERM, HUP, QUIT, a second signal during the restore, or a closed stdout; a journal left by `kill -9` is replayed at the start of the next root run). Holds you placed are never touched. `apt-mark` ignores `DPkg::Lock::Timeout`, so it is retried for up to 60 s while dpkg's lock is busy. | plan: `apt-get -s purge --auto-remove PKG` minus what `apt-get -s autoremove` already listed (`--auto-remove` is system-wide), then `apt-get -y purge` of exactly that set: PKG and the dependencies this purge orphans; no `autoremove` |
| pacman / pman | `pacman -Sy --dbpath <throwaway copy>` (with `local` linked to the real local database, seeded with the system's `*.db` and `*.db.sig`): listing, `q`, end of input and `--dry-run` never touch `/var/lib/pacman/sync`; errors pacman prints about it are shown | check `pacman -Sup --noconfirm --ignore ... --dbpath <throwaway>` (on failure: nothing changed, its exit status), install the reviewed `*.db`/`*.db.sig` into `/var/lib/pacman/sync` under `db.lck`, all-or-nothing, never over a newer system database (exit 2), then `pacman -Su --noconfirm` - exactly the reviewed metadata, no second sync; with `--no-refresh` plain `pacman -Su` | `--ignore a,b` | plan: `pacman -Rs --print`; then `pacman -Rns --noconfirm -- ...` |
| dnf / yum | `dnf -q --refresh --setopt=skip_if_unavailable=0 makecache` | `dnf -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade` (the reviewed metadata; the repo glob is needed because a repo's own `metadata_expire` overrides the main one) | `--exclude=a,b` (adds to `exclude=` from the configuration) | plan: `dnf --assumeno remove PKG`; then `dnf -y remove ...` |
| zypper | `zypper --non-interactive --quiet refresh` | `zypper --non-interactive --no-refresh update` (Leap/SLE) or `dist-upgrade` (Tumbleweed/Slowroll, by `ID=` in os-release) | locks in a *private* `ZYPP_CONF`: the effective configuration (main zypp.conf plus the `zypp.conf.d` drop-ins of `/usr/etc` and `/etc`) with `locksfile.path` pointing at a copy of the effective lock file (also when a drop-in sets it), plus `addlock` of the exclusions; deleted afterwards: the real lock file is never written | plan: `zypper --xmlout remove --dry-run --clean-deps PKG`; then `zypper --non-interactive --no-refresh remove --clean-deps ...` |
Every package-manager call runs with `LC_ALL=C` (and `DEBIAN_FRONTEND=noninteractive`;
`COLUMNS=0` for pacman), with stdin from `/dev/null` so that it can never eat the
commands you pipe in. The upgradable list comes from `apt-get -s --with-new-pkgs
upgrade` (Inst lines + the "kept back" block), `pacman -Qu` joined with `pacman -Sl`,
`dnf -q check-update` (+ `rpm -qa` for the installed versions) and
`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).
## Testing
Everything runs in Docker; the host only needs GNU make, bash, Docker and (once,
for `make vendor`) curl. Run `make help` for the full list of targets.
| target | what it does |
|--------|--------------|
| `make vendor` | download the pinned bats-core, bats-support and bats-assert into `tests/vendor/` (once; no network afterwards) |
| `make lint` | shellcheck the applet, the test scripts, fixtures and fakes (`koalaman/shellcheck` image; `discovery/` is reference material and is not linted) |
| `make test-unit` | `tests/unit/*.bats` in a `debian:12` container, against fake package managers |
| `make test-e2e` | `tests/e2e/` inside every distro listed in `tests/docker/images.txt`, against the real package managers; logs go to `tests/docker/logs/<label>.log` |
| `make test` | snapshot, `test-unit`, `test-e2e`, then **always** `docker-cleanup`; exits with the tests' status |
| `make test-no-cleanup` | same as `test` but keeps the pulled images, for iterating |
| `make docker-cleanup-dry-run` | show what the cleanup would do |
| `make clean` | remove test logs and `.bats-tmp/` |
Variables: `make test-e2e E2E_ONLY=debian-12` (comma-separated list allowed)
runs a subset of distros; `E2E_FAIL_FAST=1` stops at the first failing distro.
### Unit tests (`tests/unit/`)
No root, no network, no real package manager. The applet runs with a hermetic
`PATH`: the fakes in `tests/unit/fakes/<backend>/` (`apt-get`, `apt-cache`,
`dpkg-query`, `apt-mark`, `pacman`, `pman`, `dnf`, `yum`, `rpm`, `zypper`, `sudo`)
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
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,
`--dry-run`, and the robustness fixes (hold restore with a closed stdout and with a second
signal during the exit handler, the holds lock kept while a restore failed, journal and private
zypper configuration replayed only when no other run holds their lock, the pacman pre-check /
database install / `-Su` sequence and stale throwaway databases, statefile locking and merge,
the `mkdir` lock fallback (EPERM owner alive, no busy loop), an inaccessible state directory, the
sudo re-exec of the file itself and its loop guard).
About 30 seconds.
### End-to-end tests (`tests/e2e/`)
`tests/docker/run-e2e.sh <label> <image>` runs `tests/e2e/entrypoint.sh` as root in a
throwaway container (repository mounted read-only). The entrypoint installs the
applet, builds a fixture with `tests/e2e/fixtures/{apt,pacman,rpm}.sh` (copies of
the proven `discovery/` fixtures): `su-dummy-a` (depends on `su-dummy-b`),
`su-dummy-b` and `su-dummy-c` installed at 1.0, 2.0 offered by a local repository,
plus `su-dummy-orphan`, an unrelated automatically-installed package nothing depends on,
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
`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),
purging everything, `q`, end of input, `--dry-run`, the read-only commands as an unprivileged
user, plus per backend: pacman never touching the real sync database outside the upgrade and
installing exactly the reviewed database at `<enter>` (same hash, a package published after the
review is not installed), apt
holds waiting for a busy dpkg lock, zypper honouring a lock file set in a drop-in and applying
the reviewed metadata although a newer package appeared after the review.
The matrix (`tests/docker/images.txt`):
| label | image | backend |
|-------|-------|---------|
| debian-12, debian-13 | `debian:12`, `debian:13` | apt |
| ubuntu-22.04, ubuntu-24.04, ubuntu-latest | `ubuntu:22.04`, `ubuntu:24.04`, `ubuntu:latest` | apt |
| archlinux | `archlinux:latest` | pacman |
| almalinux-9, almalinux-10 | `almalinux:9`, `almalinux:10` | dnf (dnf4 on both) |
| rockylinux-9, rockylinux-10 | `rockylinux/rockylinux:9`, `rockylinux/rockylinux:10` | dnf (dnf4) |
| opensuse-tumbleweed, opensuse-leap | `opensuse/tumbleweed:latest`, `opensuse/leap:latest` | zypper (`dist-upgrade` / `update`) |
The Rocky images come from the `rockylinux/` namespace: the Docker Hub library
`rockylinux:9` is a stale Rocky 9.3 build and `rockylinux:10` does not exist.
The rpm fixtures install `rpm-build` and `createrepo_c` from the distro mirrors, so
the rpm-family runs need network for that step (everything after it is offline).
### Docker image hygiene
The E2E matrix pulls a dozen distro images. To leave the host as it was:
- `tests/docker/snapshot.sh` (`make docker-snapshot`) records the image set in
`tests/docker/images-before.tsv` and `tests/docker/image-ids-before.txt`. It
**refuses to overwrite an existing snapshot**, so a second `make test` cannot
sneak the images pulled by the first run into the baseline.
- `tests/docker/cleanup.sh` (`make docker-cleanup`) restores that set: it points
pre-existing tags that a newer pull took over (e.g. `archlinux:latest`) back at
their original image, untags tags that are new, and deletes image ids that are
new. It never stops or removes containers; images still used by a container are
left in place and reported. It is idempotent and exits non-zero if a
pre-existing tag could not be restored. `--dry-run` prints the plan.
`make test` always runs the cleanup at the end; use `make test-no-cleanup` (or the
individual `test-unit` / `test-e2e` targets) while iterating and `make docker-cleanup`
when done. To re-baseline on purpose (for example after pulling images you want to
keep), run `make docker-cleanup` first if a test run happened since the last
snapshot, then:
```sh
tests/docker/snapshot.sh --force
```
## Known limitations
From the discovery reports (`discovery/*.md`), all reproduced there:
- **pacman**: excluding a package that another upgrade needs at a newer version
(e.g. `a` 2.0 depends on `b>=2.0`, `b` excluded) makes `pacman -Su --noconfirm`
abort the *whole* transaction (exit 1, nothing upgraded): `--noconfirm` answers
"skip the unresolvable package?" with no. Exclude the dependants too.
- **dnf4** (EL9/EL10): the same situation is an error (exit 1) unless `--nobest`
is used; dnf5 silently skips the dependant (exit 0). The tool prints a hint, it
does not retry with `--nobest`.
- **zypper on Tumbleweed**: `dist-upgrade` with a vendor change (or another
problem the solver cannot decide alone) is cancelled by `--non-interactive` with
exit 4; run `zypper dist-upgrade` by hand to pick a solution.
- **apt**: holding an excluded package also keeps back everything that needs a
newer version of it (shown in apt's own "kept back" output).
- **pacman**: the upgrade applies exactly the reviewed metadata: the reviewed transaction is
checked first (`pacman -Sup` against the throwaway database; if it fails, e.g. the ignored-
dependency trap above, nothing on the system changes and the tool exits with pacman's status),
then the reviewed databases are installed into `/var/lib/pacman/sync` (under pacman's own
`db.lck`, all-or-nothing) and `pacman -Su` runs. If a system database changed since the review
and is newer (you ran `pacman -Sy` meanwhile), nothing is changed and the tool exits with 2:
run it again. If that `pacman -Su` still fails or is interrupted (a file conflict, Ctrl-C, a
full disk), the system's sync database is newer than its installed packages - the tool warns;
finish with `pacman -Su` or run it again before any `pacman -S`. `--no-refresh` applies the existing databases with `-Su`.
Throwaway databases left by `kill -9` are removed at the start of the next root run.
- **dnf**: `--setopt=*.metadata_expire=-1` freezes cached metadata (verified on dnf4 and dnf5
against an HTTP repository), but libdnf reads local `file://` repositories live, so those
can change between review and upgrade. dnf5 is only exercised by the unit tests (with
recorded dnf5 output); the EL images of the E2E matrix all run dnf4.
- **zypper**: the private configuration assumes libzypp's layering (main file from `/etc`,
else `/usr/etc`; drop-ins of both `zypp.conf.d` directories, `/etc` winning on equal names,
in name order; nothing else when `ZYPP_CONF` is set). Verified on Tumbleweed and Leap 16 for
an `/etc` drop-in. On Leap the review list is `list-updates` (new dependencies are not rows);
on Tumbleweed the plan needs root, so unprivileged runs show `list-updates` with a note.
- Purges follow pacman's `-Rns` principle on every backend: a package that other installed
packages depend on is not purged (they are listed; purge them first), and only dependencies
that were installed automatically and that nothing else needs go along with the target -
dependencies installed by hand stay. The reverse-dependency query is the one `N?` uses (apt:
`apt-cache rdepends --installed`, which also counts a dependent whose candidate, not installed,
version needs the package; rpm: provides + files); a dependent it misses is still caught by the
check that the simulated removal holds nothing outside the target's dependency closure.
- **Closures are computed at trust time.** A dependency that appears later is new
to the system and is questioned once; run `skeptical-updater refresh` to re-trust
the current closures of all your trusted packages wholesale. A package shared by
several trusted roots is recorded under the first one only, so `untrust` of that
root drops it until the next `refresh`.
- The upgradable lists show what the native listing shows: new dependencies are rows on apt
and on Tumbleweed (`-` as the old version); on pacman and dnf new dependencies,
replacements and obsoletes only appear as a note, and removals only on Tumbleweed (note);
everything else the transaction does is visible in the package manager's own output.
## License
GPL-2.0-or-later, see [LICENSE](LICENSE).
+607
View File
@@ -0,0 +1,607 @@
# APT family (Debian / Ubuntu) - discovery for skeptical-updater
Everything below was verified empirically in throwaway containers (`docker run --rm -i <image> bash ...`) on
2026-10-09, always with `LC_ALL=C DEBIAN_FRONTEND=noninteractive`. Exit codes are quoted as `[exit=N]`.
Sample outputs are real; real-world package versions drift daily, the `su-dummy-*` fixture ones do not.
Companion files in `discovery/apt/` (all runnable, all proven on the five images):
| file | purpose |
|---|---|
| `fixture.sh` | builds the local flat repo + dummy packages (item 10) |
| `smoke.sh` | asserts every recipe in this document on top of the fixture (32 checks, passes on all five images) |
| `parse-inst.awk` | `Inst` line -> `name<TAB>old<TAB>new<TAB>repo` (item 2) |
| `closure-dpkg.awk` | exact installed dependency closure from the dpkg database (item 5) |
## 0. TL;DR - chosen commands
| need | command (all with `LC_ALL=C DEBIAN_FRONTEND=noninteractive`) |
|---|---|
| refresh | `apt-get update -qq -o APT::Update::Error-Mode=any` (without the `-o`, unreachable mirrors still give exit 0!) |
| list what the upgrade will do | `apt-get -s --with-new-pkgs upgrade \| awk -f parse-inst.awk` (same verb + flags as the real upgrade) |
| list kept-back/held | same output, block `The following packages have been kept back:` (do NOT use `-qq`, it drops it); `apt-mark showhold` for real holds |
| cross-check / "everything newer" | `apt list --upgradable 2>/dev/null` (stderr carries a warning, stdout has a `Listing...` header) |
| one-liner description | `apt-cache show --no-all-versions P \| sed -n 's/^Description: //p'` |
| full description (candidate) | `apt-cache show --no-all-versions P \| awk '<see item 3>'` |
| installed reverse deps | `apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P \| awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ \|]+/,"");print}' \| sort -u` |
| installed dep closure | exact: `dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' \| awk -F'\t' -v roots="P" -f closure-dpkg.awk` (== apt's own parser, verified). The single-call `apt-cache depends --recurse ... P` is **inexact in both modes** (with `--installed` it loses virtual-provided deps, without it over-approximates 2-8x): see item 5 |
| per-run exclusion | `apt-mark hold P` for packages not already held, restore from a persisted snapshot with `comm` (item 6) |
| upgrade | `apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade` |
| purge | `apt-get -y purge P`, then `apt-get -y autoremove --purge` |
| bats | `apt-get install -y --no-install-recommends bats` (1.2.1 .. 1.13.0 depending on image) |
Top gotchas (details in the sections):
1. `apt-get update` exits **0** when every mirror is unreachable (only `W:` lines). Use `-o APT::Update::Error-Mode=any` (or `--error-on=any`) to get exit 100. Hard errors (unsigned repo, no root) are exit 100 regardless.
2. `apt list` / `apt show` are the "unstable CLI": a warning is printed on **stderr** whenever stdout is not a tty. Use `apt-get` / `apt-cache` / `dpkg-query` / `apt-mark` for parsing.
3. All five images run as **root with no sudo, no python3**; `apt-get update` must be run first (lists are empty).
4. `-y` upgrade with a *locally modified conffile* that the new package also changes => dpkg prompts, hits EOF on stdin, fails, **exit 100**, package left in `iU` state. Always pass `--force-confdef --force-confold`.
5. `apt-cache depends --recurse` cannot produce an exact installed closure: with `--installed` it silently loses everything reachable only through a **virtual package** (e.g. `base-files` -> `awk` -> `mawk`: closure of 1 instead of 5 on debian:12) and may still print non-installed provider names; without `--installed` it walks through non-installed alternatives/providers and uses *candidate* metadata (debian:12 `bash`: 19 names vs the exact 8). Use the dpkg-database awk (`closure-dpkg.awk`).
6. `apt-cache rdepends` does not follow `Provides` (nobody is listed as reverse dependency of `mawk` although `base-files` needs `awk`), lists duplicates (one per version), and `--installed` filters on the *package* being installed, not the *version* that has the dependency.
7. `apt-get` takes the dpkg lock: a concurrent apt gives exit 100 `Could not get lock /var/lib/dpkg/lock-frontend` (also `apt-mark hold`, with a misleading "Are you root?"). `-o DPkg::Lock::Timeout=N` waits instead.
8. Holding a package also keeps back everything that needs a newer version of it (hold `b` => `a` kept back). `apt-mark hold` with a mix of valid/invalid names holds the valid ones **and** exits 100.
9. stderr noise is not failure: minimal images print `debconf: delaying package configuration, since apt-utils is not installed` on every dpkg run. Trust exit codes.
10. Plain `apt-get install bats` pulls `parallel` + perl (39 pkgs / 85 MB on debian:12, 60 / 159 MB on ubuntu:24.04) and may upgrade unrelated packages; use `--no-install-recommends`.
11. `ubuntu:latest` is now **26.04 (apt 3.2.0, new solver3)**: prints `Solving dependencies...`, and `dist-upgrade` refuses to remove manually installed packages (keeps them back instead).
## 1. Test matrix and base-image facts
| image | resolves to | image id | apt | dpkg | bash | awk | bats (apt) |
|---|---|---|---|---|---|---|---|
| debian:12 | Debian 12 bookworm | 94926f0fd968 | 2.6.1 | 1.21.23 | 5.2.15 | mawk 1.3.4 20200120 | 1.8.2-1 |
| debian:13 | Debian 13 trixie | 99339bdd341a | 3.0.3 | 1.22.22 | 5.2.37 | mawk 1.3.4 20250131 | 1.11.1-1 |
| ubuntu:22.04 | Ubuntu 22.04.5 jammy | e8df6c74f650 | 2.4.14 | 1.21.1 | 5.1.16 | mawk 1.3.4 20200120 | 1.2.1-3build1 |
| ubuntu:24.04 | Ubuntu 24.04.5 noble | 69f919497cad | 2.8.3 | 1.22.6 | 5.2.21 | mawk 1.3.4 20240123 | 1.10.0-1 |
| ubuntu:latest | **Ubuntu 26.04.1 resolute** | 6e92c4fd8dfd | 3.2.0 | 1.23.7 | 5.3.9 | mawk 1.3.4 20260129 | 1.13.0-1 |
Identical on all five (item 9):
```
$ id -u; whoami -> 0, root (docker default user; no USER directive)
$ command -v sudo -> (nothing) sudo is NOT installed
$ command -v python3 -> (nothing) python3 is NOT installed
$ command -v bash -> /usr/bin/bash bash present (5.1 .. 5.3); /bin/sh is dash
perl, flock, sed (GNU), grep, awk (mawk), dpkg-deb: present. curl, wget, gpg, dpkg-scanpackages, apt-ftparchive: absent.
```
- Do not rely on gawk features: `awk` is **mawk** everywhere and `sed` is GNU sed 4.8/4.9. Verified on mawk: `gensub()` -> `function gensub never defined`; 3-argument `match(s,r,arr)` -> syntax error; regex brace repetition `a{2}` is matched **literally** on mawk 1.3.4 20200120 (debian:12, ubuntu:22.04) and works on 20240123+ (debian:13, ubuntu:24.04, ubuntu:latest) - so avoid `{n}` in awk regexes.
- `/var/lib/apt/lists` is empty at start: nothing works (`apt-cache policy` has no candidates) until `apt-get update`.
- Docker-specific apt config shipped in the images: `docker-clean` (`Dir::Cache::pkgcache ""` => **no binary cache**, every `apt-get/apt-cache/apt-mark` call re-parses all lists), `docker-no-languages` (`Acquire::Languages "none"` => descriptions come from the `Packages` file, no Translation files), `docker-gzip-indexes`, `docker-autoremove-suggests` (`SuggestsImportant "false"`), `docker-disable-periodic-update` (ubuntu), `/usr/sbin/policy-rc.d` (`exit 101`, services are not started by maintainer scripts).
- Sources point to the **live** mirrors (`deb.debian.org`, `archive.ubuntu.com`, `security.ubuntu.com`) although the debian images carry a commented snapshot date. Therefore real-world upgradable packages exist in the images and change over time. Seen on 2026-10-09:
| image | real-world upgradable right now |
|---|---|
| debian:12 | `perl-base` 5.36.0-7+deb12u3 -> u4 (`oldstable-security`) |
| debian:13 | `liblzma5` 5.8.1-1+deb13u1 -> u2 (`stable-security`) |
| ubuntu:22.04 | `libssl3` 3.0.2-0ubuntu1.29 -> .30 |
| ubuntu:24.04 | `libaudit-common`, `libaudit1`, `libssl3t64` |
| ubuntu:latest | `libssl3t64`, `openssl-provider-legacy` 3.5.5-1ubuntu3.5 -> .7 |
- bats (item 9): `apt-get install -y --no-install-recommends bats` works on all five (exit 0, 0.7-1.7 s, 1 package, <200 kB; `bats --version` as in the table; `bats --tap` works). Helper libs `bats-support bats-assert bats-file` exist on every image **except ubuntu:22.04**. Oldest is **1.2.1** (jammy) which lacks `run --separate-stderr`, `bats_require_minimum_version`, `setup_suite`: restrict tests to the 1.2.1 feature set or vendor bats-core. `Recommends: parallel` (that is what bloats a plain install).
## 2. Item 1 - refreshing metadata: `apt-get update`
```
$ apt-get update (debian:12, root, network ok) [exit=0]
Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB]
Get:2 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]
Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [34.8 kB]
Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8790 kB]
Get:5 http://deb.debian.org/debian bookworm-updates/main amd64 Packages [6924 B]
...
Fetched 9388 kB in 2s (5412 kB/s)
Reading package lists...
```
Second run: `Hit:1 ... InRelease` lines, exit 0. `-qq` prints nothing on success (still exit 0); warnings/errors still go to **stderr** under `-qq`.
| situation | exit | where |
|---|---|---|
| success / nothing new (`Hit:`) | 0 | |
| not root | **100** | `E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)` + `E: Unable to lock directory /var/lib/apt/lists/` |
| all mirrors unreachable (`--network none`) | **0** (!) | stdout `Err:1 ...` + `Temporary failure resolving 'deb.debian.org'`; stderr `W: Failed to fetch ...` and `W: Some index files failed to download. They have been ignored, or old ones used instead.` |
| one of several sources unreachable | **0** (!) | same `W:` lines |
| same two cases with `-o APT::Update::Error-Mode=any` (or `--error-on=any`) | **100** | the `W:` become `E:` (same text) |
| unsigned repo (no `[trusted=yes]`) | **100** | `E: The repository 'file:/srv/su-repo ./ Release' is not signed.` |
The "unreachable => exit 0" row and the `Error-Mode=any` fix are identical on all five images. After a failed update the old lists are kept (verified with a dead `Acquire::http::Proxy`: exit 0, `W: Failed to fetch ...`, the 3 `Packages` files and the candidate versions are unchanged = stale data) - so a tool must either use `Error-Mode=any` or parse stderr for `^W: Failed to fetch`.
Other notes: `apt-get update --print-uris` lists the index URLs without fetching. A second `update` of a `file:` repo regenerated within the same second can be seen as unchanged (mtime resolution): `fixture.sh` removes the repo's list files first.
## 3. Item 2 - listing upgradable packages
### 3.1 The candidates compared (real data, ubuntu:24.04)
`apt list --upgradable` (stdout and stderr captured separately) [exit=0]:
```
stdout:
Listing...
libaudit-common/noble-updates 1:3.1.2-2.1ubuntu0.1 all [upgradable from: 1:3.1.2-2.1build1.1]
libaudit1/noble-updates 1:3.1.2-2.1ubuntu0.1 amd64 [upgradable from: 1:3.1.2-2.1build1.1]
libssl3t64/noble-updates,noble-security 3.0.13-0ubuntu3.16 amd64 [upgradable from: 3.0.13-0ubuntu3.15]
stderr:
(empty line)
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
```
- stderr warning on all five images whenever stdout is not a tty; `Listing...` header on stdout; origin column is only the suite(s) comma-separated (no Label/Version); the name has **no arch qualifier** (with multiarch the same name appears twice: `su-dummy-m/su-repo 2.0 amd64 ...` and `su-dummy-m/su-repo 2.0 i386 ...`); it lists **every** package whose candidate is newer, including held and kept-back ones (it is not "what the upgrade will do").
`apt-get -s --with-new-pkgs upgrade` (ubuntu:24.04) [exit=0]:
```
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
libaudit-common libaudit1 libssl3t64
3 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst libaudit-common [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
Conf libaudit-common (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
Inst libaudit1 [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [amd64])
Inst libssl3t64 [3.0.13-0ubuntu3.15] (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
```
debian:12: `Inst perl-base [5.36.0-7+deb12u3] (5.36.0-7+deb12u4 Debian-Security:12/oldstable-security [amd64])`.
`apt-get -s dist-upgrade` prints the identical `Inst` lines when no removals/new packages are involved (all three verbs gave the same count on the real data).
### 3.2 Decision
Use **`LC_ALL=C apt-get -s --with-new-pkgs upgrade`** and parse the `Inst` lines:
- it lists exactly what the real `apt-get --with-new-pkgs upgrade` will do (same resolver, same holds/pins), including *new* dependency packages and the correct order;
- no stderr warning, works **as non-root** (prints a `NOTE: This is only a simulation!` block on stdout, harmless), needs no lock;
- carries old version, new version, full origin and arch;
- `Inst`/`Conf`/`Remv`/`Purg` line prefixes are not translated, but the `kept back` header is, hence `LC_ALL=C`.
Keep `apt list --upgradable 2>/dev/null` only as a cross-check (it is the superset: includes held/kept-back).
### 3.3 Exact parse -> `name<TAB>old<TAB>new<TAB>repo` (`discovery/apt/parse-inst.awk`, POSIX awk, tested with mawk)
```awk
/^Inst / {
name = $2
rest = $0
sub(/^Inst [^ ]+ /, "", rest)
old = "-"
if (substr(rest, 1, 1) == "[") { # "[old] (new origins [arch])"
old = rest
sub(/^\[/, "", old)
sub(/\].*$/, "", old)
sub(/^\[[^]]*\] /, "", rest)
}
sub(/^\(/, "", rest) # "new origins [arch]) ..."
new = rest
sub(/ .*$/, "", new)
sub(/^[^ ]+ ?/, "", rest) # "origins [arch]) ..."
i = index(rest, "])")
repo = substr(rest, 1, i - 1) # "origins [arch"
sub(/ ?\[[^[]*$/, "", repo) # drop " [arch"
printf "%s\t%s\t%s\t%s\n", name, old, new, repo
}
```
Usage: `LC_ALL=C apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk -f parse-inst.awk`. Real output:
```
perl-base 5.36.0-7+deb12u3 5.36.0-7+deb12u4 Debian-Security:12/oldstable-security
libssl3t64 3.0.13-0ubuntu3.15 3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security
libaudit-common 1:3.1.2-2.1build1.1 1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates
su-dummy-newdep - 1.0 su-fixture:su-repo
```
Edge cases, proven with a unit test on literal lines (real lines plus two hand-written ones modelled on them, the `libc6:i386` line and the `Inst su-dummy-slow` line with an empty origin) - input on the left, TSV output on the right:
```
Inst su-dummy-newdep (1.0 su-fixture:su-repo [all]) -> su-dummy-newdep - 1.0 su-fixture:su-repo
Inst su-dummy-slow [0.9] (1.0 [all]) -> su-dummy-slow 0.9 1.0 (empty repo)
Inst su-dummy-m [1.0] (2.0 su-fixture:su-repo [amd64]) [su-dummy-m:amd64 on su-dummy-m:i386] [su-dummy-m:i386 ]
-> su-dummy-m 1.0 2.0 su-fixture:su-repo
Inst libc6:i386 [2.36-9] (2.36-9+deb12u10 Debian-Security:12/oldstable-security, Debian:12.12/oldstable [i386]) []
-> libc6:i386 2.36-9 2.36-9+deb12u10 Debian-Security:12/oldstable-security, Debian:12.12/oldstable
Conf ... / Remv su-dummy-f [1.0] -> (ignored)
```
(`smoke.sh` asserts the plain fixture line and the new-package `-` case on every image; the real-data lines above come from the images themselves.)
- **new package** (pulled in as dependency): `Inst su-dummy-newdep (1.0 su-fixture:su-repo [all])` has no `[old]` => old column is `-` (not empty: `IFS=$'\t' read` collapses empty tab fields because tab is IFS-whitespace).
- **multiple origins**: `Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security` (comma+space, kept verbatim in the repo column).
- **empty origin** (package from a local .deb): `(1.0 [all])` -> repo is empty.
- **foreign arch**: name carries the qualifier, `libc6:i386`; native arch has none.
- **trailing junk after the closing paren** (multiarch pairs): `Inst su-dummy-m [1.0] (2.0 su-fixture:su-repo [amd64]) [su-dummy-m:amd64 on su-dummy-m:i386] [su-dummy-m:i386 ]` - hence the parser cuts at the first `])`, never anchors on `$`. A bare ` []` may also trail.
- `Inst` order is **dependency order** (b before a), not alphabetical: `sort` if a stable order is needed.
- Repo string format is `Label:Version/Suite` (from the Release file; `Debian-Security:12/oldstable-security`, `Ubuntu:24.04/noble-updates`); fixture repo (no Version) gives `su-fixture:su-repo`; with neither Label nor Suite apt prints the host (`localhost`).
Secondary parse of `apt list --upgradable` (if ever needed), same 4 columns (repo = suite list):
```sh
apt list --upgradable 2>/dev/null | sed -nE 's#^([^/]+)/([^ ]+) ([^ ]+) ([^ ]+) \[upgradable from: ([^]]+)\]$#\1\t\5\t\3\t\2#p'
```
(`Listing...` is skipped by the anchored pattern; no arch disambiguation.)
### 3.4 Held and kept-back packages
Held packages never appear as `Inst`. They show up in the `kept back` block together with packages kept back for dependency reasons, and in the "N not upgraded" count; `apt list --upgradable` still lists them. Real output (fixture, `su-dummy-c` held, `-d` needs a new package, `-e` needs a removal):
```
$ apt-mark hold su-dummy-c ; apt-get -s upgrade
The following packages have been kept back:
su-dummy-c su-dummy-d su-dummy-e
The following packages will be upgraded:
su-dummy-a su-dummy-b su-dummy-w
3 upgraded, 0 newly installed, 0 to remove and 3 not upgraded.
```
Parse of the block (names are space separated, indented by two spaces, wrapped at 80 columns):
```sh
awk '/^The following packages have been kept back:/{k=1;next} k&&/^ /{for(i=1;i<=NF;i++)print $i;next} {k=0}'
```
Distinguish "held" from "kept back for other reasons" with `apt-mark showhold`. **Do not use `-qq`** with `-s` when you need this block: `apt-get -qq -s ...` prints only the `Inst`/`Conf` lines (verified).
Behaviour of the three upgrade verbs on the same state (fixture `SU_FIXTURE_EXTRA=1`: d needs a NEW dependency, e conflicts with installed f; debian:12/13, ubuntu:22.04/24.04 identical):
| verb | upgraded | newly installed | removed | kept back |
|---|---|---|---|---|
| `apt-get upgrade` | a b c w | - | - | d e |
| `apt-get --with-new-pkgs upgrade` | a b c d w | su-dummy-newdep | - | e |
| `apt-get dist-upgrade` | a b c d e w | su-dummy-newdep | su-dummy-f | - |
(`ubuntu:latest`/apt 3.2 solver3: `dist-upgrade` does **not** remove the manually installed `su-dummy-f`, so `e` stays kept back there: 5 upgraded, 1 new, 0 removed.) `--with-new-pkgs upgrade` is the safe choice: it never removes anything.
## 4. Item 3 - description of a package
Candidate vs installed matters. Real data (debian:12, `perl-base` installed u3, candidate u4):
```
$ apt-cache policy perl-base
perl-base:
Installed: 5.36.0-7+deb12u3
Candidate: 5.36.0-7+deb12u4
Version table:
5.36.0-7+deb12u4 500
500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages
*** 5.36.0-7+deb12u3 500
500 http://deb.debian.org/debian bookworm/main amd64 Packages
100 /var/lib/dpkg/status
```
| command | content | notes |
|---|---|---|
| `apt-cache policy P` | Installed / Candidate / version table with pins and origins | no description at all; exit 0 and **empty output** for an unknown package |
| `apt-cache show P` | full control record(s) **of all versions** (default `--all-versions`: 2 stanzas for perl-base) | `--no-all-versions` => only the candidate (1 stanza); `P=1.0` selects one version; unknown package: `E: No packages found` **exit 100**; purely virtual: no output, exit 0 |
| `dpkg -s P` / `dpkg-query -W -f '${Description}' P` | record of the **installed** version only | non-installed (known or unknown) package: `dpkg-query: package 'x' is not installed and no information is available`, **exit 1** (`dpkg-query -W -f '${binary:Summary}'` prints `(no description available)` with exit 0) |
| `apt show P` | like `apt-cache show` plus formatting | stderr warning, not for scripts |
Short description (one line):
```sh
apt-cache show --no-all-versions "$pkg" | sed -n 's/^Description: //p' # candidate -> minimal Perl system
dpkg-query -W -f='${binary:Summary}\n' "$pkg" # installed version
apt-cache search --names-only "^$pkg\$" # "perl-base - minimal Perl system"
```
Full Description paragraph of the **candidate** (first line, continuation lines with the single leading space stripped, ` .` turned into an empty line, stops at the next field):
```sh
apt-cache show --no-all-versions "$pkg" | awk '
/^Description(-[a-z][a-z](_[A-Z][A-Z])?)?:/ { p=1; sub(/^[^:]*: ?/,""); print; next }
p && /^ / { l=substr($0,2); print (l=="." ? "" : l); next }
p { exit }'
```
Verified output for the fixture package (identical on debian:12 and ubuntu:24.04; `Description-md5:` is correctly not matched):
```
dummy package su-dummy-a for skeptical-updater tests
This package only exists to exercise the package manager.
It ships one data file and one conffile (version 2.0).
```
Raw record order differs per distro (`Description` is followed by `Description-md5`, `Homepage`, ... on Debian; Ubuntu 24.04+/Debian 13 print an empty `Description-md5:` for installed-only stanzas) - that is why the awk stops at the first non-continuation line instead of counting lines. `dpkg -s` shows the same text for the installed version (`... (version 1.0)` in the fixture, `2.0` for the candidate).
## 5. Item 4 - installed reverse dependencies
Flags: `apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P`.
Without the `--no-conflicts/--no-breaks/...` flags Conflicts/Breaks/Replaces are included (fixture: `su-dummy-e` shows up as "reverse dependency" of `su-dummy-f` only because of `Conflicts`; on debian:12 `libc6`: 76 entries default vs 75 filtered). Pre-Depends are always included (good).
Output format (fixture, debian:12) [exit=0]:
```
su-dummy-b
Reverse Depends:
su-dummy-a
su-dummy-a
```
real data (`gpgv`, debian:12, an alternative `gpgv | gpgv2 | gpgv1` in apt's Depends):
```
gpgv
Reverse Depends:
|apt
```
- line 1 = the package, line 2 = `Reverse Depends:`, then one dependent per line, 2 spaces indent; a leading ` |` marks "I depend on this **or an alternative**" (the `|` replaces the first space).
- **duplicates**: one entry per package *version* that has the dependency (installed 1.0 and candidate 2.0 both => twice; ubuntu repeats per pocket) => `sort -u`.
- unknown package: `E: No packages found` **[exit=100]**. Purely virtual name (`awk`, `su-virtual`): prints only `<awk>` [exit=0], never the dependents.
- Not-installed package with `--installed`: still answers (lists installed dependents).
Parse to plain names (one package per call):
```sh
apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "$pkg" \
| awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ |]+/,""); print}' | sort -u
```
Real: `libgcrypt20` -> `gpgv libapt-pkg6.0 libsystemd0` (debian:12), `gpgv libapt-pkg6.0t64 libsystemd0` (ubuntu:24.04); without `--installed` the same query returns 155 / 171 names.
For several packages in one call the output is a repeated block (`pkg` / `Reverse Depends:` / entries); track the block header (first non-indented line) if needed.
Known inexactness (all verified):
1. **`Provides` is not followed**: `apt-cache rdepends --installed ... mawk` => empty, although `base-files` (Pre-)Depends on the virtual `awk` that `mawk` provides. Fixture: `su-dummy-w` depends on `su-virtual | su-dummy-alt`, `su-dummy-v` provides `su-virtual` => `rdepends su-dummy-v` is empty. To be complete, also query every name in the target's `Provides:` (`apt-cache show --no-all-versions P | sed -n 's/^Provides: //p'`) - but `rdepends <virtual>` itself prints only `<virtual>`, so you must instead scan for the virtual in `apt-cache showpkg <virtual>` ("Reverse Depends:" section lists `su-dummy-w,su-virtual`) or use the dpkg database.
2. `--installed` filters on the dependent **package** being installed, not on the installed **version** having the dependency: `rdepends --installed su-dummy-newdep` lists `su-dummy-d` although only the not-yet-installed d 2.0 depends on it.
3. Exact alternative for "what would break if I removed P": `apt-get -s purge P | awk '/^Purg /{print $2}'` (transitive, uses the installed versions, the output includes P itself): fixture `apt-get -s purge su-dummy-b` => `Purg su-dummy-a [1.0]`, `Purg su-dummy-b [1.0]` [exit=0]. For Essential chains it errors (`E: Error, pkgProblemResolver::Resolve generated breaks`, `apt-get -s remove libgcrypt20`).
## 6. Item 5 - recursive dependency closure restricted to installed packages
Requested form: `apt-cache depends --recurse --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P`.
### 6.1 Output format (debian:12, `apt`)
```
apt <- column 0: a package header (one stanza per reachable package; --recurse output is FLAT, not a tree)
Depends: adduser <- 2 spaces: dependency ("PreDepends:" for Pre-Depends)
|Depends: gpgv <- leading " |": member of an alternative group "gpgv | gpgv2 | gpgv1"; the last member has no '|'
Depends: libapt-pkg6.0
Depends: libsystemd0
libelogind0 <- 4 spaces: packages that PROVIDE the dependency name above (not filtered by --installed!)
adduser
Depends: passwd
gpgv
Depends: libbz2-1.0
...
```
Without `--installed` the group is `|Depends: gpgv`, `|Depends: gpgv2`, ` Depends: gpgv1`. A purely virtual dependency is printed in angle brackets with its providers below it, and later as its own (empty) stanza:
```
ca-certificates (NOT installed; plain depends) [exit=0]
Depends: openssl
|Depends: debconf
Depends: <debconf-2.0>
cdebconf
debconf
```
- Package **not installed**: exit 0, the header and its dependencies are printed anyway; with `--installed` only the installed dependencies (` |Depends: debconf`; `openssl` and `<debconf-2.0>` disappear), and `--recurse --installed` yields `ca-certificates` (the root, although not installed) + `debconf`.
- Unknown package: `E: No packages found` [exit=100]. Purely virtual root: prints `<name>` [exit=0].
- `depends P` uses the **candidate** version's dependencies (fixture: installed `su-dummy-d 1.0` has none, candidate 2.0 needs `su-dummy-newdep` => `apt-cache depends su-dummy-d` shows it; `apt-cache depends su-dummy-d=1.0` shows nothing). Pin a version with `P=VERSION` to get the installed version's dependencies.
### 6.2 What the single-call forms really return (real lists, all five images)
Reference ("exact") = the installed versions' Pre-Depends/Depends, expanded only through installed packages, every installed alternative followed, virtuals resolved through installed providers. It was computed two independent ways that agree 100% (`comm` diff empty) for 5 roots x 5 images: (D) a BFS driving `apt-cache depends --no-... pkg=INSTALLED_VERSION` (apt's own parser), and (C) `closure-dpkg.awk` over the dpkg database (6.4).
**A. with `--installed`** (as requested) - under-approximates, and is not even purely "installed":
```
$ apt-cache depends --recurse --installed <flags> base-files (debian:12)
base-files <- the whole closure is ONE package (exact: 5)
```
Virtual packages are not "installed", so `--installed` prunes every dependency satisfied through `Provides` and the recursion stops there (`base-files` has `PreDepends: <awk>`, provider `mawk`). Sizes `--installed` vs exact: `base-files` 1 vs 5 (debian:12/13), 5 vs 6 (ubuntu 22.04/24.04), 6 vs 7 (ubuntu:latest); `bash` 7 vs 8, 7 vs 8, 8 vs 9, 8 vs 9, 9 vs 10 (always missing `mawk`); fixture `su-dummy-w` -> `<su-virtual>` -> `su-dummy-v` lost the same way (1 vs 2). Conversely the provider lines are not filtered and get recursed: non-installed names such as `libelogind0` (debian:12 `apt`: 46 vs 45) or `gpgv-from-sq` (ubuntu:latest `apt`: 27 vs 26) appear as column-0 headers. A non-installed root is still printed as the first stanza.
**B. without `--installed`, then intersect with dpkg-installed names** - over-approximates, because apt-cache walks into *non-installed* alternatives/providers (`debconf-2.0` -> `cdebconf`, `gpgv2`, ...) and uses the *candidate* version's dependencies:
| root | exact | B | | root | exact | B |
|---|---|---|---|---|---|---|
| debian:12 `apt` | 45 | 50 | | debian:13 `apt` | 27 | 46 |
| debian:12 `base-files` | 5 | 17 | | ubuntu:24.04 `apt` | 35 | 49 |
| debian:12 `bash` | 8 | 19 | | ubuntu:latest `apt` | 26 | 45 |
| debian:12 `tzdata` | 2 | 16 | | ubuntu:22.04 `bash` | 9 | 19 |
(the extras are installed packages such as `dpkg libacl1 tar libselinux1 libtinfo6 ...` that are reached only by recursing into **non-installed** providers/alternatives: `cdebconf` (+ `libnewt0.52 libslang2 ...`) for `tzdata`'s `debconf | debconf-2.0`, `gawk`/`original-awk` (+ `libmpfr6 libreadline8 ...`) for the virtual `awk` of `base-files`/`bash`; verified by listing the non-installed column-0 stanzas of the recursion). In addition `depends` uses the *candidate* version's dependencies (fixture `su-dummy-d`), which differ from the installed ones whenever an upgrade changes dependencies. B is a superset of the exact closure, A is neither a subset nor a superset. With the offline fixture (no real lists, apt only knows installed versions) A, B and exact differ only by the virtual case.
Other facts: cost of the recursion itself is nil (0.35 s with and without `--installed` on debian:12, dominated by re-parsing the lists); the foreign-arch names come as `name:arch`.
### 6.3 If a single apt-cache call is preferred anyway (superset semantics, e.g. "candidate/post-upgrade closure")
```sh
FLAGS="--no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances"
installed_names() { dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\n' | awk -F'\t' 'substr($1,2,1) !~ /[nc]/ {print $2}' | sort -u; }
comm -12 \
<(apt-cache depends --recurse $FLAGS "$pkg" | awk '/^[^ <]/' | sort -u) \
<(installed_names)
```
- `awk '/^[^ <]/'` = column-0 lines = every package that was reached, minus `<virtual>` headers; the `|` markers (` |Depends: x`) and the 4-space provider lines are indented, hence ignored (`|alternatives` are therefore all followed, installed or not).
- `substr($1,2,1) !~ /[nc]/`: dpkg state is not `n`(ot-installed)/`c`(onfig-files only); `ii`, `iU`, `iF` count as installed, `rc` does not.
- Use `${Package}:${Architecture}` on both sides if foreign architectures matter (apt-cache prints `name:arch` for non-native only).
### 6.4 Recommended (exact, no apt call): `discovery/apt/closure-dpkg.awk`
```sh
dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' \
| awk -F'\t' -v roots="pkg1 pkg2" -f closure-dpkg.awk | sort -u
```
Follows Pre-Depends + Depends of the **installed** versions, every installed alternative of an `a | b` group, virtual names through the `Provides` of installed packages (versioned `Provides: x (= 1.2)` and `:any` qualifiers handled); prints plain names, roots included if installed (a non-installed root yields nothing). ~0 s (single dpkg-query). Output verified identical to the apt-cache BFS reference on `apt base-files bash libpam-runtime tzdata` on all five images (e.g. debian:12 `apt` 45 = 45, `bash` 8 = 8, `base-files` 5 = 5 incl. `mawk`; ubuntu:24.04 `tzdata` not installed => empty).
## 7. Item 6 - per-run exclusion with `apt-mark hold / unhold / showhold`
Real outputs (fixture, debian:12):
```
$ apt-mark hold su-dummy-c su-dummy-c set on hold. [exit=0]
$ apt-mark hold su-dummy-c su-dummy-c was already set on hold. [exit=0]
$ apt-mark showhold su-dummy-c [exit=0] (works as non-root too)
$ apt-mark unhold su-dummy-c Canceled hold on su-dummy-c. [exit=0]
$ apt-mark unhold su-dummy-a su-dummy-a was already not on hold. [exit=0]
$ apt-mark hold no-such-pkg-xyz E: Unable to locate package no-such-pkg-xyz / E: No packages found [exit=100]
$ apt-mark hold su-dummy-newdep no-such-pkg-xyz su-dummy-newdep set on hold. / E: Unable to locate package ... [exit=100] <- the valid one IS held
$ (non-root) apt-mark hold X dpkg: error: required read/write access to the dpkg database directory / E: Executing dpkg failed. Are you root? [exit=100]
$ dpkg-query -W -f '${Package} ${Status}\n' su-dummy-c su-dummy-c hold ok installed
$ dpkg --get-selections | grep hold su-dummy-c<TABs>hold
```
- A hold is a dpkg selection state (stored in `/var/lib/dpkg/status`): persistent, visible to `dpkg`, needs root **and the dpkg lock** (`apt-mark hold` while another apt runs: `dpkg: error: dpkg frontend lock was locked by another process`, exit 100).
- Holding a package that is **not installed** works (`set on hold`), shows in `showhold`, and blocks its installation as a new dependency (it made `su-dummy-d` kept back), but `dpkg --get-selections` does not list it.
- Foreign arch is shown as `name:i386`.
**`apt-get --with-new-pkgs upgrade` skips held packages** - proven (fixture, real run, `su-dummy-c` held):
```
$ apt-get -y --with-new-pkgs upgrade [exit=0]
The following NEW packages will be installed: su-dummy-newdep
The following packages have been kept back: su-dummy-c su-dummy-e
The following packages will be upgraded: su-dummy-a su-dummy-b su-dummy-d su-dummy-w
4 upgraded, 1 newly installed, 0 to remove and 2 not upgraded.
=> a=2.0 b=2.0 c=1.0 (held, untouched) d=2.0 e=1.0 (kept back)
```
- The hold cascades: holding `b` (while `a` 2.0 needs `b (>= 2.0)`) keeps **a** back too (`kept back: su-dummy-a su-dummy-b su-dummy-c ...`, exit 0). Always re-run the simulation after holding and show the user the real result.
- `dist-upgrade` honours holds the same way. `apt-get -y purge <held>` fails: `E: Held packages were changed and -y was used without --allow-change-held-packages.` [exit=100].
- No-state alternative (inclusion list instead of exclusion): `apt-get -y --only-upgrade install P1 P2 ...` upgrades only those (plus whatever they need: `a` drags `b` along; `d` drags `su-dummy-newdep`; a not-installed name is skipped with exit 0; if the needed dependency is held it fails `E: Unable to correct problems, you have held broken packages.` exit 100).
### Recipe: hold temporarily and restore the previous hold set EXACTLY (verified, incl. crash recovery)
Idea: never touch what the admin already holds; journal (write-ahead) every hold *we* add to a state file; restore = unhold exactly the journaled packages that are still held. The journal survives `kill -9`, so the next run can undo a leaked hold without disturbing holds the admin added/removed in the meantime (a snapshot-and-diff restore would revert such admin edits - tested, rejected).
```bash
SU_STATE_DIR=${SU_STATE_DIR:-/var/lib/skeptical-updater}
SU_HOLDS_ADDED=$SU_STATE_DIR/holds.added # write-ahead journal: the holds WE put (one name per line)
su_holds_begin() { # su_holds_begin PKG... (packages to exclude from this run)
mkdir -p "$SU_STATE_DIR" || return 1
local held p; held=$(apt-mark showhold) || return 1
: > "$SU_HOLDS_ADDED"
for p in "$@"; do
grep -qxF -- "$p" <<<"$held" && continue # already held by the admin: not ours, never touched
echo "$p" >> "$SU_HOLDS_ADDED" # journal first (write-ahead) ...
apt-mark hold "$p" >/dev/null || return 1 # ... then hold (on failure the caller runs su_holds_restore)
done
}
su_holds_restore() { # idempotent; also call it at the START of every run (crash recovery)
[ -f "$SU_HOLDS_ADDED" ] || return 0
apt-mark showhold | sort -u | comm -12 - <(sort -u "$SU_HOLDS_ADDED") | xargs -r apt-mark unhold >/dev/null
rm -f "$SU_HOLDS_ADDED"
}
trap su_holds_restore EXIT # plus INT/TERM; SIGKILL cannot be trapped -> journal + start-of-run call
```
Verified scenarios (debian:12; the same logic is asserted by `smoke.sh` on all five images):
```
admin before : [su-dummy-a su-dummy-alt ] <- a installed, alt NOT installed
su_holds_begin su-dummy-a su-dummy-c journal: [su-dummy-c ] (a is the admin's: not journaled)
during : [su-dummy-a su-dummy-alt su-dummy-c ]
upgrade exit=0 a=1.0 (held) c=1.0 (held), b/d/w upgraded
after restore: [su-dummy-a su-dummy-alt ] <- identical to "before"; a 2nd restore is a no-op (rc=0)
crash: su_holds_begin su-dummy-c su-dummy-b ; kill -9 ; admin: hold su-dummy-e, unhold su-dummy-alt
leaked : [su-dummy-a su-dummy-alt su-dummy-b su-dummy-c ]
admin edits : [su-dummy-a su-dummy-b su-dummy-c su-dummy-e ]
recovered : [su-dummy-a su-dummy-e ] <- only b,c released; the admin's new hold e kept, the admin's removal of alt respected
```
Notes: each `apt-mark` call costs 0.3-0.6 s on these images (no cache) - pass many names to one call where possible (a bad name in a multi-name call holds the valid ones and exits 100, so validate names first: `apt-cache show --no-all-versions N >/dev/null`); `apt-mark`/`xargs -r` needs root and the dpkg lock (use the same `DPkg::Lock::Timeout` discipline: `apt-mark hold` has no timeout option, retry on exit 100 when the message is `frontend lock was locked`).
## 8. Item 7 - upgrading non-interactively
```
$ apt-get -y --with-new-pkgs upgrade (fixture; held c) [exit=0]
...
Need to get 0 B/4188 B of archives.
Get:1 file:/srv/su-repo ./ su-dummy-b 2.0 [826 B]
(Reading database ... 6114 files and directories currently installed.)
Preparing to unpack ..././su-dummy-b_2.0_all.deb ...
Unpacking su-dummy-b (2.0) over (1.0) ...
Setting up su-dummy-b (2.0) ...
Installing new version of config file /etc/su-dummy/su-dummy-b.conf ...
```
Exit codes (identical on the five images unless noted):
| case | exit | evidence |
|---|---|---|
| success | 0 | |
| nothing to upgrade (all three verbs) | **0**, ends with `0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.` | `apt-get -qq -s` prints nothing |
| kept back / held packages remain | **0** | summary `... and 2 not upgraded.` |
| dpkg fails (postinst `exit 1`, fixture `SU_FIXTURE_BAD=1`) | **100** | stdout `Errors were encountered while processing:` + pkg; stderr `E: Sub-process /usr/bin/dpkg returned an error code (1)`; other packages in the same run are still configured; the bad package is left `iF` (half-configured) and every later `apt-get upgrade`/`install -f` fails again with 100 until fixed |
| conffile conflict, no `--force-conf*` | **100** | see below |
| lock held by another apt | **100** | `E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 191 (apt-get)` + `E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), is another process using it?` |
| `-o DPkg::Lock::Timeout=30` | waits (7 s in the test) then proceeds, exit 0 | |
| not root (real run) | **100** | `E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)` + `E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?` (`-s` works as non-root) |
| unknown package (`install`/`purge`) | **100** | `E: Unable to locate package no-such-pkg-xyz` |
| unsatisfiable (held dependency) | **100** | `E: Unable to correct problems, you have held broken packages.` |
**Conffile prompt** (fixture: `/etc/su-dummy/su-dummy-c.conf` edited locally, 2.0 ships a different one, stdin = /dev/null, `-y` only):
```
Configuration file '/etc/su-dummy/su-dummy-c.conf'
==> Modified (by you or by a script) since installation.
==> Package distributor has shipped an updated version.
What would you like to do about it ? Your options are: ...
*** su-dummy-c.conf (Y/I/N/O/D/Z) [default=N] ? dpkg: error processing package su-dummy-c (--configure):
end of file on stdin at conffile prompt [exit=100], state iU, su-dummy-c.conf.dpkg-new left behind
```
With a tty it would block waiting for input instead. Results of the fix options (same scenario, separate containers, all exit 0, state `ii`):
| `-o Dpkg::Options::=...` | conf after | leftover |
|---|---|---|
| `--force-confold` | `local-edit=1` (kept) | `su-dummy-c.conf.dpkg-dist` (new version) |
| `--force-confnew` | `version=2.0` (replaced) | `su-dummy-c.conf.dpkg-old` (your edit) |
| `--force-confdef --force-confold` | kept | `.dpkg-dist` |
Use `--force-confdef --force-confold` (never silently overwrite admin edits; unmodified conffiles are still updated: `Installing new version of config file ...`).
`upgrade` vs `dist-upgrade`: see the table in 3.4. `apt-get -y dist-upgrade` on the fixture removed `su-dummy-f` (`Removing su-dummy-f (1.0) ...` - note it stays `rc` in dpkg, `dpkg-query -W` still prints its version; always check `${db:Status-Abbrev}`) with no extra confirmation. Recommendation: `--with-new-pkgs upgrade`; use `dist-upgrade` only when explicitly asked, and then show the `Remv` lines first.
Output noise (non-tty): `(Reading database ... 5%(Reading database ...` progress is in stdout, `-qq` drops the apt summary but keeps dpkg's output; `-o Dpkg::Use-Pty=0` / `Dpkg::Progress-Fancy=0` change nothing in non-tty runs. stderr always has `debconf: delaying package configuration, since apt-utils is not installed` on the minimal images (harmless).
## 9. Item 8 - purge and autoremove
```
$ apt-get -s purge su-dummy-b (a depends on b) [exit=0]
The following packages will be REMOVED:
su-dummy-a* su-dummy-b* <- '*' = purge
0 upgraded, 0 newly installed, 2 to remove and 4 not upgraded.
Purg su-dummy-a [1.0]
Purg su-dummy-b [1.0]
$ apt-get -y purge su-dummy-b [exit=0]
Removing su-dummy-a (1.0) ... <- reverse dependencies are removed too, NO extra confirmation with -y
Removing su-dummy-b (1.0) ...
Purging configuration files for su-dummy-b (1.0) ...
Purging configuration files for su-dummy-a (1.0) ...
```
- `apt-get -y remove P` leaves conffiles and state `rc` (`rc su-dummy-c=1.0`, `/etc/su-dummy/su-dummy-c.conf` still there); a following `apt-get -y purge P` prints `Purging configuration files for P` and deletes them (state: no dpkg-query entry).
- Not installed / already purged / known-but-not-installed: `Package 'su-dummy-a' is not installed, so not removed` [exit=0]. Unknown name: `E: Unable to locate package no-such-pkg-xyz` [exit=100].
- Essential protection: `apt-get -y purge login` -> `WARNING: The following essential packages will be removed. This should NOT be done unless you know exactly what you are doing!` ... `E: Essential packages were removed and -y was used without --allow-remove-essential.` [exit=100], nothing is removed (debian:12/13, ubuntu:22.04/24.04; `coreutils` gives the same on debian:13, ubuntu:22.04 and ubuntu:latest). `login` is **not Essential on ubuntu 26.04** and was really purged there (exit 0) - use `coreutils` for a cross-image Essential test.
- Held package: `E: Held packages were changed and -y was used without --allow-change-held-packages.` [exit=100] (with the flag it works).
Autoremove (fixture: `su-dummy-d` 2.0 pulled `su-dummy-newdep` as automatically installed):
```
$ apt-get -y purge su-dummy-d [exit=0]
The following package was automatically installed and is no longer required:
su-dummy-newdep
Use 'apt autoremove' to remove it.
The following packages will be REMOVED:
su-dummy-d*
$ apt-get -s autoremove --purge -> Purg su-dummy-newdep [1.0]
$ apt-get -y autoremove --purge [exit=0]
Removing su-dummy-newdep (1.0) ...
Purging configuration files for su-dummy-newdep (1.0) ...
$ apt-get -y autoremove --purge (nothing left) ... 0 to remove ... [exit=0]
```
`apt-get autopurge` is an equivalent alias (verified: `Purg su-dummy-newdep [1.0]` / `Removing ...` / `Purging configuration files ...`, exit 0). Packages installed with `dpkg -i` are *manual*; `apt-mark auto P` / `apt-mark showauto` control the auto flag. The images set `APT::AutoRemove::SuggestsImportant "false"`.
Simulation lines to preview: `Purg name [ver]` for purge/autoremove --purge, `Remv name [ver]` for plain remove.
## 10. Item 10 - E2E fixture: `discovery/apt/fixture.sh`
Builds `/srv/su-repo` (flat repo: `*.deb`, `Packages`, `Release`), source line `deb [trusted=yes] file:/srv/su-repo ./` in `/etc/apt/sources.list.d/su-repo.list`, installs the 1.0 packages with `dpkg -i` (from `/srv/su-old`, deliberately not published), runs `apt-get update` and **asserts** that `su-dummy-a/b/c` are upgradable via both `apt list --upgradable` and `apt-get -s --with-new-pkgs upgrade` (exit 1 otherwise).
- `su-dummy-a` 1.0 `Depends: su-dummy-b`; 2.0 `Depends: su-dummy-b (>= 2.0)`; `su-dummy-b`; `su-dummy-c` standalone. Arch `all` (runs on any host arch). Each ships one data file and one conffile (`/etc/su-dummy/<name>.conf`, content `version=<ver>`), multi-line Description.
- `dpkg-deb --root-owner-group -Zgzip --build`. Index: default `SU_FIXTURE_INDEXER=shell` (dpkg-deb -f + md5sum/sha256sum + hand-written `Release` with Origin/Label `su-fixture`, Suite/Codename `su-repo` and a valid `Date`) => **no extra packages, no network needed**. `scanpackages` (installs `dpkg-dev`) and `ftparchive` (installs `apt-utils`) are also implemented and verified; both need the real mirrors, and installing the tool can upgrade packages as a side effect (on debian:12 installing dpkg-dev upgraded `perl-base`, i.e. the "real-world upgradable" package disappeared from the list) - prefer `shell`.
- Missing `Date:` in Release gives `W: Invalid 'Date' entry in Release file`; the generated Release has one. A Release without any file also works with `[trusted=yes]` but is noisy (`Method gave a blank filename` x6), hence the generated Release.
- Optional knobs: `SU_FIXTURE_OFFLINE=1` (moves the distro's real sources to `/srv/su-disabled-sources` => hermetic, 0.4 s, works with `--network none`), `SU_FIXTURE_EXTRA=1` (su-dummy-d/newdep/e/f/v/w/alt scenarios for kept-back, removal, virtual and alternatives), `SU_FIXTURE_BAD=1` (`su-dummy-bad` 2.0 with failing postinst). Wipe guard: repo/old dirs must match `/srv/su-*`, `/tmp/su-*`, `/var/tmp/su-*`. Re-running in a dirty container works (uses `--force-confnew`, `</dev/null`; a downgrade is reported on stderr).
- Real-world upgradable packages of the base image are **kept** (not touched) and printed as a `NOTE:` line, e.g. debian:12 `perl-base`, debian:13 `liblzma5`, ubuntu:22.04 `libssl3`, ubuntu:24.04 `libaudit-common libaudit1 libssl3t64`, ubuntu:latest `libssl3t64 openssl-provider-legacy`. They are time dependent: tests must not hard-code them; assert only on `su-dummy-*`.
Proof, exact required invocation (`docker run --rm -i -v <project>/discovery/apt:/d:ro IMAGE bash /d/fixture.sh`), all [exit=0], no stderr output:
```
== debian:12 [fixture] repo /srv/su-repo indexed with 'shell': 3 packages
[fixture] installed (old): su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=1.0
[fixture] upgradable dummy packages: su-dummy-a su-dummy-b su-dummy-c
[fixture] NOTE: real-world upgradable packages present in this base image: perl-base
[fixture] OK in 3s (image: Debian GNU/Linux 12 (bookworm))
== ubuntu:24.04 ... upgradable dummy packages: su-dummy-a su-dummy-b su-dummy-c
NOTE: ... libaudit-common libaudit1 libssl3t64 ; OK in 5s (image: Ubuntu 24.04.5 LTS)
== debian:13, ubuntu:22.04, ubuntu:latest(26.04.1): same, OK in 3s / 7s / 6s
```
Also verified: `SU_FIXTURE_INDEXER=scanpackages|ftparchive` on debian:12, debian:13, ubuntu:24.04, ubuntu:latest; `SU_FIXTURE_OFFLINE=1 SU_FIXTURE_EXTRA=1 SU_FIXTURE_BAD=1 --network none` on all five; `smoke.sh` (32 checks: Inst parse, kept-back parse, description awk, rdepends, three closure methods, hold/restore, upgrade, purge, autoremove) `ALL PASSED` on all five images.
Fixture-state facts useful for tests: `apt list --upgradable` => `su-dummy-a/su-repo 2.0 all [upgradable from: 1.0]`; `Inst su-dummy-a [1.0] (2.0 su-fixture:su-repo [all])`; `apt-cache policy su-dummy-a` => Installed 1.0 / Candidate 2.0, table `2.0 500 / 500 file:/srv/su-repo ./ Packages` and ` *** 1.0 100 / 100 /var/lib/dpkg/status`.
## 11. Item 11 - timing (rough, this host, 3 runs each, network OK)
| image | `apt-get update` cold (lists empty) | lists size | `fixture.sh` default (incl. real update + 3 apt calls) | `fixture.sh` OFFLINE (`--network none`) | one `apt-get -s upgrade` / `apt list` / `apt-cache policy` |
|---|---|---|---|---|---|
| debian:12 | 1.1 s | 19 MB | 2.8-3.1 s (docker wall 3.2-3.4) | 0.4 s | 0.72 / 0.35 / 0.31 s |
| debian:13 | 1.0-1.1 s | 21 MB | 3.0-3.3 s | 0.4 s | 0.82 / 0.44 / 0.31 s |
| ubuntu:22.04 | 2.5-3.4 s | 78 MB | 5.7-5.8 s | 0.4 s | 1.62 / 0.92 / 0.71 s |
| ubuntu:24.04 | 2.2-3.3 s | 52 MB | 4.9 s | 0.3-0.4 s | 1.11 / 0.63 / 0.47 s |
| ubuntu:latest | 1.7-3.1 s | 42 MB | 4.1-4.4 s | 0.5 s | 1.00 / 0.56 / 0.40 s |
Container start overhead is ~0.2-0.3 s; `docker pull` of the five images took 3-8 s each. Network variance dominates `update`. `smoke.sh` (hermetic) takes a few seconds per image. Because the images have no apt binary cache, budget ~0.3-1.6 s **per apt invocation** in tests, and prefer one batched call over per-package loops. `apt-get install bats` (no recommends) +0.7-1.7 s; with recommends 5.7 s (debian:12) / 13 s (ubuntu:24.04).
## 12. Differences between the five images (summary)
| aspect | debian:12 | debian:13 | ubuntu:22.04 | ubuntu:24.04 | ubuntu:latest (26.04) |
|---|---|---|---|---|---|
| apt / solver | 2.6.1 classic | 3.0.3 classic (behaves like 12) | 2.4.14 classic | 2.8.3 classic | **3.2.0, solver3 default** |
| sources format | `debian.sources` (deb822) | deb822 | `sources.list` (one-line) | `ubuntu.sources` (deb822) | `ubuntu.sources` |
| repo string in `Inst` (real) | `Debian:12.15/oldstable`, `Debian-Security:12/oldstable-security` | `Debian:13.7/stable`, `Debian-Security:13/stable-security` | `Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security` | `Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security` | `Ubuntu:26.04/resolute-updates, Ubuntu:26.04/resolute-security` |
| `Solving dependencies...` line | no | no | no | no | **yes** (in simulations/removals, stdout) |
| `dist-upgrade` removes a manually-installed conflicting pkg | yes | yes | yes | yes | **no** (kept back) |
| `Description-md5:` in `apt-cache show` of installed-only | value | empty | value | empty | empty |
| Essential `login` (`apt-get -y purge login`) | protected | protected | protected | protected | **not Essential: purged without error** |
| bats | 1.8.2 | 1.11.1 | **1.2.1**, no helper libs | 1.10.0 | 1.13.0 |
Everything else compared with `diff` over a 385-line transcript of ~60 commands (update, list, simulate, hold, show/policy/dpkg -s, depends/rdepends, real upgrade, dist-upgrade, purge, autoremove, error paths): identical wording and exit codes across the five images apart from the rows above and checksums/sizes. Specifically identical: `apt list` stderr warning, `Inst`/`Conf`/`Remv`/`Purg` formats, `kept back` block, `apt-mark` messages, `apt-cache depends/rdepends` formats, `E:`/`W:` texts and exit codes (0 / 100), lock message, conffile failure, Essential protection message.
Pre-existing docker images were not touched (`images-before.tsv` compared: nothing missing or changed); only `debian:12`, `debian:13`, `ubuntu:22.04`, `ubuntu:24.04`, `ubuntu:latest` were pulled and only `docker run --rm` containers were used (one hung throwaway container of mine, caused by a test that re-ran the fixture over a locally modified conffile, was `docker kill`ed).
+41
View File
@@ -0,0 +1,41 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# closure-dpkg.awk - dependency closure (Pre-Depends + Depends) of installed packages, computed
# from the dpkg database only (exactly the dependencies of the INSTALLED versions; virtual
# packages are resolved through the Provides of installed packages; every installed alternative
# of an "a | b" group is followed).
# usage: dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' \
# | awk -F'\t' -v roots="pkg1 pkg2" -f closure-dpkg.awk
# output: plain package names, one per line, roots included (only if installed), BFS order
function trim(s) { gsub(/^[ ]+|[ ]+$/, "", s); return s }
substr($1, 2, 1) ~ /[nc]/ { next } # not-installed / config-files-only
{
inst[$2] = 1
deps[$2] = $3 "," $4
n = split($5, pv, ",")
for (i = 1; i <= n; i++) {
p = trim(pv[i]); sub(/ .*/, "", p); sub(/:.*/, "", p)
if (p != "") prov[p] = prov[p] " " $2
}
}
END {
nr = split(roots, r, " ")
for (i = 1; i <= nr; i++) if (inst[r[i]] && !seen[r[i]]) { seen[r[i]] = 1; q[++t] = r[i] }
for (h = 1; h <= t; h++) {
p = q[h]; print p
m = split(deps[p], d, ",")
for (j = 1; j <= m; j++) {
na = split(d[j], alt, "|")
for (k = 1; k <= na; k++) {
a = alt[k]
gsub(/\([^)]*\)/, "", a); gsub(/\[[^]]*\]/, "", a); gsub(/<[^>]*>/, "", a)
a = trim(a); sub(/:(any|native)$/, "", a)
if (a == "") continue
if (inst[a] && !seen[a]) { seen[a] = 1; q[++t] = a }
np = split(prov[a], pp, " ")
for (x = 1; x <= np; x++) if (!seen[pp[x]]) { seen[pp[x]] = 1; q[++t] = pp[x] }
}
}
}
}
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fixture.sh - APT e2e fixture for skeptical-updater (Debian/Ubuntu containers).
#
# Builds a local *flat* APT repository at /srv/su-repo containing dummy packages
# and installs the old (1.0) versions, so that after `apt-get update` the package
# manager reports deterministic upgrades:
#
# su-dummy-a 1.0 -> 2.0 (Depends: su-dummy-b, ships a conffile)
# su-dummy-b 1.0 -> 2.0 (dependency of su-dummy-a)
# su-dummy-c 1.0 -> 2.0 (standalone)
#
# Optional extras (SU_FIXTURE_EXTRA=1) for "kept back" / removal / virtual-package
# scenarios:
# su-dummy-d 1.0 -> 2.0 2.0 adds a NEW dependency (su-dummy-newdep, not installed)
# => plain `upgrade` keeps it back, `--with-new-pkgs upgrade`
# and `dist-upgrade` install it
# su-dummy-e 1.0 -> 2.0 2.0 Conflicts: su-dummy-f (installed, no 2.0 exists)
# => only `dist-upgrade` can do it (removes su-dummy-f) - except with apt 3.x
# solver3 (ubuntu:latest = 26.04) which refuses to remove the manually
# installed su-dummy-f and keeps su-dummy-e back even for dist-upgrade
# su-dummy-f 1.0 installed, never upgradable
# su-dummy-w 1.0 -> 2.0 Depends: su-virtual | su-dummy-alt (virtual + alternative)
# su-dummy-v 1.0 Provides: su-virtual
# (+ su-dummy-newdep 1.0 and su-dummy-alt 1.0: only in the repo, never pre-installed)
#
# Environment knobs (all optional):
# SU_REPO_DIR=/srv/su-repo where the repo is created (wiped first; must match /srv/su-*, /tmp/su-*, /var/tmp/su-*)
# SU_OLD_DIR=/srv/su-old where the 1.0 .debs are kept (NOT in the repo)
# SU_FIXTURE_INDEXER=shell shell | scanpackages | ftparchive
# shell = no extra tools needed (dpkg-deb -f + sha256sum)
# scanpackages = dpkg-scanpackages (installs dpkg-dev if missing)
# ftparchive = apt-ftparchive (installs apt-utils if missing)
# SU_FIXTURE_OFFLINE=1 disable the distro's real sources (hermetic: no network at all)
# SU_FIXTURE_EXTRA=1 add the extra scenario packages listed above
# SU_FIXTURE_BAD=1 add su-dummy-bad: 1.0 installed, 2.0 has a postinst that fails
# (apt-get upgrade then exits 100) - for error-path tests
#
# Must run as root inside a throwaway container, e.g.:
# docker run --rm -i -v "$PWD/discovery/apt:/d:ro" debian:12 bash /d/fixture.sh
set -euo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
REPO_DIR=${SU_REPO_DIR:-/srv/su-repo}
OLD_DIR=${SU_OLD_DIR:-/srv/su-old}
INDEXER=${SU_FIXTURE_INDEXER:-shell}
OFFLINE=${SU_FIXTURE_OFFLINE:-0}
EXTRA=${SU_FIXTURE_EXTRA:-0}
BAD=${SU_FIXTURE_BAD:-0}
SOURCES_FILE=/etc/apt/sources.list.d/su-repo.list
T0=$(date +%s)
log() { printf '[fixture] %s\n' "$*"; }
die() { printf '[fixture] ERROR: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "must run as root"
command -v apt-get >/dev/null || die "apt-get not found (not a Debian/Ubuntu image?)"
command -v dpkg-deb >/dev/null || die "dpkg-deb not found"
case $INDEXER in shell|scanpackages|ftparchive) ;; *) die "bad SU_FIXTURE_INDEXER=$INDEXER" ;; esac
# the directories are wiped with rm -rf: only accept obviously fixture-owned locations
for d in "$REPO_DIR" "$OLD_DIR"; do
case $d in /srv/su-*|/tmp/su-*|/var/tmp/su-*) ;; *) die "refusing to wipe '$d' (must match /srv/su-*, /tmp/su-* or /var/tmp/su-*)" ;; esac
done
# ---------------------------------------------------------------- build debs
# mkdeb NAME VERSION OUTDIR [extra control line]...
# If $MKDEB_POSTINST is set it becomes the body of the package's postinst script.
mkdeb() {
local name=$1 ver=$2 out=$3 root line
shift 3
root=$(mktemp -d)
mkdir -p "$root/DEBIAN" "$root/usr/share/su-dummy" "$root/etc/su-dummy"
{
printf 'Package: %s\nVersion: %s\nSection: misc\nPriority: optional\n' "$name" "$ver"
printf 'Architecture: all\nMaintainer: Skeptical Updater Fixture <su-fixture@example.invalid>\n'
for line in "$@"; do printf '%s\n' "$line"; done
printf 'Description: dummy package %s for skeptical-updater tests\n' "$name"
printf ' This package only exists to exercise the package manager.\n'
printf ' .\n'
printf ' It ships one data file and one conffile (version %s).\n' "$ver"
} > "$root/DEBIAN/control"
echo "$ver" > "$root/usr/share/su-dummy/$name"
echo "version=$ver" > "$root/etc/su-dummy/$name.conf"
echo "/etc/su-dummy/$name.conf" > "$root/DEBIAN/conffiles"
if [ -n "${MKDEB_POSTINST:-}" ]; then
printf '#!/bin/sh\n%s\n' "$MKDEB_POSTINST" > "$root/DEBIAN/postinst"
chmod 755 "$root/DEBIAN/postinst"
fi
mkdir -p "$out"
# -Zgzip: readable by every dpkg in the supported images; --root-owner-group: files owned by root
dpkg-deb --root-owner-group -Zgzip --build "$root" "$out/${name}_${ver}_all.deb" >/dev/null
rm -rf "$root"
}
rm -rf "$REPO_DIR" "$OLD_DIR"
mkdir -p "$REPO_DIR" "$OLD_DIR"
# old versions (installed straight away with dpkg, NOT published in the repo)
mkdeb su-dummy-a 1.0 "$OLD_DIR" 'Depends: su-dummy-b'
mkdeb su-dummy-b 1.0 "$OLD_DIR"
mkdeb su-dummy-c 1.0 "$OLD_DIR"
# new versions (published in the repo)
mkdeb su-dummy-a 2.0 "$REPO_DIR" 'Depends: su-dummy-b (>= 2.0)'
mkdeb su-dummy-b 2.0 "$REPO_DIR"
mkdeb su-dummy-c 2.0 "$REPO_DIR"
if [ "$EXTRA" = 1 ]; then
mkdeb su-dummy-d 1.0 "$OLD_DIR"
mkdeb su-dummy-d 2.0 "$REPO_DIR" 'Depends: su-dummy-newdep'
mkdeb su-dummy-newdep 1.0 "$REPO_DIR" # only in the repo, never pre-installed
mkdeb su-dummy-e 1.0 "$OLD_DIR"
mkdeb su-dummy-e 2.0 "$REPO_DIR" 'Conflicts: su-dummy-f'
mkdeb su-dummy-f 1.0 "$OLD_DIR"
mkdeb su-dummy-v 1.0 "$OLD_DIR" 'Provides: su-virtual'
mkdeb su-dummy-alt 1.0 "$REPO_DIR" # alternative target, not installed
mkdeb su-dummy-w 1.0 "$OLD_DIR" 'Depends: su-virtual | su-dummy-alt'
mkdeb su-dummy-w 2.0 "$REPO_DIR" 'Depends: su-virtual | su-dummy-alt'
fi
if [ "$BAD" = 1 ]; then
mkdeb su-dummy-bad 1.0 "$OLD_DIR"
MKDEB_POSTINST='[ "$1" = configure ] && { echo "su-dummy-bad: postinst failing on purpose" >&2; exit 1; }; exit 0' \
mkdeb su-dummy-bad 2.0 "$REPO_DIR"
fi
# ---------------------------------------------------------------- real sources
if [ "$OFFLINE" = 1 ]; then
mkdir -p /srv/su-disabled-sources
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do
[ -e "$f" ] || continue
case $f in "$SOURCES_FILE") continue ;; esac
mv "$f" "/srv/su-disabled-sources/$(basename "$f")"
done
log "OFFLINE: real sources moved to /srv/su-disabled-sources"
fi
# ---------------------------------------------------------------- indexing
install_tool() { # install_tool PACKAGE : needs the real mirrors
[ "$OFFLINE" = 1 ] && die "SU_FIXTURE_INDEXER=$INDEXER needs network to install $1; use shell with OFFLINE"
log "installing $1 (needs network)"
apt-get update -qq >/dev/null
apt-get install -y -qq --no-install-recommends "$1" >/dev/null
}
index_shell() {
local f
: > Packages
for f in ./*.deb; do
f=${f#./}
dpkg-deb -f "$f" >> Packages
printf 'Filename: ./%s\nSize: %s\nMD5sum: %s\nSHA256: %s\n\n' \
"$f" "$(stat -c %s "$f")" "$(md5sum < "$f" | cut -d' ' -f1)" "$(sha256sum < "$f" | cut -d' ' -f1)" >> Packages
done
{
printf 'Origin: su-fixture\nLabel: su-fixture\nSuite: su-repo\nCodename: su-repo\n'
printf 'Date: %s\n' "$(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
printf 'SHA256:\n %s %s Packages\n' "$(sha256sum < Packages | cut -d' ' -f1)" "$(stat -c %s Packages)"
} > Release
}
index_scanpackages() {
command -v dpkg-scanpackages >/dev/null || install_tool dpkg-dev
dpkg-scanpackages --multiversion . /dev/null > Packages 2>/dev/null
# dpkg-scanpackages does not write a Release file; make a minimal one
{
printf 'Origin: su-fixture\nLabel: su-fixture\nSuite: su-repo\nCodename: su-repo\n'
printf 'Date: %s\n' "$(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
printf 'SHA256:\n %s %s Packages\n' "$(sha256sum < Packages | cut -d' ' -f1)" "$(stat -c %s Packages)"
} > Release
}
index_ftparchive() {
command -v apt-ftparchive >/dev/null || install_tool apt-utils
apt-ftparchive packages . > Packages
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=su-fixture \
-o APT::FTPArchive::Release::Label=su-fixture \
-o APT::FTPArchive::Release::Suite=su-repo \
-o APT::FTPArchive::Release::Codename=su-repo \
release . > Release
}
( cd "$REPO_DIR" && "index_$INDEXER" )
log "repo $REPO_DIR indexed with '$INDEXER': $(grep -c '^Package:' "$REPO_DIR/Packages") packages"
echo "deb [trusted=yes] file:$REPO_DIR ./" > "$SOURCES_FILE"
# ---------------------------------------------------------------- install old versions
# --force-confnew + </dev/null: never block on a conffile prompt if the fixture is re-run in a dirty container
dpkg --force-confnew -i "$OLD_DIR"/*.deb >/dev/null </dev/null
log "installed (old): $(dpkg-query -W -f '${db:Status-Abbrev} ${Package}=${Version}\n' 'su-dummy-*' 2>/dev/null | awk '$1 == "ii" {printf "%s ", $2}')"
# ---------------------------------------------------------------- refresh + verify
# drop stale lists of our own repo first: apt compares mtimes of file: indexes with 1s resolution,
# so a repo regenerated within the same second would otherwise be seen as "unchanged"
rm -f /var/lib/apt/lists/"$(printf '%s' "$REPO_DIR" | tr / _)"_*
apt-get update -qq >/dev/null
fail=0
upg=$(apt list --upgradable 2>/dev/null | awk -F/ '/^su-dummy-/{print $1}' | sort | tr '\n' ' ')
for p in su-dummy-a su-dummy-b su-dummy-c; do
case " $upg" in *" $p "*) ;; *) log "MISSING from upgradable: $p"; fail=1 ;; esac
done
sim=$(apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk '/^Inst su-dummy-(a|b|c) /{n++} END{print n+0}')
[ "$sim" -ge 3 ] || { log "simulated upgrade only covers $sim of 3 core packages"; fail=1; }
[ "$fail" -eq 0 ] || die "fixture verification failed"
log "upgradable dummy packages: $upg"
real=$(apt list --upgradable 2>/dev/null | awk -F/ 'NR>1 && $1 !~ /^su-dummy-/{print $1}' | tr '\n' ' ')
if [ -n "$real" ]; then
log "NOTE: real-world upgradable packages present in this base image: $real"
else
log "NOTE: no real-world upgradable packages in this base image right now"
fi
log "OK in $(( $(date +%s) - T0 ))s (image: $(. /etc/os-release; echo "$PRETTY_NAME"))"
+29
View File
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# parse-inst.awk - parse the "Inst" lines of `LC_ALL=C apt-get -s <verb>` into TSV.
# usage: LC_ALL=C apt-get -s --with-new-pkgs upgrade | awk -f parse-inst.awk
# output: name<TAB>oldversion<TAB>newversion<TAB>repo
# oldversion is "-" for packages that are newly installed (no previous version);
# repo is the raw origin list, e.g. "Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security"
# (may be empty for packages coming from a local .deb). POSIX awk, tested with mawk 1.3.4.
/^Inst / {
name = $2
rest = $0
sub(/^Inst [^ ]+ /, "", rest)
old = "-"
if (substr(rest, 1, 1) == "[") { # "[old] (new origins [arch])"
old = rest
sub(/^\[/, "", old)
sub(/\].*$/, "", old)
sub(/^\[[^]]*\] /, "", rest)
}
sub(/^\(/, "", rest) # "new origins [arch]) ..."
new = rest
sub(/ .*$/, "", new)
sub(/^[^ ]+ ?/, "", rest) # "origins [arch]) ..."
i = index(rest, "])")
repo = substr(rest, 1, i - 1) # "origins [arch"
sub(/ ?\[[^[]*$/, "", repo) # drop " [arch"
printf "%s\t%s\t%s\t%s\n", name, old, new, repo
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# smoke.sh - proves that the command recipes documented in discovery/apt.md work, on top of fixture.sh.
# docker run --rm -i --network none -v "$PWD/discovery/apt:/d:ro" debian:12 bash /d/smoke.sh
# Runs fixture.sh in hermetic mode (SU_FIXTURE_OFFLINE=1, SU_FIXTURE_EXTRA=1) and then asserts.
set -uo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
fails=0
ok() { printf 'PASS %s\n' "$1"; }
bad() { printf 'FAIL %s\n got: %s\n expected: %s\n' "$1" "$2" "$3"; fails=$((fails + 1)); }
check() { # check LABEL GOT EXPECTED
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi
}
ver() { dpkg-query -W -f '${Version}' "$1" 2>/dev/null; }
SU_FIXTURE_OFFLINE=1 SU_FIXTURE_EXTRA=1 bash "$HERE/fixture.sh" >/dev/null || { echo "fixture failed"; exit 2; }
echo "image: $(. /etc/os-release; echo "$PRETTY_NAME") apt: $(apt-get --version | head -1)"
# ---- 1. refresh: exit code is 0 and the unreachable-source case is made fatal by Error-Mode=any
apt-get update -qq -o APT::Update::Error-Mode=any >/dev/null 2>&1; check "update exit code" "$?" "0"
# ---- 2. upgradable list (Inst parse) + kept-back parse + apt list cross-check
list=$(apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk -f "$HERE/parse-inst.awk" | sort)
check "Inst parse su-dummy-a" "$(printf '%s\n' "$list" | grep '^su-dummy-a')" "$(printf 'su-dummy-a\t1.0\t2.0\tsu-fixture:su-repo')"
check "Inst parse new package (old=-)" "$(printf '%s\n' "$list" | grep '^su-dummy-newdep')" "$(printf 'su-dummy-newdep\t-\t1.0\tsu-fixture:su-repo')"
kept=$(apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk '/^The following packages have been kept back:/{k=1;next} k&&/^ /{for(i=1;i<=NF;i++)print $i;next} {k=0}' | tr '\n' ' ')
check "kept-back parse" "$kept" "su-dummy-e "
alist=$(apt list --upgradable 2>/dev/null | sed -nE 's#^([^/]+)/([^ ]+) ([^ ]+) ([^ ]+) \[upgradable from: ([^]]+)\]$#\1\t\5\t\3\t\2#p' | grep '^su-dummy-a')
check "apt list parse" "$alist" "$(printf 'su-dummy-a\t1.0\t2.0\tsu-repo')"
# ---- 3. description
desc=$(apt-cache show --no-all-versions su-dummy-a | awk '
/^Description(-[a-z][a-z](_[A-Z][A-Z])?)?:/ { p=1; sub(/^[^:]*: ?/,""); print; next }
p && /^ / { l=substr($0,2); print (l=="." ? "" : l); next }
p { exit }')
check "description first line" "$(printf '%s\n' "$desc" | head -1)" "dummy package su-dummy-a for skeptical-updater tests"
check "description line count" "$(printf '%s\n' "$desc" | wc -l | tr -d ' ')" "4"
# ---- 4. rdepends / closure
RD="--installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances"
rd=$(apt-cache rdepends $RD su-dummy-b | awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ |]+/,""); print}' | sort -u | tr '\n' ' ')
check "rdepends su-dummy-b" "$rd" "su-dummy-a "
NI="--no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances"
installed_names() { dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\n' | awk -F'\t' 'substr($1,2,1) !~ /[nc]/ {print $2}' | sort -u; }
clos=$(comm -12 <(apt-cache depends --recurse $NI su-dummy-w | awk '/^[^ <]/' | sort -u) <(installed_names) | tr '\n' ' ')
check "closure su-dummy-w via virtual (no --installed)" "$clos" "su-dummy-v su-dummy-w "
clos_flag=$(apt-cache depends --recurse --installed $NI su-dummy-w | awk '/^[^ <]/' | sort -u | tr '\n' ' ')
check "closure su-dummy-w WITH --installed (documented defect: provider lost)" "$clos_flag" "su-dummy-w "
clos_dpkg=$(dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' | awk -F'\t' -v roots="su-dummy-w" -f "$HERE/closure-dpkg.awk" | sort -u | tr '\n' ' ')
check "closure su-dummy-w via dpkg awk" "$clos_dpkg" "su-dummy-v su-dummy-w "
# real package: base-files Pre-Depends on the virtual 'awk' (provided by the installed mawk)
real_dpkg=$(dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' | awk -F'\t' -v roots="base-files" -f "$HERE/closure-dpkg.awk" | sort -u | tr '\n' ' ')
case " $real_dpkg" in *" mawk "*) ok "closure-dpkg base-files reaches mawk through virtual awk" ;; *) bad "closure-dpkg base-files reaches mawk" "$real_dpkg" "... mawk ..." ;; esac
real_flag=$(apt-cache depends --recurse --installed $NI base-files | awk '/^[^ <]/' | sort -u | tr '\n' ' ')
case " $real_flag" in *" mawk "*) bad "apt-cache --recurse --installed base-files lacks mawk (documented defect)" "$real_flag" "no mawk" ;; *) ok "apt-cache --recurse --installed base-files lacks mawk (documented defect)" ;; esac
# ---- 5. hold / exclusion recipe + upgrade + restore
SU_STATE_DIR=$(mktemp -d); SU_HOLDS_ADDED=$SU_STATE_DIR/holds.added
su_holds_begin() {
mkdir -p "$SU_STATE_DIR" || return 1
local held p; held=$(apt-mark showhold) || return 1
: > "$SU_HOLDS_ADDED"
for p in "$@"; do
grep -qxF -- "$p" <<<"$held" && continue
echo "$p" >> "$SU_HOLDS_ADDED"
apt-mark hold "$p" >/dev/null || return 1
done
}
su_holds_restore() {
[ -f "$SU_HOLDS_ADDED" ] || return 0
apt-mark showhold | sort -u | comm -12 - <(sort -u "$SU_HOLDS_ADDED") | xargs -r apt-mark unhold >/dev/null
rm -f "$SU_HOLDS_ADDED"
}
apt-mark hold su-dummy-alt >/dev/null # pre-existing admin hold: must survive untouched
before=$(apt-mark showhold | sort | tr '\n' ' ')
su_holds_begin su-dummy-c su-dummy-alt
check "hold set during run" "$(apt-mark showhold | sort | tr '\n' ' ')" "su-dummy-alt su-dummy-c "
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 \
--with-new-pkgs upgrade >/dev/null 2>&1; check "upgrade exit code" "$?" "0"
check "held pkg skipped (c)" "$(ver su-dummy-c)" "1.0"
check "a upgraded" "$(ver su-dummy-a)" "2.0"; check "b upgraded" "$(ver su-dummy-b)" "2.0"
check "new dep installed via --with-new-pkgs" "$(ver su-dummy-newdep)" "1.0"
check "kept back e untouched" "$(ver su-dummy-e)" "1.0"
su_holds_restore
check "hold set restored exactly" "$(apt-mark showhold | sort | tr '\n' ' ')" "$before"
# crash recovery: journal survives, admin edits in between are respected
su_holds_begin su-dummy-b su-dummy-e; apt-mark hold su-dummy-w >/dev/null; apt-mark unhold su-dummy-alt >/dev/null # (no restore = simulated kill -9)
su_holds_restore
check "crash recovery releases only our holds, keeps admin edits" "$(apt-mark showhold | sort | tr '\n' ' ')" "su-dummy-w "
apt-mark unhold su-dummy-w >/dev/null
apt-get -y --with-new-pkgs upgrade >/dev/null 2>&1; check "second upgrade exit code" "$?" "0"
check "c upgraded after restore" "$(ver su-dummy-c)" "2.0"
noop=$(apt-get -y --with-new-pkgs upgrade 2>&1); rc=$?
check "nothing-to-do exit code" "$rc" "0"
check "nothing-to-do summary" "$(printf '%s\n' "$noop" | grep -E '^[0-9]+ upgraded')" "0 upgraded, 0 newly installed, 0 to remove and 1 not upgraded."
# ---- 6. purge + autoremove
apt-mark auto su-dummy-b >/dev/null
apt-get -y purge su-dummy-a >/dev/null 2>&1; check "purge exit code" "$?" "0"
check "a purged (no dpkg status)" "$(dpkg-query -W -f '${db:Status-Abbrev}' su-dummy-a 2>/dev/null)" ""
check "autoremove --purge candidate" "$(apt-get -s autoremove --purge | awk '/^Purg /{print $2}' | tr '\n' ' ')" "su-dummy-b "
apt-get -y autoremove --purge >/dev/null 2>&1; check "autoremove exit code" "$?" "0"
check "b gone" "$(dpkg-query -W -f '${db:Status-Abbrev}' su-dummy-b 2>/dev/null)" ""
check "conffile of b gone" "$(ls /etc/su-dummy/su-dummy-b.conf 2>&1 | grep -c 'No such file')" "1"
rm -rf "$SU_STATE_DIR"
[ "$fails" -eq 0 ] && echo "ALL PASSED" || { echo "$fails FAILED"; exit 1; }
+570
View File
@@ -0,0 +1,570 @@
# 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).
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Executable proof for discovery/pacman.md. Run inside archlinux:latest:
# docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/checks.sh
# Needs network (real repos + pacman-contrib for the pactree comparison).
export LC_ALL=C
fails=0
ok() { printf 'PASS %s\n' "$*"; }
bad() { printf 'FAIL %s\n' "$*"; fails=$((fails+1)); }
eq() { # eq <desc> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1"; printf ' expected: %q\n actual: %q\n' "$2" "$3"; fi
}
d=$(dirname "$(readlink -f "$0")")
bash "$d/fixture.sh" 2>&1 | grep -v 'database file for'
. "$d/lib.sh"
echo "### 1/2 refresh + upgradable"
pm_refresh >/dev/null 2>&1; eq "pm_refresh exit" 0 $?
pm_upgradable | grep '^su-dummy' > /tmp/up.tsv
eq "upgradable su-dummy rows" $'su-dummy-a\t1.0-1\t2.0-1\tsu-dummy\t0\nsu-dummy-b\t1.0-1\t2.0-1\tsu-dummy\t0\nsu-dummy-c\t1.0-1\t2.0-1\tsu-dummy\t0' "$(cat /tmp/up.tsv)"
pm_upgradable | head -3 | sed 's/^/ /'
echo 'IgnorePkg = su-dummy-c' > /etc/pacman.d/ignorepkg
eq "ignored flag" $'su-dummy-c\t1.0-1\t2.0-1\tsu-dummy\t1' "$(pm_upgradable | grep '^su-dummy-c')"
eq "pacman-conf IgnorePkg" su-dummy-c "$(pm_ignored_patterns)"
: > /etc/pacman.d/ignorepkg
echo "### 3 descriptions"
eq "local desc" $'bash\tThe GNU Bourne Again shell' "$(pm_desc_local bash)"
eq "cand desc" $'su-dummy-a\tSkeptical-updater dummy package A' "$(pm_desc_cand su-dummy/su-dummy-a)"
eq "desc wrapped input is unwrapped" $'bash\tThe GNU Bourne Again shell' "$(COLUMNS=20 pm_desc_local bash)"
eq "desc via raw COLUMNS=20 wraps" yes "$( [ "$(COLUMNS=20 pacman -Qi bash | grep -c '^ \{10,\}')" -gt 0 ] && echo yes || echo no )"
echo "### 4 reverse deps"
eq "required-by su-dummy-b" su-dummy-a "$(pm_required_by su-dummy-b)"
eq "required-by su-dummy-a (None => empty)" "" "$(pm_required_by su-dummy-a)"
eq "required-by readline (soname dep)" "bash" "$(pm_required_by readline | grep -x bash)"
eq "optional-for bash" "ncurses pcre2 tzdata" "$(pm_optional_for bash | tr '\n' ' ' | sed 's/ $//')"
echo "### 5 closure vs pactree"
pacman -S --noconfirm --needed pacman-contrib fakeroot >/dev/null 2>&1 # fakeroot only for checkupdates
for p in bash pacman su-dummy-a; do
mine=$(pm_closure "$p" | tr '\n' ' ')
ref=$(pactree -l -u "$p" | xargs -r pacman -Qq -- 2>/dev/null | sort -u | grep -vx "$p" | tr '\n' ' ')
eq "closure($p) == pactree|resolve" "$ref" "$mine"
printf ' %s -> %s packages\n' "$p" "$(echo $mine | wc -w)"
done
eq "closure(sh) resolves provider" "$(pm_closure bash)" "$(pm_closure sh)"
eq "reverse closure(su-dummy-b)" su-dummy-a "$(pm_closure -r su-dummy-b)"
for p in glibc bash; do
mine=$(pm_closure -r "$p" | tr '\n' ' ')
ref=$(pactree -r -l -u "$p" | xargs -r pacman -Qq -- 2>/dev/null | sort -u | grep -vx "$p" | tr '\n' ' ')
eq "reverse closure($p) == pactree -r" "$ref" "$mine"
done
echo "### 6 plan / ignore / apply"
eq "plan all" $'su-dummy/su-dummy-b 2.0-1\nsu-dummy/su-dummy-a 2.0-1\nsu-dummy/su-dummy-c 2.0-1' "$(pm_plan | grep su-dummy)"
eq "plan skip a,c" $'su-dummy/su-dummy-b 2.0-1' "$(pm_plan su-dummy-a su-dummy-c | grep su-dummy)"
pm_apply su-dummy-a,su-dummy-c >/dev/null 2>&1; eq "apply (comma list passed as one arg) exit" 0 $?
eq "after partial apply" $'su-dummy-a 1.0-1\nsu-dummy-b 2.0-1\nsu-dummy-c 1.0-1' "$(pacman -Q su-dummy-a su-dummy-b su-dummy-c)"
pm_apply su-dummy-a >/dev/null 2>&1; pm_apply >/dev/null 2>&1
eq "after full apply" $'su-dummy-a 2.0-1\nsu-dummy-b 2.0-1\nsu-dummy-c 2.0-1' "$(pacman -Q su-dummy-a su-dummy-b su-dummy-c)"
echo "### 7 nothing to do / exit codes"
pm_apply >/dev/null 2>&1 # real repos: upgrade everything so nothing is left
pacman -Qu >/dev/null; eq "pacman -Qu exit when fully upgraded" 1 $?
out=$(pacman -Su --noconfirm 2>&1); rc=$?
eq "pacman -Su nothing to do: exit" 0 "$rc"
eq "pacman -Su nothing to do: message" $':: Starting full system upgrade...\n there is nothing to do' "$out"
checkupdates >/dev/null 2>&1; eq "checkupdates exit when nothing to do" 2 $?
echo "### 8 purge"
pm_purge su-dummy-b >/dev/null 2>&1; eq "purge b (needed by a) exit" 1 $?
pm_purge su-dummy-a >/dev/null 2>&1; eq "purge a exit" 0 $?
pacman -Q su-dummy-b >/dev/null 2>&1; eq "b removed with a (was --asdeps)" 1 $?
echo
[ "$fails" -eq 0 ] && echo "ALL CHECKS PASSED" || echo "$fails CHECK(S) FAILED"
exit "$fails"
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Deterministic pacman fixture for skeptical-updater E2E tests.
#
# Creates three dummy packages installed at 1.0 and a local pacman repo
# ([su-dummy], file:///srv/su-repo) that offers them at 2.0:
#
# su-dummy-a depends=(su-dummy-b) installed explicitly
# su-dummy-b installed as a dependency (--asdeps)
# su-dummy-c installed explicitly
#
# It also pre-wires /etc/pacman.d/ignorepkg (empty) and the matching
# "Include" line inside the [options] section of /etc/pacman.conf, i.e. the
# same layout that pman expects (IgnorePkg = <pkg> lines).
#
# Run as root inside an Arch container:
# docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
#
# Environment (all optional):
# FIXTURE_BUILDER tar (default) | makepkg
# tar : hand-built .pkg.tar.zst (.PKGINFO + bsdtar). No network, no
# fakeroot, no build user. ~0.1 s.
# makepkg : real makepkg run as the unprivileged user "su-builder" via
# runuser. Needs fakeroot (installed with `pacman -Sy fakeroot`
# if missing, so it needs network). Slower.
# FIXTURE_ISOLATE 0 (default) | 1
# 1 comments out every repo section except [su-dummy], so `pacman -Qu`
# reports EXACTLY the three dummy packages (no real upgrades, no network
# needed after the fixture is built).
# FIXTURE_STRICT_DEP 0 (default) | 1
# 1 makes su-dummy-a 2.0 depend on "su-dummy-b>=2.0" (instead of plain
# "su-dummy-b"), so excluding su-dummy-b from an upgrade (--ignore /
# IgnorePkg) while upgrading su-dummy-a fails with unsatisfied deps.
# FIXTURE_REPO repo directory (default /srv/su-repo)
# FIXTURE_SYNC 0 (default) | 1 -> run `pacman -Sy` at the end
#
# The fixture deliberately does NOT sync by default: the tool under test is
# expected to perform the refresh itself (pacman -Sy), after which
# `pacman -Qu` lists su-dummy-{a,b,c} (1.0-1 -> 2.0-1).
export LC_ALL=C
FIXTURE_BUILDER=${FIXTURE_BUILDER:-tar}
FIXTURE_ISOLATE=${FIXTURE_ISOLATE:-0}
FIXTURE_STRICT_DEP=${FIXTURE_STRICT_DEP:-0}
FIXTURE_REPO=${FIXTURE_REPO:-/srv/su-repo}
FIXTURE_SYNC=${FIXTURE_SYNC:-0}
FIXTURE_REPO_NAME=su-dummy
FIXTURE_EPOCH=1700000000 # fixed build date => reproducible tarballs
fx_log() { printf 'fixture: %s\n' "$*" >&2; }
fx_die() { fx_log "ERROR: $*"; exit 1; }
# pacman wrapper: stdout dropped, the harmless "database file for X does not
# exist" warnings (fresh image, no sync yet) filtered, real errors kept, exit
# status preserved.
fx_pacman() {
local rc=0
pacman "$@" 2>&1 >/dev/null | { grep -v "^warning: database file for" >&2 || true; } || rc=$?
return $rc
}
# fx_build_tar <outdir> <name> <pkgver> <pkgdesc> [depend...]
# Hand-built package: only a .PKGINFO and the payload are required by pacman -U
# and repo-add (no .MTREE / .BUILDINFO needed; `pacman -Qk` is happy too).
fx_build_tar() {
local out=$1 name=$2 ver=$3 desc=$4; shift 4
local w payload
w=$(mktemp -d)
payload="usr/share/su-dummy/$name"
mkdir -p "$w/$payload"
printf '%s\n' "$ver" >"$w/$payload/VERSION"
{
printf 'pkgname = %s\n' "$name"
printf 'pkgbase = %s\n' "$name"
printf 'pkgver = %s-1\n' "$ver"
printf 'pkgdesc = %s\n' "$desc"
printf 'url = https://example.invalid/su-dummy\n'
printf 'builddate = %s\n' "$FIXTURE_EPOCH"
printf 'packager = su-fixture <su-fixture@example.invalid>\n'
printf 'size = %s\n' "$(wc -c <"$w/$payload/VERSION")"
printf 'arch = any\n'
printf 'license = custom\n'
local d
for d in "$@"; do printf 'depend = %s\n' "$d"; done
} >"$w/.PKGINFO"
(cd "$w" && bsdtar --zstd --uid 0 --gid 0 --uname root --gname root \
-cf "$out/$name-$ver-1-any.pkg.tar.zst" .PKGINFO usr)
rm -rf "$w"
}
# fx_build_makepkg <outdir> <name> <pkgver> <pkgdesc> [depend...]
# Real makepkg. makepkg refuses to run as root, so run it as an unprivileged
# user with `runuser -u` (no sudo needed; `su -s /bin/bash -c` also works).
fx_build_makepkg() {
local out=$1 name=$2 ver=$3 desc=$4; shift 4
local w depends="" d
for d in "$@"; do depends+="'$d' "; done
w=/home/su-builder/build/$name-$ver
mkdir -p "$w"
cat >"$w/PKGBUILD" <<EOF
pkgname=$name
pkgver=$ver
pkgrel=1
pkgdesc='$desc'
arch=('any')
url='https://example.invalid/su-dummy'
license=('custom')
options=('!debug' '!strip') # arch=any payload: no debugedit/binutils needed
depends=($depends)
package() {
install -Dm644 /dev/stdin "\$pkgdir/usr/share/su-dummy/\$pkgname/VERSION" <<<"\$pkgver"
}
EOF
chown -R su-builder: "$w"
runuser -u su-builder -- env \
PKGDEST="$out" SOURCE_DATE_EPOCH="$FIXTURE_EPOCH" \
PACKAGER='su-fixture <su-fixture@example.invalid>' \
bash -c "cd '$w' && makepkg --force --nodeps --noconfirm --nocolor >/dev/null 2>&1" \
|| { runuser -u su-builder -- bash -c "cd '$w' && makepkg --force --nodeps --noconfirm --nocolor" >&2; fx_die "makepkg failed for $name"; }
}
fx_prepare_makepkg() {
if ! command -v fakeroot >/dev/null; then
fx_log "makepkg builder: installing fakeroot (needs network)"
fx_pacman -Sy --noconfirm --needed fakeroot
fi
id su-builder >/dev/null 2>&1 || useradd --create-home su-builder
}
fx_build() { # fx_build <outdir> <name> <ver> <desc> [dep...]
case $FIXTURE_BUILDER in
tar) fx_build_tar "$@" ;;
makepkg) fx_build_makepkg "$@" ;;
*) fx_die "unknown FIXTURE_BUILDER=$FIXTURE_BUILDER" ;;
esac
}
fx_main() {
set -euo pipefail
[ "$(id -u)" -eq 0 ] || fx_die "must run as root"
local old new
old=$(mktemp -d); new=$FIXTURE_REPO
rm -rf "$FIXTURE_REPO"; mkdir -p "$FIXTURE_REPO"
chmod 755 "$old"
if [ "$FIXTURE_BUILDER" = makepkg ]; then
fx_prepare_makepkg
chown su-builder: "$old" "$new" # PKGDEST dirs must be writable by the build user
fi
# 1.0 packages (installed) and 2.0 packages (offered by the repo)
fx_build "$old" su-dummy-a 1.0 'Skeptical-updater dummy package A' su-dummy-b
fx_build "$old" su-dummy-b 1.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build "$old" su-dummy-c 1.0 'Skeptical-updater dummy package C (independent)'
local a2_dep=su-dummy-b
[ "$FIXTURE_STRICT_DEP" = 1 ] && a2_dep='su-dummy-b>=2.0'
fx_build "$new" su-dummy-a 2.0 'Skeptical-updater dummy package A' "$a2_dep"
fx_build "$new" su-dummy-b 2.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build "$new" su-dummy-c 2.0 'Skeptical-updater dummy package C (independent)'
# Local repository database (repo-add ships with pacman)
(cd "$FIXTURE_REPO" && repo-add --quiet "$FIXTURE_REPO_NAME.db.tar.gz" ./*.pkg.tar.zst)
chmod -R a+rX "$FIXTURE_REPO"; chmod a+rx /srv 2>/dev/null || true
# pacman.conf: register the repo (appended at the end => lowest priority)
if ! grep -q "^\[$FIXTURE_REPO_NAME\]" /etc/pacman.conf; then
{
printf '\n[%s]\n' "$FIXTURE_REPO_NAME"
printf 'SigLevel = Optional TrustAll\n'
printf 'Server = file://%s\n' "$FIXTURE_REPO"
} >>/etc/pacman.conf
fi
# pman-style ignore file, included from the [options] section
# (inserted right after the FIRST "[options]" header; appending at EOF would
# land in whatever section happens to be last).
: >/etc/pacman.d/ignorepkg
if ! grep -q '^Include *= */etc/pacman.d/ignorepkg' /etc/pacman.conf; then
sed -i '0,/^\[options\]/s||[options]\nInclude = /etc/pacman.d/ignorepkg|' /etc/pacman.conf
fi
if [ "$FIXTURE_ISOLATE" = 1 ]; then
# comment out all repos except [su-dummy]; keep [options] intact
awk -v keep="$FIXTURE_REPO_NAME" '
/^\[.*\][[:space:]]*$/ { sec=$0; sub(/^\[/,"",sec); sub(/\].*$/,"",sec); active = (sec=="options" || sec==keep) }
{ if (sec != "" && !active && $0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*$/) print "#" $0; else print }
' /etc/pacman.conf >/etc/pacman.conf.new && mv /etc/pacman.conf.new /etc/pacman.conf
fi
# Install the 1.0 packages: b as a dependency, a and c explicit.
fx_pacman -U --noconfirm "$old"/su-dummy-a-1.0-1-any.pkg.tar.zst \
"$old"/su-dummy-b-1.0-1-any.pkg.tar.zst \
"$old"/su-dummy-c-1.0-1-any.pkg.tar.zst
fx_pacman -D --asdeps su-dummy-b
rm -rf "$old"
if [ "$FIXTURE_SYNC" = 1 ]; then fx_pacman -Sy; fi
fx_log "builder=$FIXTURE_BUILDER isolate=$FIXTURE_ISOLATE repo=$FIXTURE_REPO"
fx_log "installed: $(pacman -Q su-dummy-a su-dummy-b su-dummy-c 2>/dev/null | tr '\n' ';')"
fx_log "repo list: $(pacman-conf --repo-list | tr '\n' ' ')"
}
# Run when executed; do nothing when sourced (so tests can reuse fx_build_tar etc.
# without inheriting `set -e`).
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
fx_main "$@"
fi
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# pacman helper functions for skeptical-updater (proven on pacman 7.1.0 /
# archlinux:latest by discovery/pacman/checks.sh). Source this file.
#
# Conventions
# * every pacman call runs with LC_ALL=C (stable field names / messages) and
# COLUMNS=0 (never wrap -Qi/-Si output, even if COLUMNS is exported).
# * PM_OPTS is an optional array of extra global pacman options, e.g.
# PM_OPTS=(--dbpath /tmp/checkdb) to work against a temporary sync DB.
# * nothing here needs pacman-contrib, sudo, python or fakeroot.
PM_OPTS=("${PM_OPTS[@]}")
_pm() { LC_ALL=C COLUMNS=0 pacman "${PM_OPTS[@]}" "$@"; }
# ---------------------------------------------------------------- refresh ---
# Refresh the real sync DBs (root). Safe as long as it is followed by -Su
# (never `pacman -S foo` after a bare -Sy: that is the partial-upgrade case).
pm_refresh() { LC_ALL=C pacman -Sy "$@"; }
# Refresh a THROWAWAY sync DB (root, no fakeroot/pacman-contrib needed) and
# point PM_OPTS at it; the system DB under /var/lib/pacman/sync is untouched.
# The temp dir must be world-traversable (mode 755) because the sandboxed
# download user "alpm" has to write into it, mktemp -d's 0700 is not enough.
pm_refresh_tmpdb() {
local db
db=$(mktemp -d /tmp/su-checkdb.XXXXXX) || return 1
chmod 755 "$db"
ln -s "$(pacman-conf DBPath)local" "$db/local" || return 1
LC_ALL=C pacman -Sy --dbpath "$db" --logfile /dev/null >/dev/null || return 1
PM_OPTS=(--dbpath "$db")
PM_TMPDB=$db
}
# ------------------------------------------------------------ upgradable ---
# name<TAB>oldversion<TAB>newversion<TAB>repo<TAB>ignored(0|1)
# -Qu line : "name old -> new" or "name old -> new [ignored]"
# -Sl line : "repo name version [installed[: oldver]]"
# The candidate repo is the FIRST repo (config order) that carries the name,
# which is what both `-Qu` and `-S name` pick (verified with a dup in 2 repos).
# Prints nothing (and returns 1) when nothing is upgradable, like `pacman -Qu`.
pm_upgradable() {
local out
out=$(awk '
FNR == NR { if (!($2 in repo)) repo[$2] = $1; next }
{ printf "%s\t%s\t%s\t%s\t%d\n", $1, $2, $4, repo[$1], ($5 == "[ignored]") }
' <(_pm -Sl 2>/dev/null) <(_pm -Qu 2>/dev/null))
[ -n "$out" ] || return 1
printf '%s\n' "$out"
}
# Effective IgnorePkg patterns (globs allowed), wherever they are configured
# (pacman.conf or an Include such as /etc/pacman.d/ignorepkg).
pm_ignored_patterns() { pacman-conf IgnorePkg; }
# ------------------------------------------------------------------ info ---
# pm_field <-Qi|-Si> <Field> <target>...
# -> "<name>\t<value>" per package; wrapped continuation lines are joined with
# one space. Use "repo/name" targets with -Si (a name in 2 repos prints twice).
# List fields keep pacman's two-space separators (split with `tr -s ' ' '\n'`);
# "None" is returned verbatim. Missing targets are skipped (the pacman error
# goes to /dev/null).
pm_field() {
local op=$1 field=$2; shift 2
_pm "$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() }'
}
# One-line description of the installed package / of the upgrade candidate.
pm_desc_local() { pm_field -Qi Description "$@"; } # name<TAB>desc
pm_desc_cand() { pm_field -Si Description "$@"; } # args: repo/name
# Full dumps (verbatim pacman output, never wrapped).
pm_info_local() { _pm -Qi -- "$@"; }
pm_info_cand() { _pm -Si -- "$@"; } # args: repo/name
# Installed reverse dependencies as plain names, one per line (sorted).
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; }
# ------------------------------------------------------------ dependency ---
# pm_closure [-r] [-o] <pkg-or-dep>...
# Recursive closure over INSTALLED packages, one real package name per line,
# sorted, roots excluded.
# default : forward closure (Depends On, with provides/soname/version
# constraints resolved: `libfoo.so=1-64`, `glibc>=2.38`, `sh`)
# -r : reverse closure (Required By)
# -o : with -r also follow "Optional For"
# Roots may be package names or provided names. One `pacman -Qi` call,
# one awk process.
pm_closure() {
local mode=deps opt=0
while [ $# -gt 0 ]; do
case $1 in -r) mode=rdeps ;; -o) opt=1 ;; --) shift; break ;; *) break ;; esac
shift
done
_pm -Qi 2>/dev/null | awk -v roots="$*" -v mode="$mode" -v opt="$opt" '
function norm(s) { gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return (s == "None") ? "" : s }
function flush( c, a, i, p) {
if (name == "") return
real[name] = 1; dep[name] = norm(F["Depends On"]); rb[name] = norm(F["Required By"])
of[name] = norm(F["Optional For"]); pv[name] = norm(F["Provides"])
name = ""; delete F; cur = ""
}
function resolve(d, b) { b = d; sub(/[<>=].*/, "", b); if (b in real) return b; return prov[b] }
/^[ \t]*$/ { flush(); next }
/^[^ \t]/ { i = index($0, ":"); k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
v = substr($0, i + 1); sub(/^[ \t]+/, "", v); cur = k; F[k] = v
if (k == "Name") name = v; next }
{ sub(/^[ \t]+/, ""); F[cur] = F[cur] " " $0 }
END {
flush()
for (n in real) { c = split(pv[n], a, " "); for (i = 1; i <= c; i++) { p = a[i]; sub(/[<>=].*/, "", p); prov[p] = (p in prov) ? prov[p] " " n : n } }
nr = split(roots, r, " "); head = 0; tail = 0
for (i = 1; i <= nr; i++) { m = split(resolve(r[i]), a, " "); for (j = 1; j <= m; j++) if (!(a[j] in seen)) { seen[a[j]] = 1; isroot[a[j]] = 1; q[++tail] = a[j] } }
while (head < tail) {
n = q[++head]
if (mode == "deps") { lst = dep[n]; c = split(lst, a, " "); for (i = 1; i <= c; i++) { m = split(resolve(a[i]), b, " "); for (j = 1; j <= m; j++) if (!(b[j] in seen)) { seen[b[j]] = 1; q[++tail] = b[j] } } }
else { lst = rb[n] (opt ? " " of[n] : ""); c = split(lst, a, " "); for (i = 1; i <= c; i++) if (!(a[i] in seen)) { seen[a[i]] = 1; q[++tail] = a[i] } }
}
for (n in seen) if (!(n in isroot)) print n | "sort"
}'
}
# ------------------------------------------------------- apply / dry-run ---
# Join array elements with commas (for --ignore a,b,c).
_pm_join() { local IFS=,; printf '%s' "$*"; }
# pm_plan [pkg-to-skip...] -> "repo/name newversion" per package the upgrade
# would install (dependency order, includes NEW dependencies and respects
# IgnorePkg). Exit 1 when the exclusions make the transaction unsatisfiable.
# Needs a synced DB (run pm_refresh first); does not need root.
pm_plan() {
local ign=() out err rc
[ $# -gt 0 ] && ign=(--ignore "$(_pm_join "$@")")
err=$(mktemp)
out=$(_pm -Sup --noconfirm "${ign[@]}" --print-format '%r/%n %v' </dev/null 2>"$err"); rc=$?
if [ "$rc" -eq 0 ]; then [ -z "$out" ] || printf '%s\n' "$out"
else printf '%s\n' "$out" >&2; cat "$err" >&2; fi
rm -f "$err"; return "$rc"
}
# pm_apply [pkg-to-skip...] (root) non-interactive upgrade of the already
# refreshed DB. Use `-Su`, not `-Syu`, to apply exactly what was reviewed.
pm_apply() {
local ign=()
[ $# -gt 0 ] && ign=(--ignore "$(_pm_join "$@")")
LC_ALL=C pacman -Su --noconfirm "${ign[@]}"
}
# pm_purge <pkg>... (root) -Rns: also removes now-unneeded deps + config backups
pm_purge() { LC_ALL=C pacman -Rns --noconfirm -- "$@"; }
+706
View File
@@ -0,0 +1,706 @@
# RPM family discovery: DNF (AlmaLinux / Rocky / Fedora) and ZYPPER (openSUSE)
Everything below was run in throwaway `docker run --rm -i` containers (Docker 29.8.2 on the host, 2026-10-09).
All outputs are real; "exit=N" is the real exit status. All commands run with `LC_ALL=C`.
Companion files (all in `discovery/rpm/`, all proven on every image listed in section 0):
| file | purpose |
|---|---|
| `fixture.sh` | E2E fixture: builds 3 dummy RPMs (+ optional extras), local repo, 1.0 installed / 2.0 offered. dnf vs zypper auto-detected |
| `lib-rpm.sh` | the proven bash building blocks quoted in this document (`su_refresh`, `su_upgradable`, `su_describe`, `su_rdeps`, `su_closure`, `su_upgrade`, `su_plan`, `su_remove`, ...) |
| `proof.sh` | runs the fixture + every `lib-rpm.sh` function and asserts the results (43-53 PASS lines per image) |
Reproduce on any image: `docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh` or `... bash /d/proof.sh`.
---------------------------------------------------------------------------------------------------------
## 0. Verdict, images, and the surprises
### 0.1 Chosen commands (one line each)
| need | DNF (dnf4 and dnf5, `yum` is the same binary) | ZYPPER |
|---|---|---|
| refresh | `dnf -q --refresh --setopt=skip_if_unavailable=0 makecache >/dev/null` (exit 0/1) | `zypper --non-interactive --quiet refresh` (exit 0; 4 repo error; 3 unknown repo; 6 no repos) |
| upgradable list | `dnf -q check-update --setopt=skip_if_unavailable=0` (exit 100 = updates, 0 = none, 1 = error), parse 3 columns `name.arch evr repo`, old version from `rpm -qa` | `zypper --non-interactive --xmlout list-updates` (exit 0 always), parse `<update ... name edition edition-old arch>` + `<source alias>` |
| summary/description | `dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' PKG`, fallback `rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' PKG` | `zypper -q -n info PKG` (shows candidate, else installed), parse `Summary :` / indented `Description :` |
| installed reverse deps | pure rpm: provides + owned files joined against all REQUIRENAMEs (`su_rdeps`); == `dnf repoquery --installed --whatrequires` | same pure-rpm function (identical on both families) |
| recursive installed closure | pure rpm BFS `su_closure` (rpm -q REQUIRENAME + rpm -q --whatprovides); == dnf4 `repoquery --installed --requires --resolve --recursive`; dnf5 has no `--resolve` | same function |
| per-run exclusion | `dnf -y upgrade --exclude=a,b` (comma list; repeated flags also work; dnf4 and dnf5) | no `--exclude`. Chosen: private `ZYPP_CONF` with its own `locksfile.path` (`su_zypper_excluding`). Alternative with exact restore: snapshot/restore `/etc/zypp/locks` + trap (`su_zypper_excluding_snapshot`) |
| non-interactive upgrade | `dnf -y upgrade` | Leap/SLE: `zypper --non-interactive update`; Tumbleweed (os-release `ID=opensuse-tumbleweed`): `zypper --non-interactive dup` |
| remove + unneeded deps | `dnf -y remove PKG` (`clean_requirements_on_remove=1` is the default on dnf4 and dnf5) | `zypper --non-interactive remove --clean-deps PKG` |
| plan preview (new deps, removals, obsoletes) | `dnf --assumeno upgrade [--exclude=..]` + table parse (`su_plan_dnf`) | `zypper --non-interactive --xmlout update` or `dup` plus `--dry-run` (`<install-summary><to-upgrade>/<to-install>/<to-remove>`) |
### 0.2 Correct image names
| asked | result |
|---|---|
| `almalinux:9` | exists. AlmaLinux 9.8, **dnf 4.14.0**, rpm 4.16.1.3, bash 5.1.8 |
| `almalinux:10` | exists. AlmaLinux 10.2, **dnf 4.20.0 (NOT dnf5)**, rpm 4.19.1.1, bash 5.2.26 |
| `rockylinux:9` | exists but **stale**: Docker "official" library image = Rocky 9.3 created 2023-11-28 (96 pending upgrades on day one), dnf 4.14.0. Fresh equivalent: `rockylinux/rockylinux:9` or `quay.io/rockylinux/rockylinux:9` = Rocky 9.8 (same image id `686e4558d0a5`) |
| `rockylinux:10` | **does not exist**: `docker manifest inspect rockylinux:10` -> "no such manifest" (also `:10.0`, `:10.1`, `:10-minimal` missing). **Correct: `rockylinux/rockylinux:10`** (Docker Hub) **or `quay.io/rockylinux/rockylinux:10`** |
| quay vs Docker Hub Rocky 10 | **same image**: both resolve to id `sha256:0b5329d806d0...`, repo digest `sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834`. Rocky 10.2, dnf 4.20.0, rpm 4.19.1.1. (`fixture.sh` also passes on the quay tag, 8 s.) |
| `opensuse/tumbleweed:latest` | openSUSE Tumbleweed (os-release `ID=opensuse-tumbleweed`, `VERSION_ID=20261007`), zypper 1.14.101, rpm 4.20.1, bash 5.3.20 |
| `opensuse/leap:latest` | **Leap 16.0** (`ID=opensuse-leap`), image created 2025-08-26, zypper 1.14.101, rpm 4.20.1, bash 5.2.37 |
| extra, for dnf5 only | `fedora:latest` = Fedora 44, **dnf5 5.4.3.0**, rpm 6.0.2 (pulled only to verify dnf5 syntax; not a target) |
### 0.3 Surprises that change the design
1. **EL10 is dnf4 (4.20.0), not dnf5.** AlmaLinux 10 has no `dnf5` package anywhere (BaseOS/AppStream/CRB/Extras/EPEL: `dnf -q --enablerepo='*' list --available dnf5` -> "No matching Packages"; only `microdnf 3.10.1`). Rocky 10 ships `dnf5 5.1.12` only in the *disabled* `devel` repo. So dnf5 is only met on Fedora (or a user-installed dnf5). The tool must support **dnf4 and dnf5 from one code path**; the only target images that run dnf5 are not in the requested list, so dnf5 was verified on `fedora:latest`.
2. `dnf5` silently ignores a dead repo (exit 0, `-q` hides the warning) unless `--setopt=skip_if_unavailable=0`. dnf4 errors by default (exit 1).
3. `rpm -qa --qf '[%{REQUIRENAME}\t%{NAME}\n]'` is **broken** (`error: incorrect format: array iterator used with different sized arrays`, ~50% of packages) on every rpm 4.16..6.0; the scalar tag must be written `%{=NAME}` inside `[...]`.
4. Missing tools in base images: no `awk`/`find`/`xargs`/`hostname`/`python3`/`xmllint` on Tumbleweed; no `find`/`xargs`/`python3`/`xmllint` on Leap; no `diff`/`cmp`/`ps`/`which`/`sudo` almost everywhere. All proven code uses only bash + rpm + sed/grep/tr/sort/join/cut/mktemp.
5. `rpm -q --whatrequires bash` returns **1** package on EL9 where `dnf repoquery --installed --whatrequires bash` and `su_rdeps bash` return **52** (file deps `/bin/sh`, `/usr/bin/bash`, sonames).
6. `zypper removelock` is not an undo (deletes *all* matching locks, leaves an empty `/etc/zypp/locks` file even if there was none).
7. `dnf list --upgrades` and (dnf4) `repoquery --upgrades` ignore the versionlock plugin; only `check-update` honours it on both majors.
---------------------------------------------------------------------------------------------------------
### 0.4 Base image facts (this is item 9 of the brief; repeated as a pointer in section 9)
| | alma:9 | alma:10 | rocky:9 (9.3 lib) | rocky 10 | tumbleweed | leap 16.0 |
|---|---|---|---|---|---|---|
| package mgr | dnf 4.14.0 | dnf 4.20.0 | dnf 4.14.0 | dnf 4.20.0 | zypper 1.14.101 | zypper 1.14.101 |
| `yum` | symlink -> `dnf-3` | `dnf-3` | `dnf-3` | `dnf-3` | absent | absent |
| rpm | 4.16.1.3 | 4.19.1.1 | 4.16.1.3 | 4.19.1.1 | 4.20.1 | 4.20.1 |
| bash | 5.1.8 | 5.2.26 | 5.1.8 | 5.2.26 | 5.3.20 | 5.2.37 |
| `sudo` | no (installable, baseos) | no | no | no | no (installable) | no (installable) |
| `su` | yes | **no** | yes | yes | yes | yes |
| `python3` | yes | yes | yes | yes | **no** | **no** |
| awk / find / xargs | yes | yes | yes | yes | **no / no / no** | awk yes, **find, xargs no** |
| xmllint | yes | yes | yes | yes | no | no |
| runs as | root | root | root | root | root | root |
`bats`:
* EL9/EL10 (Alma and Rocky): **not in BaseOS/AppStream/CRB**; needs EPEL: `dnf -y install epel-release && dnf -y install bats` (epel-release is in `extras`, enabled by default). Verified: EL9 -> `Bats 1.8.0` (bats-1.8.0-1.el9), EL10 -> `Bats 1.11.1` (bats-1.11.1-1.el10_0). `bats` install itself took 3-5 s; `epel-release` 1-12 s (measured on all four EL images).
* Tumbleweed: `zypper -n in bats` -> `Bats 1.14.0` (bats-1.14.0-1.1), main OSS repo, no extra repo. Leap 16.0: `Bats 1.11.1` (bats-1.11.1-160000.2.2), main repo. (install of bats+sudo: 8-9 s.)
* `sudo` installable everywhere: EL baseos `1.9.17p2` / `1.9.17-10.p2.el10_2.7`; Tumbleweed `1.9.17p2`; Leap `1.9.17p1`.
* `python3` on openSUSE: Leap has `python3` / `python313`; Tumbleweed has versioned `python311`/`python312`/... (no unversioned package).
* `rpm-build` and `createrepo_c` are installable on all six images (BaseOS/AppStream on EL, main OSS on openSUSE). EL9: rpm-build 4.16.1.3-40.el9 + createrepo_c 0.20.1-4.el9; EL10: rpm-build 4.19.1.1-23.el10 + createrepo_c 1.1.2-4.el10; Tumbleweed: rpm-build 4.20.1-11.1 + createrepo_c 1.2.3-1.4; Leap 16: rpm-build 4.20.1-160000.3.1 + createrepo_c 1.1.0-160000.2.2. No EPEL needed for them.
---------------------------------------------------------------------------------------------------------
## 1. Refresh metadata non-interactively
### DNF
```
$ dnf makecache # alma9 (dnf4), cold cache, 9.0 s
AlmaLinux 9 - AppStream 23 MB/s | 28 MB 00:01
AlmaLinux 9 - BaseOS 32 MB/s | 45 MB 00:01
AlmaLinux 9 - Extras 52 kB/s | 33 kB 00:00
Last metadata expiration check: 0:00:01 ago on Fri Oct 9 08:20:43 2026.
Metadata cache created. exit=0
```
Variants tested (stdout lines / stderr lines / exit):
| command | dnf4 (alma9/10) | dnf5 (fedora 44) |
|---|---|---|
| `dnf makecache` | 5 / 0 / 0 (progress + "Metadata cache created." on **stdout**) | 1 / 3 / 0 (progress "Updating and loading repositories:" ... "Repositories loaded." on **stderr**, "Metadata cache created." on stdout) |
| `dnf -q makecache` | 0 / 0 / 0 (silent) | 1 / 0 / 0 (**still prints "Metadata cache created." to stdout**) -> `>/dev/null` |
| `dnf --refresh makecache` and `dnf makecache --refresh` | both accepted, forces re-download | both accepted |
| plain `makecache` (no `--refresh`) | no re-download while the cache is younger than `metadata_expire` (dnf4 dump on alma9: `metadata_expire = 172800` = 48 h; ~1-2 s of revalidation measured) | same |
* `--refresh` (= "set metadata as expired") is the right switch for a skeptical tool: `dnf -q --refresh makecache` re-downloads every enabled repo (alma9 1.6-7 s, alma10 2.5-7 s, rocky 1-1.4 s, fedora 1.7 s measured warm; cold 3-9.5 s, section 11).
* `dnf check-update` exit codes (verified on dnf4 and dnf5): **100** = updates available, **0** = nothing (also 0 when a name pattern matches nothing: `dnf -q check-update 'nonexist*'` -> exit=0), **1** = error.
* **Dead repo handling differs** (repodata removed from the local repo):
| command | dnf4 alma10 | dnf5 fedora |
|---|---|---|
| `dnf -q --refresh makecache` | exit 1: `Error: Failed to download metadata for repo 'su-dummy': Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried` | **exit 0**, no message (repo silently ignored, default `skip_if_unavailable=true` for repos that do not set it) |
| same + `--setopt=skip_if_unavailable=0` | exit 1 (same message) | exit 1: `Failed to download metadata (baseurl: "file:///var/lib/su-dummy-repo") for repository "su-dummy": Usable URL not found` |
| `dnf -y upgrade su-dummy-a` | exit 1 | **exit 0, prints `Nothing to do.`; su-dummy-a stays 1.0** (the dead repo is only a stderr warning `>>> Curl error (37) ...`, absent with `-q`) unless `skip_if_unavailable=0` |
So always pass `--setopt=skip_if_unavailable=0` (harmless on dnf4) and never rely on `-q` to see problems.
* Oddity seen on dnf 4.14.0 (EL9): with the repo gone, `dnf -q check-update` still returned **100 from stale cache**, while `makecache --refresh`, `repoquery` and `upgrade` failed with exit 1. => run the explicit refresh first and abort on its failure, do not infer repo health from check-update.
* Real-world flake observed once on `rockylinux:9`: `Error: Failed to download metadata for repo 'extras': repomd.xml parser error: Parse error at line: 1 (Extra content at the end of the document` (mirror served garbage), exit 1; re-run was clean. The refresh step correctly failed the run; the tool should surface this and let the user retry.
* `yum`: `/usr/bin/yum -> dnf-3` on all EL images (prints `4.14.0` / `4.20.0`); `/usr/bin/yum -> dnf5` on Fedora. Detection: `dnf --version | head -1` starts with `dnf5 version` on dnf5, with a bare version (`4.14.0`) on dnf4.
### ZYPPER
```
$ zypper --non-interactive refresh # tumbleweed, metadata fresh, 0.6 s
Repository 'openSUSE-Tumbleweed-Update' is up to date.
Repository 'openSUSE-Tumbleweed-Non-Oss' is up to date.
Repository 'Open H.264 Codec (openSUSE Tumbleweed)' is up to date.
Repository 'openSUSE-Tumbleweed-Oss' is up to date.
Repository 'su-dummy' is up to date.
All repositories have been refreshed. exit=0
```
* `-q` silences it; exit 0 either way. `zypper refresh --force su-dummy` re-downloads ("Forcing raw metadata refresh ... Building repository 'su-dummy' cache [....done]", exit 0).
* Normal `refresh` only checks whether the repo changed (cheap); it is also what `zypper lu/up/dup` do implicitly (autorefresh=on).
* Broken repo: `Retrieving repository 'su-dummy' metadata [.error]` ... `Could not refresh the repositories because of errors.` **exit=4**. Unknown name (`refresh nonexist`): `Repository 'nonexist' not found by its alias, number, or URI.` **exit=3**.
* Leap prints `Refreshing service 'openSUSE'.` as first line of nearly every command (it is a service, not a repo) - in `-q` mode it disappears, in `--xmlout` mode it is a `<message type="info">`.
* GPG: the test repo is `--no-gpgcheck`. For real third-party repos `--non-interactive` would refuse unknown keys; `--gpg-auto-import-keys` is the (security-relevant) opt-in. Not used by the tool.
---------------------------------------------------------------------------------------------------------
## 2. Machine-parsable list of upgradable packages (`name<TAB>old<TAB>new<TAB>repo`)
### 2.1 DNF: candidates compared (alma9/alma10 = dnf4, fedora = dnf5)
| | `dnf -q check-update` | `dnf -q repoquery --upgrades --queryformat ...` | `dnf -q list --upgrades` |
|---|---|---|---|
| first line(s) | dnf4: one **blank line**; dnf5: a header line `Upgrades` (printed even with `-q`) | none | `Available Upgrades` (dnf4) / `Available upgrades` (dnf5) |
| columns | 3: `name.arch evr repo` (arch suffix attached, epoch shown as `2:...` when non-zero) | whatever you ask for | same 3 columns |
| honours `--exclude` / `excludepkgs` | yes (dnf4, dnf5) | yes | yes |
| honours **versionlock** plugin | **yes** (dnf4 and dnf5) | dnf4: **no** (locked pkg still listed), dnf5: yes | **no** on both |
| same code path as `dnf upgrade` | yes | no | no |
| "Obsoleting Packages" | dnf4: trailing section `Obsoleting Packages` (+ indented old pkg `@System`); dnf5: not listed | not listed | not listed |
| exit | 100 / 0 / 1 | 0 | 0 |
| queryformat escapes | - | dnf4 expands `\t` `\n` **and appends its own newline** (blank line after each record); dnf5 does **not** expand `\t` (prints literal `\t`), expands `\n`, appends nothing | - |
| old (installed) version | not given | not given (no installed-version tag) | not given |
Samples (`cat -A`; EL9 shown, EL10 identical in shape):
```
$ dnf -q check-update | cat -A # dnf4, alma9, exit=100
$
gnutls.x86_64 3.8.10-9.el9_8 baseos $
su-dummy-a.noarch 2.0-1 su-dummy$
su-dummy-b.noarch 2.0-1 su-dummy$
su-dummy-c.x86_64 2.0-1 su-dummy$
vim-minimal.x86_64 2:8.2.2637-26.el9_8.23 baseos $
$ dnf -q check-update | head -3 # dnf5, fedora 44, exit=100
Upgrades
ca-certificates.noarch 2026.2.90_v9.0.317-1.fc44 updates
curl.x86_64 8.18.0-12.fc44 updates
$ dnf -q check-update 'nonexist*' ; echo exit=$? # both: nothing printed, exit=0
$ dnf -q repoquery --upgrades --queryformat '%{name}\t%{evr}\t%{repoid}\n' 'su-dummy-*'
su-dummy-a 2.0-1 su-dummy # dnf4: ...followed by a BLANK line after every record
su-dummy-a\t2.0-1\tsu-dummy # dnf5: literal "\t" (not expanded!)
$ dnf -q repoquery --upgrades --queryformat $'%{name}\t%{arch}\t%{evr}\t%{repoid}\n' 'su-dummy-*' | cat -A
su-dummy-a^Inoarch^I2.0-1^Isu-dummy$ # dnf4 and dnf5 identical when real TAB chars are passed
$ dnf -q repoquery --upgrades # default format on both: name-epoch:ver-rel.arch
su-dummy-a-0:2.0-1.noarch
```
Other tag differences: `%{reponame}` is the repo *id* on dnf4 but the human name on dnf5 (`skeptical-updater dummy packages`); `%{repoid}` is the id on both. `%{evr}` omits epoch `0` on both, includes `1:` when non-zero (`su-dummy-e` with `Epoch: 1` -> `1:2.0-1`).
dnf4 long names do **not** wrap in a non-tty even at `COLUMNS=30` (a 108-character package name stays on one line, padded): the 3-field read is safe; the same holds on dnf5.
**Decision: parse `dnf -q check-update`** (only listing that honours excludes *and* versionlock on both majors and is the same solver view as `dnf upgrade`), take the *old* version from `rpm -qa`, and use `repoquery` only for summary/description. Parse (from `lib-rpm.sh`, proven on 5 dnf images):
```bash
_su_installed_evr() { # "name.arch<TAB>evr", ascending so the highest version (installonly pkgs have several) wins
rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2V
}
su_upgradable_dnf() {
local out rc na ver repo rest name arch k evr; local -A old=() oldn=()
out=$(dnf -q check-update --setopt=skip_if_unavailable=0); rc=$?
case $rc in 0) return 0 ;; 100) ;; *) return "$rc" ;; esac
while IFS=$'\t' read -r k evr; do evr=${evr#0:}; old[$k]=$evr; oldn[${k%.*}]=$evr; done < <(_su_installed_evr)
while read -r na ver repo rest; do
[ "$na" = Obsoleting ] && break # dnf4 trailing "Obsoleting Packages" section
[ -n "$repo" ] && [ -z "$rest" ] || continue # drops the blank line (dnf4), the "Upgrades" header (dnf5), anything not 3 fields
name=${na%.*}; arch=${na##*.} # plain name; the arch never contains a dot
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo" "$arch"
done <<<"$out" | LC_ALL=C sort
}
```
Output from `proof.sh` (alma10, tab shown as `^I`):
```
gnutls^I3.8.10-4.el10_2^I3.8.10-5.el10_2^Ibaseos^Ix86_64$
rpm-sequoia^I1.10.1.1-2.el10^I1.10.2.1-1.el10_2^Ibaseos^Ix86_64$
su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-b^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
su-dummy-c^I1.0-1^I2.0-1^Isu-dummy^Ix86_64$
```
Epoch example (alma9): `vim-minimal 2:8.2.2637-26.el9_8.21 2:8.2.2637-26.el9_8.23 baseos x86_64`.
**Excluded / locked packages**: both are *removed from the sack*, so they simply do not appear in `check-update`/`repoquery` (with `--exclude=a,b`: only `su-dummy-c` remains). To see them anyway: dnf4 `--disableexcludes=all`; **dnf5 has no such option** (`Unknown argument "--disableexcludes=all" for command "repoquery"`, also no `--disable-excludes`/`--no-excludes`). Versionlock: dnf4 `python3-dnf-plugin-versionlock` (`/etc/dnf/plugins/versionlock.list`, line `su-dummy-a-0:1.0-1.*`), dnf5 `dnf5-plugins` (`/etc/dnf/versionlock.toml`); after `versionlock add su-dummy-a`, `check-update` lists only b and c on both.
**Blind spot - obsoletes**: a package that *obsoletes* an installed one (`su-dummy-new` obsoleting `su-dummy-old`) is listed only by dnf4 `check-update` (in the trailing section, which the parser drops) and by **no** `repoquery --upgrades`/dnf5 listing, but `dnf upgrade` will install it and remove the old one:
```
$ dnf --assumeno upgrade # dnf4
Installing:
su-dummy-new noarch 1.0-1 su-dummy 6.5 k
replacing su-dummy-old.noarch 1.0-1
```
=> use the plan preview of section 7 (`su_plan`) when the user must see *everything* that will change.
### 2.2 ZYPPER: candidates compared
| | `zypper -n --xmlout list-updates` | `zypper -q -n list-updates` (table) | `... lu --all` |
|---|---|---|---|
| exit | 0 (also with updates; there is no 100) | 0 | 0 |
| noise | `<message>` tags (`Loading repository data...`, Leap: `Refreshing service &apos;openSUSE&apos;.`) | `-q` removes all non-table lines | |
| fields | name, edition (new), edition-old, arch, summary, description, `<source alias=...>` | `S \| Repository \| Name \| Current Version \| Available Version \| Arch` (repo = display *name*, e.g. `openSUSE-Tumbleweed-Update`) | same table, locked rows get status `vl` |
| locked packages | hidden | hidden | **shown with status `vl`** |
| vendor-change candidates | hidden | hidden | shown (`v`) |
| when nothing | empty `<update-list>` | prints nothing (even no header) | |
```
$ zypper -q -n list-updates # tumbleweed
S | Repository | Name | Current Version | Available Version | Arch
---+----------------------------+--------------------+-----------------+-------------------+-------
v | openSUSE-Tumbleweed-Update | openSUSE-build-key | 1.0-68.1 | 1.0-69.1 | x86_64
v | su-dummy | su-dummy-a | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-b | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-c | 1.0-1 | 2.0-1 | x86_64
$ zypper -n --xmlout list-updates # one <update> element per package; attributes + summary + description (candidate!) + source
<update-list>
<update kind="package" name="su-dummy-a" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-a (v2.0)</summary><description>Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
...</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" ...
```
Note the XML puts *all `<update>` elements on one physical line* and descriptions span several lines, `xmllint` does not exist on SUSE images, `python3` neither. Parse with `tr`+`sed`+bash (proven on Tumbleweed and Leap):
```bash
_su_xattr() { # _su_xattr '<tag text>' attr -> $REPLY (XML-unescaped); rc 1 if absent. ' edition="' never matches ' edition-old="'
local v=${1#* $2=\"}; if [ "$v" = "$1" ]; then REPLY=; return 1; fi
v=${v%%\"*}; v=${v//&lt;/<}; v=${v//&gt;/>}; v=${v//&quot;/\"}; v=${v//&apos;/\'}; v=${v//&amp;/&}; REPLY=$v
}
su_upgradable_zypper() {
local xml rc line tag name new old arch src alias
xml=$(zypper --non-interactive --xmlout list-updates); rc=$?; [ "$rc" -eq 0 ] || return "$rc"
# tr leaves no trailing newline and GNU sed keeps it that way: without `|| [ -n "$line" ]` the LAST package is silently dropped
printf '%s\n' "$xml" | tr '\n' ' ' | sed 's/<update /\n<update /g' | while IFS= read -r line || [ -n "$line" ]; do
case $line in '<update kind="package" '*) ;; *) continue ;; esac
tag=${line%%>*}
_su_xattr "$tag" name; name=$REPLY; _su_xattr "$tag" edition; new=$REPLY
_su_xattr "$tag" edition-old; old=$REPLY; _su_xattr "$tag" arch; arch=$REPLY
src=${line#*<source }; alias=; [ "$src" != "$line" ] && { _su_xattr "$src" alias && alias=$REPLY; }
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$old" "$new" "$alias" "$arch"
done | LC_ALL=C sort
}
```
Output (tumbleweed): `openSUSE-build-key^I1.0-68.1^I1.0-69.1^Irepo-update^Ix86_64$`, `su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$` ... The repo column is the stable **alias** (`repo-update`, Leap: `openSUSE:repo-oss`).
Table fallback (also proven; repo = display name, may contain spaces, e.g. `repo-oss (16.0)`):
```bash
trim() { local v=$1; v=${v#"${v%%[![:space:]]*}"}; v=${v%"${v##*[![:space:]]}"}; REPLY=$v; }
zypper -q -n list-updates | while IFS='|' read -r s repo name cur avail arch; do
case $s in v*) ;; *) continue ;; esac # data rows only (header/separator rows do not start with "v")
trim "$repo"; repo=$REPLY; trim "$name"; name=$REPLY; trim "$cur"; cur=$REPLY; trim "$avail"; avail=$REPLY; trim "$arch"; arch=$REPLY
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$cur" "$avail" "$repo" "$arch"
done
```
zypper already gives plain names (arch is a separate attribute/column).
**Locks** (see section 6): after `zypper addlock su-dummy-a su-dummy-b`, `lu` shows only `su-dummy-c`; `lu --all` shows
```
vl | su-dummy | su-dummy-a | 1.0-1 | 2.0-1 | noarch
vl | su-dummy | su-dummy-b | 1.0-1 | 2.0-1 | noarch
v | su-dummy | su-dummy-c | 1.0-1 | 2.0-1 | x86_64
```
**Vendor change**: package whose candidate has another vendor (`Vendor: FooCorp` installed, `BarCorp` in repo) is hidden by `lu`, shown by `lu --all`, and `zypper up` prints `There is an update candidate for 'su-dummy-v' from vendor 'BarCorp', while the current vendor is 'FooCorp'. Use 'zypper install su-dummy-v-2.0-1.noarch' to install this candidate.` followed by `Nothing to do.` (the update is skipped).
---------------------------------------------------------------------------------------------------------
## 3. One-line description and full info dump
Fixture packages carry `Summary: skeptical-updater dummy su-dummy-a (v1.0|v2.0)` and a two-paragraph description (blank line between paragraphs) so installed vs candidate and paragraph handling are distinguishable.
### 3.1 Installed version (all backends): rpm
```
$ rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' su-dummy-a
skeptical-updater dummy su-dummy-a (v1.0)
Dummy package su-dummy-a version 1.0 for skeptical-updater tests.
Second paragraph of su-dummy-a 1.0 (blank line above on purpose).
$ rpm -qi su-dummy-a # Name/Version/Release/Architecture/Install Date/Group/Size/License/Signature/Source RPM/Build Date/Build Host/Summary/Description/Distribution
```
### 3.2 Candidate (new) version
**DNF**: `dnf info` prints *both* an `Installed Packages` and an `Available Packages` block (dnf4 `Description : first line` + ` : continuation` lines; dnf5 `info` adds `Epoch`, `From repository : <unknown>`, `Vendor`). `dnf info --upgrades PKG` (dnf4 and dnf5) prints only the candidate block (`Available Upgrades` / `Available upgrades`), as does `dnf repoquery -i --upgrades PKG`. For machine use take `repoquery` with a queryformat (same on dnf4/dnf5):
```bash
dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' su-dummy-a
# -> line 1 summary, then the description with paragraph breaks intact; empty output => no candidate => fall back to rpm -q
```
dnf4 appends one extra blank line (harmless; `$(...)` strips it). Verified output (both dnf4 and dnf5): `skeptical-updater dummy su-dummy-a (v2.0)` / `Dummy package su-dummy-a version 2.0 ...` / blank / `Second paragraph of su-dummy-a 2.0 ...`.
Caveat: dnf4 `repoquery --upgrades` does not honour versionlock, so the "candidate" may be one the lock blocks; for the summary only this is irrelevant.
**ZYPPER**: `zypper info PKG` already shows the *best candidate* (and marks the installed state), falling back to the installed package when no repo has it:
```
$ zypper -q -n info su-dummy-a
Information for package su-dummy-a:
-----------------------------------
Repository : su-dummy
Name : su-dummy-a
Version : 2.0-1
Arch : noarch
Vendor :
Installed Size : 15 B
Installed : Yes
Status : out-of-date (version 1.0-1 installed)
Source package : su-dummy-a-2.0-1.src
Summary : skeptical-updater dummy su-dummy-a (v2.0)
Description :
Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
Second paragraph of su-dummy-a 2.0 (blank line above on purpose).
```
`zypper info nonexistentpkg` -> `package 'nonexistentpkg' not found.` **exit=104**. `--xmlout info` is useless (one big `<message>` blob). `-q` does not remove the `Information for package` banner. Description lines are indented 4 spaces; **paragraph breaks are real empty lines and the block also ends with an empty line** (and a second empty line before the next `Information for package` banner when several names are given), so a plain `/^Description/,/^$/` sed range truncates after the first paragraph. State machine (proven):
```bash
zypper --non-interactive --quiet info "$pkg" 2>/dev/null | {
indesc=0; pend=
while IFS= read -r l; do
if [ $indesc = 1 ]; then
case $l in ' '*) printf '%s%s\n' "$pend" "${l# }"; pend= ;; # text line: flush held blank lines first
'') pend+=$'\n' ;; # blank: paragraph break OR end of block - decide on next line
*) break ;; esac # anything else = next block
else
case $l in Summary*:*) [ -z "${sum:-}" ] && { sum=${l#*: }; printf '%s\n' "$sum"; } ;; Description*:*) indesc=1 ;; esac
fi
done; }
# if zypper info exits non-zero (104) fall back to: rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg"
```
The XML of `list-updates`/`update --dry-run` (sections 2.2 and 7) also carries the candidate `summary`+`description` for upgradable packages.
`su_describe PKG` in `lib-rpm.sh` wraps all of this; `proof.sh` asserts candidate summary `skeptical-updater dummy su-dummy-a (v2.0)`, the preserved blank line, and installed-only `su-dummy-d (v1.0)` on all 7 images.
---------------------------------------------------------------------------------------------------------
## 4. Installed reverse dependencies
Candidates and what they really do:
| command | result on EL9 for `bash` | verdict |
|---|---|---|
| `rpm -q --whatrequires bash` | **1** package (`ca-certificates`) - matches only a Requires entry literally named `bash` | misses file deps (`/bin/sh`, `/usr/bin/bash`), sonames, virtual provides |
| `dnf repoquery --installed --whatrequires bash` | **52** (dnf4 default = `--alldeps`: all provides *and files*; `--exactdeps` drops to 1) | correct, dnf only; works on dnf4 and dnf5 (fedora: 42) |
| `zypper search --installed-only -t package --requires bash` | = naive (name only); `--requires-pkg bash` uses all provides but over-reports on Tumbleweed (43 hits vs 4 direct: includes packages that depend via `/bin/sh` -> `bash-sh` -> `bash`) | not the reference |
| **pure rpm `su_rdeps`** (below) | EL9 52, EL10 40, Fedora 42, Tumbleweed 4 direct (69 recursive), Leap 5 direct (67 recursive) | **chosen**; identical to the dnf answer wherever dnf can answer |
Why name != capability: on Tumbleweed `/bin/sh` is provided by `bash-sh`, not `bash` (`rpm -q --whatprovides /bin/sh` -> `bash-sh`), so `aaa_base`, `binutils`, ... are *direct* dependents of `bash-sh` and only *transitive* dependents of `bash`; `su_rdeps_recursive bash` (69) is a superset of zypper's 43 (`comm -23` empty).
Algorithm: (1) the set of names the package is known by = `[%{PROVIDENAME}\n]` + `rpm -ql` (owned files, so file deps like `/bin/sh` match); (2) one table of every installed package's requirements `capability<TAB>package` from `rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]'` with `rpmlib(...)` dropped and rich deps expanded; (3) `join` the two on the capability, drop the package itself. **Mandatory trick `%{=NAME}`** (a bare `%{NAME}` inside `[...]` makes rpm print `error: incorrect format: array iterator used with different sized arrays` for roughly half of the packages; reproduced on rpm 4.16.1.3, 4.19.1.1, 4.20.1, 6.0.2 - and the same error appears for `[%{PROVIDENAME}\t%{NAME}\n]`).
```bash
su_req_table() { # "capability<TAB>requiring-package", sorted; rich deps expanded via su_cap_names (section 5)
local out=$1 cap name t all; all=$(mktemp)
rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' > "$all"
{ grep -v -e '^rpmlib(' -e '^(' "$all" || true
{ grep -e '^(' "$all" || true; } | while IFS=$'\t' read -r cap name; do
su_cap_names <<<"$cap" | while IFS= read -r t; do printf '%s\t%s\n' "$t" "$name"; done
done
} | LC_ALL=C sort -u > "$out"; rm -f "$all"
}
su_rdeps() { # direct installed dependents of PKG, plain names, sorted
local pkg=$1 tab caps own=0
if [ -n "${SU_REQ_TAB:-}" ] && [ -s "$SU_REQ_TAB" ]; then tab=$SU_REQ_TAB; else tab=$(mktemp); su_req_table "$tab"; own=1; fi
caps=$(mktemp)
{ rpm -q --qf '[%{PROVIDENAME}\n]' "$pkg"; rpm -ql "$pkg" | grep -v -e '^(contains no files)$'; } | LC_ALL=C sort -u > "$caps"
LC_ALL=C join -t $'\t' "$caps" "$tab" | cut -f2 | LC_ALL=C sort -u | grep -v -x -F -e "$pkg" || true
rm -f "$caps"; [ "$own" = 1 ] && rm -f "$tab"; return 0
}
```
`su_rdeps_recursive PKG...` (also in the lib) loops `su_rdeps` with one shared table. Proof assertions: `su_rdeps su-dummy-b` = `su-dummy-a su-dummy-d`; `su_rdeps su-dummy-c` = `su-dummy-d` (c is only required through the rich dep `(su-dummy-c if su-dummy-b)`); `su_rdeps su-dummy-d` = empty; `su_rdeps(bash)` equals `dnf repoquery --installed --whatrequires bash` (EL9: 52 = 52, EL10: 40 = 40, Fedora/dnf5: 42 = 42). Cost: ~60-130 ms on these minimal images.
Plain names: `%{NAME}` has no arch/version; multilib duplicates collapse through `sort -u`. `dnf repoquery ... --queryformat $'%{name}\n' | sed '/^$/d'` is the dnf-native equivalent (dnf4 blank lines).
---------------------------------------------------------------------------------------------------------
## 5. Recursive dependency closure restricted to installed packages
* `dnf repoquery --installed --requires --resolve --recursive --queryformat $'%{name}\n' PKG`: **works on dnf4** (alma9/alma10); **dnf5 rejects it**: `Unknown argument "--resolve" for command "repoquery". ... The argument is available for commands: download` (rc=2). Also dnf-only.
* **Chosen: pure-rpm breadth-first search**, identical on dnf and zypper distros. Per BFS level: `rpm -q --qf '[%{REQUIRENAME}\n]' <frontier>` -> filter -> `rpm -q --whatprovides --qf '%{NAME}\n' <caps>`.
Input handling (all verified):
| dependency kind | example | handling |
|---|---|---|
| `rpmlib(...)` | `rpmlib(CompressedFileNames)` | dropped (not a package; `--whatprovides` would just say "no package provides") |
| file deps | `/bin/sh`, `/usr/bin/perl` | looked up with `rpm -q --whatprovides /bin/sh` (file owner or explicit Provides: `bash` on EL, `bash-sh` on SUSE) |
| soname / virtual | `libc.so.6(GLIBC_2.34)(64bit)`, `perl(Foo::Bar)`, `config(bash)` | kept verbatim (REQUIRENAME already carries no version); contain parentheses but do not *start* with `(` |
| versioned | `su-dummy-b >= 1.0` | `%{REQUIRENAME}` returns only `su-dummy-b`; the constraint is ignored (installed state is already consistent) |
| rich / boolean | `(su-dummy-c if su-dummy-b)`, `(glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config)`, `(account-utils or shadow-pw-mgmt = 4.20.3)`, `(annobin if (gcc or clang))` | `su_cap_names` tokenises with paren-depth tracking: members kept, comparison operators and the version token after them dropped, `and/or/with/without` ignored, the **condition** after `if`/`unless` dropped (until `else` or the closing paren). Conditions are not evaluated -> safe superset |
| unresolvable | nothing installed provides it | `no package provides X` lines are filtered out (rpm prints them on **stdout**, exit 1) |
`su_cap_names` unit results (proven, EL9):
```
rpmlib(CompressedFileNames) =>
/bin/sh => /bin/sh
libc.so.6(GLIBC_2.34)(64bit) => libc.so.6(GLIBC_2.34)(64bit)
(su-dummy-c if su-dummy-b) => su-dummy-c
(glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config) => glibc-gconv-extra(x86-64)
(foo >= 1.0-2 or (bar and baz)) => foo bar baz
((a or b) if c) => a b
(a if b else c) => a c
(a unless (b and c) else d) => a d
(libx.so.1()(64bit) if foo) => libx.so.1()(64bit)
(annobin if (gcc or clang)) => annobin
(account-utils or shadow-pw-mgmt = 4.20.3) => account-utils shadow-pw-mgmt
```
Why the condition must be dropped: with the naive "all tokens" version, `glibc` on EL9 (rich dep `(glibc-gconv-extra ... if redhat-rpm-config)`) appeared to depend on `redhat-rpm-config`, which dragged `rpm`, `rpm-build`, `binutils`, `gdb-minimal` and the perl modules into the closure of `filesystem` (167 packages instead of ~15).
```bash
su_closure() { # su_closure PKG... -> installed packages PKG... need transitively (plain names, sorted, roots excluded)
local -A seen=(); local -a frontier=("$@") caps provs next out=(); local p
for p in "$@"; do seen[$p]=1; done
while [ "${#frontier[@]}" -gt 0 ]; do
mapfile -t caps < <(rpm -q --qf '[%{REQUIRENAME}\n]' "${frontier[@]}" 2>/dev/null | sort -u | su_cap_names | sort -u)
[ "${#caps[@]}" -gt 0 ] || break
mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" 2>/dev/null | grep -v -e '^no package provides ' | sort -u)
next=()
for p in "${provs[@]}"; do [ -n "$p" ] && [ -z "${seen[$p]:-}" ] && { seen[$p]=1; next+=("$p"); out+=("$p"); }; done
frontier=("${next[@]}")
done
[ "${#out[@]}" -gt 0 ] && printf '%s\n' "${out[@]}" | sort -u; return 0
}
```
Proof (`proof.sh`, every image): `su_closure su-dummy-a` = `su-dummy-b`; `su_closure su-dummy-d` contains `su-dummy-a su-dummy-b su-dummy-c glibc` and the provider of `/bin/sh` (`bash` on EL, `bash-sh` on SUSE), no `rpmlib`, not the root. On dnf4 the result **equals** `dnf repoquery --installed --requires --resolve --recursive`: alma9 18 = 18, alma10 17 = 17 (dnf lists `bash` in its own closure because of the `filesystem` <-> `bash` cycle; ours excludes roots by design). Sample (alma10): `almalinux-gpg-keys almalinux-release almalinux-repos basesystem bash filesystem glibc glibc-common glibc-gconv-extra glibc-minimal-langpack libgcc ncurses-base ncurses-libs setup su-dummy-a su-dummy-b su-dummy-c tzdata`; Tumbleweed `closure(su-dummy-d)` = `bash bash-sh filesystem glibc libncurses6 libreadline8 su-dummy-a su-dummy-b su-dummy-c system-user-root terminfo-base`. Timing: 70-130 ms.
---------------------------------------------------------------------------------------------------------
## 6. Per-run exclusion
### 6.1 DNF (`--exclude`, verified on dnf4 4.14/4.20 and dnf5 5.4.3)
| form | dnf4 | dnf5 |
|---|---|---|
| `dnf -y upgrade --exclude=su-dummy-a,su-dummy-b` (comma list) | ok | ok (documented as `-x package,..., --exclude=package,...`, global option) |
| `--exclude=a --exclude=b` (repeated) | ok | ok |
| `-x a -x b` | ok | ok |
| globs `--exclude='su-dummy-[ab]'` | ok | ok |
| space separated in one value `--exclude='a b'` | ok (repoquery) | ok (repoquery) |
| works on `check-update`, `repoquery`, `upgrade` | yes | yes |
| `--disableexcludes=all` to see them again | yes | **not available** |
Result of `dnf -y upgrade --exclude=su-dummy-a,su-dummy-b 'su-dummy-*'` (dnf4 and dnf5): `su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0` (both syntaxes). Excluded packages disappear from `check-update` too (only `su-dummy-c` left).
Edge cases (verified):
* all packages matched by an explicit pattern are excluded: dnf4 `No match for argument: su-dummy-*` / `Error: No packages marked for upgrade.` **exit 1**; dnf5 `Argument 'su-dummy-*' matches only excluded packages.` **exit 1**. With *no* package arguments and nothing to do both print `Nothing to do.` **exit 0**.
* **an excluded dependency blocks the upgrade of its dependant** (`su-dummy-p` 2.0 `Requires: su-dummy-q >= 2.0`, `--exclude=su-dummy-q`):
* dnf4 (EL default `best = 1`, `skip_if_unavailable = 0` in the dnf4 config dump): `Error: Problem: package su-dummy-p-2.0-1.noarch from su-dummy requires su-dummy-q >= 2.0, but none of the providers can be installed ... package su-dummy-q-2.0-1.noarch from su-dummy is filtered out by exclude filtering` **exit 1**; `--skip-broken` does *not* help (same error); `--nobest` -> `Skip 1 Package ... Nothing to do.` exit 0.
* dnf5: `Skipping packages with broken dependencies: su-dummy-p noarch 2.0-1 su-dummy` then `Nothing to do.` **exit 0** (no flag needed; `--skip-broken` is rejected on `upgrade`: `Unknown argument "--skip-broken" for command "upgrade"`).
=> exit codes cannot tell "everything upgraded" from "some skipped because of the exclusion": re-run `su_upgradable` afterwards (the proof does) and report what is left.
### 6.2 ZYPPER (no `--exclude`)
Findings that drive the recipe:
* `zypper update/dup` have no exclude option. Locks (`zypper addlock NAME...`, glob via `*`/`?`) are honoured by the solver: a **full** `zypper update` (no package args) silently skips locked packages; a lock on `a`,`b` -> only `c` is upgraded.
* An **explicit** `zypper update LOCKED` or an update whose dependency is locked ends in an interactive "Problem" which `--non-interactive` answers with *cancel*: **exit 4**:
```
Problem: 1: Detailed information: - conflicting requests
Solution 1: Following actions will be done: remove lock to allow installation of su-dummy-a-2.0-1.noarch[su-dummy] ...
Solution 2: do not ask to install a solvable providing su-dummy-a.noarch = 2.0-1
Choose from above solutions by number or skip, retry or cancel [1/2/s/r/c/d/?] (c): c exit=4
```
while a full update with a locked dependency of a newer package just skips both: `The following 2 package updates will NOT be installed: su-dummy-p su-dummy-q` (exit 0), `p`/`q` stay 1.0.
* Naming the packages explicitly instead (`zypper update a b c`) is **not** an exclusion mechanism: `zypper update su-dummy-p` (p 2.0 needs q >= 2.0) also upgraded `su-dummy-q` (dependency pull) - verified.
* Lock file `/etc/zypp/locks` (path = `locksfile.path` in zypp.conf; created on first `addlock`; on Tumbleweed `/etc/zypp/zypp.conf` does not exist at all, only `/usr/etc/zypp/zypp.conf`):
```
$ cat -A /etc/zypp/locks
$
type: package$
match_type: glob$
case_sensitive: on$
solvable_name: su-dummy-a$
$
```
`zypper locks` table: `# | Name | Type | Repository | Comment`; `zypper -q ll` prints the same.
* `zypper removelock NAME` is **not** a safe undo: it deletes *every* lock that matches NAME (a pre-existing user lock of the same name is destroyed - verified), and it leaves a 0-byte `/etc/zypp/locks` behind even when no file existed before (verified: `-rw-r--r-- 1 root root 0`).
#### Recipe A (chosen): private zypp.conf, nothing persisted - `su_zypper_excluding`
libzypp reads `$ZYPP_CONF` instead of `/etc/zypp/zypp.conf`. Copy the current config (fall back to `/usr/etc/zypp/zypp.conf` on Tumbleweed), append `[main] locksfile.path = <tmp>/locks`, seed `<tmp>/locks` with a copy of the real locks, `addlock` the exclusions *there*, run, `rm -rf` the temp dir. The real lock set is never opened for writing, so there is nothing to restore (Ctrl-C safe, concurrent-run safe).
```bash
su_zypper_excluding() { # su_zypper_excluding NAME... -- zypper-args... e.g. ... a b -- update | ... a -- --xmlout dup --dry-run
local -a ex=(); local d src locks rc
while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
d=$(mktemp -d) || return 1
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
cp "$src" "$d/zypp.conf" || { rm -rf "$d"; return 1; }
if [ -e "$locks" ]; then cp "$locks" "$d/locks"; else : > "$d/locks"; fi
printf '\n[main]\nlocksfile.path = %s\n' "$d/locks" >> "$d/zypp.conf" # second [main] block: last assignment wins (verified)
if [ "${#ex[@]}" -gt 0 ]; then
ZYPP_CONF=$d/zypp.conf zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || { rm -rf "$d"; return 1; }
fi
ZYPP_CONF=$d/zypp.conf zypper --non-interactive "$@"; rc=$?
rm -rf "$d"; return $rc
}
```
Proof (Tumbleweed `dup` and Leap `update`, `proof.sh` section 6): `su_upgrade --exclude su-dummy-a --exclude su-dummy-b` -> exit 0, `su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0`; `/etc/zypp/locks` unchanged (absent before, absent after); no leftover `/tmp/tmp.*`; `su_upgradable` still lists a and b afterwards; the following unrestricted `su_upgrade` upgrades them (`a=2.0 b=2.0 c=2.0`).
#### Recipe B (alternative with exact restore of the real locks file) - `su_zypper_excluding_snapshot`
Use this only if the real locks must be the ones libzypp sees (e.g. another tool reads `/etc/zypp/locks` concurrently - note it is then visible to that tool for the duration).
```bash
su_zypper_excluding_snapshot() ( # subshell => traps are local
ex=(); while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
bak=$(mktemp) || exit 1; had=0; restored=0
if [ -e "$locks" ]; then cp -p -- "$locks" "$bak" || exit 1; had=1; fi # snapshot: bytes + mode + mtime, or "absent"
_restore() { [ "$restored" = 1 ] && return 0; restored=1
if [ "$had" = 1 ]; then cp -p -- "$bak" "$locks"; else rm -f -- "$locks"; fi; rm -f -- "$bak"; }
trap '_restore' EXIT; trap '_restore; exit 130' INT; trap '_restore; exit 143' TERM HUP
if [ "${#ex[@]}" -gt 0 ]; then zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || exit 1; fi
zypper --non-interactive "$@"
)
```
Proof (`proof.sh` section 10, Tumbleweed; Leap likewise): with a pre-existing user lock on `su-dummy-d` (comment "user lock"): `cksum`+mtime identical after a run (`834034310 97 1791570407` before and after); user lock still present, temporary locks gone; a user lock on the *same* name as an exclusion survives (`zypper removelock` would have deleted it - asserted); file absent before => absent after; run killed with SIGTERM mid-way (shadowed `zypper` that kills its parent subshell) -> exit 143 and locks restored byte-identical.
#### Recipe C (rejected): explicit package list
`zypper update <list of everything except X>`: dependencies pull X anyway (shown above) and any name that is already newest prints `No update candidate for ...`. Rejected.
---------------------------------------------------------------------------------------------------------
## 7. Non-interactive upgrade, exit codes, plan preview
### 7.1 DNF
* `dnf -y upgrade [--exclude=a,b] [--setopt=skip_if_unavailable=0]`
* Exit codes (4 and 5): **0** success *and* "Nothing to do." (also `Dependencies resolved. Nothing to do. Complete!` on dnf4); **1** any error (unknown package argument `No match for argument: X` + `Error: No packages marked for upgrade.`, dependency problem, dead repo with `skip_if_unavailable=0`); **100 exists only for `check-update`**; `--assumeno` (dry run) ends with **exit 1** (`Operation aborted.` / `Operation aborted by the user.`) after printing the transaction table.
* `-q` removes the progress chatter but dnf5 still prints `Nothing to do.`; dnf5 prints `Warning: skipped OpenPGP checks for 3 packages from repository: su-dummy` for gpgcheck=0 repos.
* On the stale `rockylinux:9` (9.3) the first `-y` transaction imports the Rocky 9 GPG key automatically (`Importing GPG key 0x350D275D: ... From : /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9`) - harmless with `-y`, but a non-`-y` path would prompt.
* There is no machine-readable transaction output in dnf4. `dnf5 check-upgrade --json` exists (dnf5 only), not used.
### 7.2 ZYPPER
Detection (verified): `/etc/os-release` `ID=opensuse-tumbleweed` (Tumbleweed) -> **`dup`**; `ID=opensuse-leap` (Leap 16.0) -> **`update`**; `ID_LIKE` is `opensuse suse` / `suse opensuse` on both, so test `ID`, not `ID_LIKE`. (`opensuse-slowroll` is rolling too and treated like Tumbleweed in the lib - not tested; MicroOS/Aeon/Leap Micro are transactional and out of scope.)
```bash
su_zypper_verb() { case "$(su_os_id)" in opensuse-tumbleweed|opensuse-slowroll) echo dup ;; *) echo update ;; esac; }
zypper --non-interactive dup # prints: Warning: You are about to do a distribution upgrade with all enabled repositories. ... Computing distribution upgrade...
zypper --non-interactive update
```
Observed exit codes:
| situation | exit |
|---|---|
| success / "Nothing to do." (`update` and `dup`; `No update candidate for 'su-dummy-a-2.0-1.noarch'. The highest available version is already installed.` for explicit names) | **0** |
| `list-updates` with or without updates; `patch-check` with 0 patches | 0 |
| repo cannot be refreshed (`Could not refresh the repositories because of errors.`) | **4** |
| unknown repository name | 3 |
| unresolved Problem answered by `--non-interactive` (default = cancel): explicit update of a locked package; dup with a vendor/"distupgrade repository" problem (`the installed su-dummy-v-1.0-1.noarch does not belong to a distupgrade repository and must be replaced` / Solution 1 install from vendor BarCorp / Solution 2 keep obsolete) | **4** |
| `info`/`remove` of a name nothing provides (`No provider of 'nonexistent-xx' found.`) | **104** |
| `update --dry-run` / `dup --dry-run` (even with `Continue? [y/n/...] (y): y` echoed) | 0 (dry run, nothing changed) |
| update of a package listed in `/etc/zypp/needreboot(.d)` | **0**, plus the text `Since the last system boot core libraries or services have been updated. Reboot is suggested to ensure that your system benefits from these updates.` |
Not reproduced here (documented zypper(8) semantics, no fixture can trigger them without real SUSE patches): `100`/`101` (`patch-check`: updates / security updates needed), `102` (reboot needed after a patch with the reboot flag), `103` (zypper itself/libzypp was updated, restart zypper), `106` (some repos skipped), `107` (an rpm scriptlet failed). The tool should treat any non-zero exit as failure to be shown, `0` as success, and treat 100-103 as "success with advice" if it ever sees them.
`dup` vs `update` on Tumbleweed: in this snapshot `update --dry-run` and `dup --dry-run` computed the same plan (4 upgrades `openSUSE-build-key su-dummy-a su-dummy-b su-dummy-c`, 11 new packages `krb5 libbrotlicommon1 ... libverto1`, 1 removal `libcurl-mini4` - i.e. a package *swap* that `list-updates` never shows). The documented difference shows with vendor changes: `list-updates` hides the candidate, `update` skips it with a message, `dup` raises the Problem above (exit 4 under `-n`) - so Tumbleweed must use `dup`, and a skeptical tool must expect exit 4 there and show the Problem text (re-run without `-n`... or `--dry-run` text) instead of retrying.
### 7.3 Plan preview (what would REALLY change: new deps, removals, obsoletes)
`list-updates`/`check-update` never show newly installed dependencies, removals or obsolete-replacements. `su_plan [--exclude N]...` (in `lib-rpm.sh`) prints `section<TAB>name<TAB>old<TAB>new<TAB>repo<TAB>arch<TAB>replaces`, honouring exclusions:
* zypper: `zypper --non-interactive --xmlout dup|update --dry-run` yields exact data (no table parsing):
```
<install-summary download-size="19047" space-usage-diff="0" ... packages-to-change="3" need-restart="0" need-reboot="0">
<to-upgrade>
<solvable type="package" name="su-dummy-a" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="..."><description>...</description></solvable>
...</to-upgrade><to-install>...</to-install><to-remove>...</to-remove></install-summary>
```
(the `<prompt>` and `<progress>` elements that follow are ignored; `need-reboot`/`need-restart` attributes are available).
* dnf: `dnf --assumeno upgrade` table parse - section headers are col-0 lines ending in `:` (`Upgrading:`, `Installing:`, `Installing dependencies:`, `Installing weak dependencies:`, `Removing:`, `Removing unused dependencies:`, `Skipping packages with broken dependencies:` ...), rows start with one space, parsing stops at `Transaction Summary`. dnf4 prints no `replacing` line for plain upgrades (old version then comes from `rpm -q`), dnf5 prints `replacing` rows with `0:`-prefixed epochs.
Proof section 9 (builds `su-dummy-new` with `Obsoletes: su-dummy-old < 2` + `Provides: su-dummy-old = 2`): with `--exclude su-dummy-a`:
```
zypper (tumbleweed) dnf4 (alma9) dnf5 (fedora)
upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch -
upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 -
install su-dummy-new - 1.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old
remove su-dummy-old - 1.0-1 @System noarch -
```
and `su_upgradable` shows 0 rows for `su-dummy-new` on all of them (the blind spot).
---------------------------------------------------------------------------------------------------------
## 8. Remove / purge
### DNF (dnf4 and dnf5 identical in effect)
* `clean_requirements_on_remove` default **1** on both (dnf4: `dnf config-manager --dump` -> `clean_requirements_on_remove = 1`; dnf5 behaves the same: its plan shows `Removing unused dependencies: su-dummy-s noarch 0:1.0-1 su-dummy`).
```
$ dnf -y install su-dummy-r # r Requires s; both installed by dnf
$ dnf -y remove su-dummy-r # dnf4: "Erasing: su-dummy-r-1.0-1.noarch ... Erasing: su-dummy-s-1.0-1.noarch" exit=0 (both removed)
$ dnf -y remove --setopt=clean_requirements_on_remove=0 su-dummy-r # only r removed; s stays
$ dnf -y autoremove # then removes the leftover s
```
* Only dependencies **installed by dnf as dependencies** are cleaned. `su-dummy-b` (installed by plain `rpm -Uvh`) stays after `dnf -y remove su-dummy-a` on both majors.
* `dnf remove nonexistent-xx` exits **0** on dnf4 (`Nothing to do.`) and dnf5 (`No packages to remove for argument: nonexistent-xx` / `Nothing to do.`) -> confirm with `rpm -q` afterwards.
* dnf4 refuses protected packages: `dnf -y remove dnf` -> `Error: Problem: The operation would result in broken dependencies for the following protected packages: yum ...` **exit 1**.
* There is no `apt purge` equivalent in the rpm world; `%config` handling on removal (`*.rpmsave`) was not exercised here.
### ZYPPER
* `zypper --non-interactive remove --clean-deps PKG` (`-u`). Plain `remove` leaves the dependency: after `zypper -n install su-dummy-r` (pulls `su-dummy-s`), `zypper -n remove su-dummy-r` -> only r removed, `rpm -q su-dummy-s` still present; with `--clean-deps` -> `2 packages to remove.` `(1/2) Removing: su-dummy-r ... (2/2) Removing: su-dummy-s`, exit 0.
* Which deps are "unneeded" is tracked by zypper in `/var/lib/zypp/AutoInstalled`; packages installed with plain `rpm` (the fixture's `su-dummy-b`) are never cleaned: `remove --clean-deps su-dummy-a` left `su-dummy-b` installed.
* Unknown name: `'nonexistent-xx' not found in package names. Trying capabilities.` ... `No provider of 'nonexistent-xx' found.` / `Nothing to do.` **exit 104**.
* `proof.sh` section 7 asserts all of the above through `su_remove` on every image.
---------------------------------------------------------------------------------------------------------
## 9. Base image facts
See section 0.4 for the table (bash versions, sudo/su/python3/awk/find/xmllint presence), bats (EPEL on EL9/EL10; main repos on openSUSE) and `rpm-build`/`createrepo_c` availability. Short version: bash is present everywhere (5.1-5.3), `sudo` nowhere, `python3` only on EL, `awk`/`find`/`xargs` missing on Tumbleweed, `find`/`xargs` missing on Leap, `su` missing on alma10; `bats` needs EPEL on Alma/Rocky; `rpm-build` and `createrepo_c` install from the base repos everywhere.
---------------------------------------------------------------------------------------------------------
## 10. E2E fixture - `discovery/rpm/fixture.sh`
```
docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh # SU_FIXTURE_EXTRAS=1 for the extras
```
What it does (single script, detects `zypper` first, then `dnf`/`yum`; refuses to run as non-root):
1. installs `rpm-build` + `createrepo_c` (dnf: `dnf -y -q install --setopt=install_weak_deps=False ...`; zypper: `zypper --non-interactive --quiet install --no-recommends ...`) - no EPEL/extra repo needed anywhere;
2. generates minimal specs and builds with `rpmbuild -bb --define "_topdir /var/tmp/su-fixture"`: `su-dummy-a` (noarch, `Requires: su-dummy-b`), `su-dummy-b` (noarch), `su-dummy-c` (**arch-specific**, `%global debug_package %{nil}`, so the `.x86_64` suffix path is exercised), versions 1.0-1 and 2.0-1, one file each, `Summary: skeptical-updater dummy <name> (v<ver>)`, two-paragraph `%description`;
3. installs the three 1.0 packages with `rpm -Uvh --oldpackage --replacepkgs` (distro-agnostic; re-running the script resets to the 1.0 state);
4. publishes only the 2.0 packages in `/var/lib/su-dummy-repo` and runs `createrepo_c`;
5. registers the repo: dnf `/etc/yum.repos.d/su-dummy.repo` (`baseurl=file:///var/lib/su-dummy-repo`, `gpgcheck=0`, `repo_gpgcheck=0`, `metadata_expire=0`) + `dnf -q makecache --repo su-dummy`; zypper `zypper addrepo --no-gpgcheck --no-check --refresh file:///var/lib/su-dummy-repo su-dummy` + `zypper refresh su-dummy`;
6. verifies that `check-update` / `list-updates` lists `su-dummy-a`, `-b`, `-c`, else exits 1.
`SU_FIXTURE_EXTRAS=1` additionally installs `su-dummy-d` 1.0 (not upgradable; `Requires: su-dummy-a`, `/bin/sh`, `su-dummy-b >= 1.0`, `(su-dummy-c if su-dummy-b)`, `rpmlib(CompressedFileNames)`) for rdeps/closure tests and publishes (not installs) `su-dummy-r` (Requires `su-dummy-s`) / `su-dummy-s` for remove tests.
Side effect worth knowing: installing `rpm-build` pulls `redhat-rpm-config` etc. on EL, which flips the condition of rich dependencies such as glibc's `(glibc-gconv-extra if redhat-rpm-config)`; the closure code must therefore not treat conditions as dependencies (it does not).
Real output (`fixture.sh` standalone, each run exit 0):
```
[fixture] backend=dnf # almalinux:9
[fixture] build tools installed in 14s
[fixture] rpmbuild done in 0s
[fixture] repo refresh in 0s
[fixture] OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total 15s, since start 15s)
```
and the same `OK` line (totals 8-16 s) on almalinux:10, rockylinux:9, rockylinux/rockylinux:10, quay.io/rockylinux/rockylinux:10, opensuse/tumbleweed (`backend=zypper`, 13 s), opensuse/leap (`backend=zypper`, 14 s), and fedora:latest (dnf5, 16 s). Also verified: a second run on the same container is a clean reset (`proof.sh` section 8).
`proof.sh` results (final run, all rc=0): almalinux:9 44 PASS, almalinux:10 44, rockylinux:9 44, rockylinux/rockylinux:10 44, opensuse/tumbleweed 53, opensuse/leap 53, fedora:latest 43 (dnf5). (zypper images run two extra sections: lock snapshot/restore.) One transient failure was seen and is not a code issue: the first `rockylinux:9` run failed section 1 (`su_refresh` exit 1: Rocky `extras` mirror returned a broken `repomd.xml`); re-run passed (the refresh gate did exactly what it is for).
---------------------------------------------------------------------------------------------------------
## 11. Rough timings (host with fast network, Docker 29.8.2; wall clock, one run each, sequential)
| image | `docker pull` | cold metadata refresh (first `dnf makecache` / `zypper refresh` in the container) | warm refresh | `fixture.sh` wall (of which tool install) | `proof.sh` wall |
|---|---|---|---|---|---|
| almalinux:9 | 4.7 s | 9.5 s | 1.6 s | 15.1 s (14 s) | 29.5 s |
| almalinux:10 | 4.5 s | 5.9 s | 1.9 s | 11.5 s (9 s) | 25.5 s |
| rockylinux:9 (9.3 lib image, 96 pending upgrades) | 6.1 s | 8.4 s | 1.1 s | 16.6 s (15 s) | 29.8 s |
| rockylinux/rockylinux:10 | 5.9 s | 3.3 s | 1.6 s | 10.5 s (8 s) | 21.0 s |
| quay.io/rockylinux/rockylinux:10 | 1.0 s (layers cached) | 4.2 s | - | 8.1 s (7 s) | not run (same image) |
| opensuse/tumbleweed | 3.4 s | 5.9 s | 0.9 s | 14.3 s (12 s) | 32.7 s |
| opensuse/leap (16.0) | 3.5 s | 6.3 s | 0.6 s | 14.4 s (12 s) | 34.3 s |
| fedora:latest (dnf5, reference) | n/a | 5.3 s | 0.3 s | 16.0 s (15 s) | 29.2 s |
Inside `proof.sh` (warm containers): forced refresh (`su_refresh`) 0.5-2.6 s (up to 7 s on alma when the mirror is slow), `su_upgradable` 0.3-0.8 s, `su_closure` 0.07-0.13 s, `su_rdeps` ~0.06-0.13 s, dummy upgrade 0.1-0.3 s (dnf) / 1.6-7 s (zypper, because a full `update`/`dup` also upgrades real packages). "Warm refresh" for dnf is a plain `dnf makecache` without `--refresh` (it still revalidates); `--refresh` forces a full download and is the number to budget for a skeptical run (3-9 s cold).
---------------------------------------------------------------------------------------------------------
## 12. Gotcha checklist for the implementer
1. Never assume dnf5 on EL10; support dnf4 + dnf5 through one parser (`check-update`, 3 fields, tolerate blank line, `Upgrades` header, `Obsoleting Packages` section).
2. Always `--setopt=skip_if_unavailable=0` (dnf5 otherwise hides dead repos, `-q` hides the warning) and gate on the refresh exit status; do not trust `check-update` alone on dnf 4.14.
3. dnf `repoquery --queryformat`: pass real TAB/newline (`$'%{name}\t%{evr}\n'`), then `sed '/^$/d'` (dnf4 adds blank lines, dnf5 expands only `\n`, `\t` stays literal). `--qf` and `--queryformat` both work on both majors.
4. dnf4 `repoquery --upgrades` and `dnf list --upgrades` ignore versionlock; `check-update` does not. dnf5 has no `--disableexcludes`.
5. `check-update`/`list-updates` hide obsoleters, new dependencies and removals. Use `su_plan` when the user must see the real transaction; compare list vs result afterwards because exit codes do not reveal skipped packages (dnf5 skips broken-dependency packages with exit 0; dnf4 fails with exit 1 unless `--nobest`).
6. `rpm --qf '[%{=NAME}...]'`, never `%{NAME}` inside `[...]`.
7. `rpm -q --whatrequires pkg` is name-only; use provides + owned files (`su_rdeps`). Rich deps: members only, conditions dropped.
8. zypper XML: all `<update>` elements are on one line, descriptions multi-line, no xmllint/python on the images, trailing-newline trap with `tr`+`sed`+`read`.
9. zypper `list-updates` exits 0 even with updates; never look for 100. Locked and vendor-change candidates are hidden (`lu --all` shows them).
10. zypper has no `--exclude`: use `ZYPP_CONF` private locks (or snapshot/restore of the real locks file; `removelock` is not an undo). Tumbleweed has no `/etc/zypp/zypp.conf`, only `/usr/etc/zypp/zypp.conf`.
11. Tumbleweed -> `dup`, Leap -> `update`, decided from `ID=` in `/etc/os-release`; `dup`/`update` under `--non-interactive` turn unresolved Problems into exit 4 (cancel) - show the Problem, do not blindly retry.
12. Base images lack `awk`, `find`, `xargs`, `python3`, `xmllint`, `sudo`, `hostname`, `diff`; `rockylinux:9` is a stale 9.3 image (use `rockylinux/rockylinux:9`); `rockylinux:10` does not exist.
13. `dnf remove` of a non-existent package exits 0; zypper exits 104. Verify with `rpm -q`.
14. Packages installed with plain `rpm -i` are never auto-removed by `dnf remove` / `zypper remove --clean-deps`.
## 13. Not verified / limits
* dnf5 was verified on `fedora:latest` (Fedora 44, dnf5 5.4.3.0 - an image outside the requested list), not on an EL10 userland (no dnf5 package in Alma 10; Rocky 10 has `dnf5 5.1.12` only in the disabled `devel` repo and was not exercised).
* 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`.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fixture.sh - deterministic E2E fixture for RPM distros (dnf: Alma/Rocky/..., zypper: openSUSE)
#
# Builds three dummy packages with rpmbuild, installs version 1.0 of each, publishes
# version 2.0 in a local file:// repo and registers that repo, so that the package
# manager reports exactly these upgrades:
#
# su-dummy-a 1.0-1 -> 2.0-1 (noarch, Requires: su-dummy-b)
# su-dummy-b 1.0-1 -> 2.0-1 (noarch)
# su-dummy-c 1.0-1 -> 2.0-1 (ARCH-SPECIFIC, so the ".x86_64" suffix path is exercised)
#
# Optional (SU_FIXTURE_EXTRAS=1):
# - installs su-dummy-d 1.0 (NOT upgradable): Requires su-dummy-a, /bin/sh (file dep),
# "su-dummy-b >= 1.0" (versioned), "(su-dummy-c if su-dummy-b)" (rich dep) and rpmlib(...)
# -> for reverse-dep / dependency-closure tests;
# - publishes su-dummy-r 1.0 (Requires su-dummy-s) and su-dummy-s 1.0 in the repo, NOT installed
# -> for install/remove/--clean-deps tests.
#
# Re-runnable: re-running resets everything to the "1.0 installed, 2.0 available" state.
#
# Usage (inside the container, as root): bash fixture.sh
# docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh
set -euo pipefail
export LC_ALL=C
T0=$SECONDS
log() { printf '[fixture] %s\n' "$*" >&2; }
die() { printf '[fixture] ERROR: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "must run as root"
# ---- 1. backend detection ---------------------------------------------------
BACKEND=
if command -v zypper >/dev/null 2>&1; then BACKEND=zypper
elif command -v dnf >/dev/null 2>&1; then BACKEND=dnf
elif command -v yum >/dev/null 2>&1; then BACKEND=yum
else die "neither zypper nor dnf/yum found"; fi
log "backend=$BACKEND"
TOP=/var/tmp/su-fixture # rpmbuild topdir
REPO=/var/lib/su-dummy-repo # file:// repo (only 2.0 packages)
SPEC_DIR=$TOP/SPECS
# ---- 2. build tools ---------------------------------------------------------
t=$SECONDS
case $BACKEND in
zypper) zypper --non-interactive --quiet install --no-recommends rpm-build createrepo_c >/dev/null ;;
dnf|yum) "$BACKEND" -y -q install --setopt=install_weak_deps=False rpm-build createrepo_c >/dev/null ;;
esac
command -v rpmbuild >/dev/null || die "rpmbuild missing after install"
command -v createrepo_c >/dev/null || die "createrepo_c missing after install"
log "build tools installed in $((SECONDS - t))s"
# ---- 3. build RPMs ----------------------------------------------------------
rm -rf "$TOP" "$REPO"
mkdir -p "$TOP"/{BUILD,BUILDROOT,RPMS,SRPMS,SOURCES,SPECS} "$REPO"
# mkspec <name> <version> <arch|noarch> <extra spec header lines...>
mkspec() {
local name=$1 ver=$2 arch=$3; shift 3
local f=$SPEC_DIR/$name-$ver.spec
{
echo "Name: $name"
echo "Version: $ver"
echo "Release: 1"
echo "Summary: skeptical-updater dummy $name (v$ver)"
echo "License: MIT"
[ "$arch" = noarch ] && echo "BuildArch: noarch"
local l; for l in "$@"; do echo "$l"; done
echo "%global debug_package %{nil}"
echo "%description"
echo "Dummy package $name version $ver for skeptical-updater tests."
echo ""
echo "Second paragraph of $name $ver (blank line above on purpose)."
echo "%install"
echo "mkdir -p %{buildroot}/usr/share/su-dummy"
echo "echo '$name $ver' > %{buildroot}/usr/share/su-dummy/$name"
echo "%files"
echo "/usr/share/su-dummy/$name"
} > "$f"
printf '%s\n' "$f"
}
build() { rpmbuild -bb --quiet --define "_topdir $TOP" "$1" >/dev/null 2>"$TOP/rpmbuild.err" \
|| { cat "$TOP/rpmbuild.err" >&2; die "rpmbuild failed for $1"; }; }
t=$SECONDS
for v in 1.0 2.0; do
build "$(mkspec su-dummy-a $v noarch 'Requires: su-dummy-b')"
build "$(mkspec su-dummy-b $v noarch)"
build "$(mkspec su-dummy-c $v arch)"
done
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then
build "$(mkspec su-dummy-s 1.0 noarch)" # published in the repo, NOT installed
build "$(mkspec su-dummy-r 1.0 noarch 'Requires: su-dummy-s')" # (used to test remove/--clean-deps)
build "$(mkspec su-dummy-d 1.0 noarch \
'Requires: su-dummy-a' \
'Requires: /bin/sh' \
'Requires: su-dummy-b >= 1.0' \
'Requires: (su-dummy-c if su-dummy-b)' \
'Requires: rpmlib(CompressedFileNames)')"
fi
log "rpmbuild done in $((SECONDS - t))s"
# ---- 4. install 1.0 (rpm directly: distro-agnostic, resets to 1.0 on re-run) ----
shopt -s nullglob
v1=( "$TOP"/RPMS/*/su-dummy-[abc]-1.0-1.*.rpm )
[ "${#v1[@]}" -eq 3 ] || die "expected 3 built 1.0 rpms, got ${#v1[@]}"
rpm -Uvh --oldpackage --replacepkgs "${v1[@]}" >/dev/null
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then
rpm -Uvh --oldpackage --replacepkgs "$TOP"/RPMS/noarch/su-dummy-d-1.0-1.*.rpm >/dev/null
fi
# ---- 5. publish 2.0 as a local repo -------------------------------------------
v2=( "$TOP"/RPMS/*/su-dummy-[abc]-2.0-1.*.rpm )
[ "${#v2[@]}" -eq 3 ] || die "expected 3 built 2.0 rpms, got ${#v2[@]}"
cp "${v2[@]}" "$REPO"/
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then cp "$TOP"/RPMS/noarch/su-dummy-[rs]-1.0-1.*.rpm "$REPO"/; fi
createrepo_c --quiet "$REPO" >/dev/null
# ---- 6. register repo ---------------------------------------------------------
case $BACKEND in
zypper)
zypper --non-interactive --quiet removerepo su-dummy >/dev/null 2>&1 || true
zypper --non-interactive --quiet addrepo --no-gpgcheck --no-check --refresh "file://$REPO" su-dummy >/dev/null
t=$SECONDS
zypper --non-interactive --quiet refresh su-dummy >/dev/null
;;
dnf|yum)
cat > /etc/yum.repos.d/su-dummy.repo <<REPOEOF
[su-dummy]
name=skeptical-updater dummy packages
baseurl=file://$REPO
enabled=1
gpgcheck=0
repo_gpgcheck=0
metadata_expire=0
REPOEOF
t=$SECONDS
"$BACKEND" -q makecache --repo su-dummy >/dev/null
;;
esac
log "repo refresh in $((SECONDS - t))s"
# ---- 7. verify: the three must be listed as upgradable --------------------------
want="su-dummy-a su-dummy-b su-dummy-c"
case $BACKEND in
zypper) listing=$(zypper --non-interactive --quiet list-updates 2>&1 || true) ;;
dnf|yum) listing=$("$BACKEND" -q check-update 2>&1 || true) ;;
esac
bad=0
for p in $want; do
printf '%s\n' "$listing" | grep -Eq "(^|[| ])$p([. ]|$)" || { log "NOT listed as upgradable: $p"; bad=1; }
done
if [ "$bad" -ne 0 ]; then printf '%s\n' "$listing" >&2; die "fixture verification failed"; fi
log "OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total ${SECONDS}s, since start $((SECONDS - T0))s)"
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# lib-rpm.sh - proven building blocks for RPM distros: dnf4, dnf5 (yum alias) and zypper.
#
# SOURCE this file; nothing runs on source. Every function here was exercised by proof.sh on
# almalinux:9, almalinux:10, rockylinux:9, rockylinux/rockylinux:10, opensuse/tumbleweed,
# opensuse/leap (16.0) and (dnf5 reference) fedora:latest.
#
# Runtime needs only: bash >= 4.2, rpm, sed, grep, tr, sort (GNU, for -V), join, cut, mktemp.
# NOT assumed (missing on at least one target image): awk, find, xargs, python3, xmllint, sudo,
# hostname, diff, cmp, column.
#
# All upgradable-package lists use one record format (TAB separated):
# name <TAB> old-evr <TAB> new-evr <TAB> repo <TAB> arch
# (name is the PLAIN rpm name, arch is a separate 5th column; "old-evr" is "?" if unknown)
export LC_ALL=C
# ---------------------------------------------------------------- detection
su_backend() { # -> "zypper" | "dnf" (dnf covers dnf4, dnf5 and the yum alias on EL9+)
if command -v zypper >/dev/null 2>&1; then echo zypper
elif command -v dnf >/dev/null 2>&1; then echo dnf
else return 1; fi
}
su_os_id() { ( . /etc/os-release 2>/dev/null; printf '%s' "${ID:-}" ); }
su_dnf_major() { dnf --version 2>/dev/null | grep -q '^dnf5' && echo 5 || echo 4; }
# zypper verb for "upgrade everything": Tumbleweed (and Slowroll) are rolling -> dup; Leap/SLE -> update
su_zypper_verb() {
case "$(su_os_id)" in opensuse-tumbleweed|opensuse-slowroll) echo dup ;; *) echo update ;; esac
}
# ---------------------------------------------------------------- 1. refresh
# exit 0 = fresh metadata for ALL repos. dnf: 1 on any failure (we force skip_if_unavailable=0 because
# dnf5 otherwise silently ignores a dead repo and exits 0 - and `-q` hides even the warning).
# zypper: 4 = repo error, 3 = bad repo name, 6 = no repos defined.
su_refresh() {
case "$(su_backend)" in
dnf) dnf -q --refresh --setopt=skip_if_unavailable=0 makecache >/dev/null ;;
zypper) zypper --non-interactive --quiet refresh ;;
esac
}
# ---------------------------------------------------------------- 2. upgradable list
# installed evr table "name.arch<TAB>evr", highest version last (installonly packages have several)
_su_installed_evr() {
rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' \
| LC_ALL=C sort -t $'\t' -k1,1 -k2,2V
}
# dnf (4 and 5): authoritative source = `dnf -q check-update` (the only listing that honours excludes AND
# versionlock in both majors; repoquery/list do not honour dnf4 versionlock). exit 100 = updates, 0 = none.
su_upgradable_dnf() {
local out rc na ver repo rest name arch k evr
local -A old=() oldn=()
out=$(dnf -q check-update --setopt=skip_if_unavailable=0); rc=$?
case $rc in 0) return 0 ;; 100) ;; *) return "$rc" ;; esac
while IFS=$'\t' read -r k evr; do evr=${evr#0:}; old[$k]=$evr; oldn[${k%.*}]=$evr; done < <(_su_installed_evr)
while read -r na ver repo rest; do
[ "$na" = Obsoleting ] && break # dnf4 appends an "Obsoleting Packages" section
[ -n "$repo" ] && [ -z "$rest" ] || continue # skips blank line, dnf5 "Upgrades" header, etc.
name=${na%.*}; arch=${na##*.}
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo" "$arch"
done <<<"$out" | LC_ALL=C sort
}
# XML attribute helper: _su_xattr '<tag text>' attr -> $REPLY (unescaped); rc 1 if absent
_su_xattr() {
local v=${1#* $2=\"}
if [ "$v" = "$1" ]; then REPLY=; return 1; fi
v=${v%%\"*}
v=${v//&lt;/<}; v=${v//&gt;/>}; v=${v//&quot;/\"}; v=${v//&apos;/\'}; v=${v//&amp;/&}
REPLY=$v
}
# zypper: `zypper --xmlout list-updates` (locked packages are hidden; `lu --all` would show them as "vl").
# The XML is flattened with tr so each <update ...>...</update> becomes one line, then split with sed.
su_upgradable_zypper() {
local xml rc line tag name new old arch src alias
xml=$(zypper --non-interactive --xmlout list-updates); rc=$?
[ "$rc" -eq 0 ] || return "$rc"
# NB: after tr the stream has no trailing newline; GNU sed keeps it that way, so the LAST record would be
# silently dropped by a plain `while read` -> `|| [ -n "$line" ]` is required.
printf '%s\n' "$xml" | tr '\n' ' ' | sed 's/<update /\n<update /g' | while IFS= read -r line || [ -n "$line" ]; do
case $line in '<update kind="package" '*) ;; *) continue ;; esac
tag=${line%%>*}
_su_xattr "$tag" name; name=$REPLY
_su_xattr "$tag" edition; new=$REPLY
_su_xattr "$tag" edition-old; old=$REPLY
_su_xattr "$tag" arch; arch=$REPLY
src=${line#*<source }; alias=
[ "$src" != "$line" ] && { _su_xattr "$src" alias && alias=$REPLY; }
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$old" "$new" "$alias" "$arch"
done | LC_ALL=C sort
}
su_upgradable() {
case "$(su_backend)" in dnf) su_upgradable_dnf ;; zypper) su_upgradable_zypper ;; esac
}
# ---------------------------------------------------------------- 3. summary + description
# su_describe PKG : line 1 = Summary, remaining lines = Description (paragraph breaks kept).
# Candidate (upgrade) version if there is one, otherwise the installed one.
su_describe() {
local pkg=$1 out
case "$(su_backend)" in
dnf)
out=$(dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' "$pkg" 2>/dev/null)
[ -n "$out" ] || out=$(rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg" 2>/dev/null)
printf '%s\n' "$out" ;;
zypper)
out=$(zypper --non-interactive --quiet info "$pkg" 2>/dev/null) || { rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg"; return; }
printf '%s\n' "$out" | {
local l indesc=0 pend=
while IFS= read -r l; do
if [ $indesc = 1 ]; then # description lines are indented by 4 spaces;
case $l in # blank lines are real empty lines (paragraph break OR end of block)
' '*) printf '%s%s\n' "$pend" "${l# }"; pend= ;;
'') pend+=$'\n' ;;
*) break ;;
esac
else
case $l in
Summary*:*) [ -z "${sum:-}" ] && { sum=${l#*: }; printf '%s\n' "$sum"; } ;;
Description*:*) indesc=1 ;;
esac
fi
done
} ;;
esac
}
# ---------------------------------------------------------------- 4/5. installed dependency graph (pure rpm)
# su_cap_names: stdin = one REQUIRENAME per line; stdout = plain capability names to look up.
# - drops rpmlib(...) pseudo-deps
# - expands rich/boolean deps "(a if b)", "(a >= 1.0 or b)", "((a and b) unless c else d)" to their
# *members*; the CONDITION of "if"/"unless" is dropped (it is not a dependency). Conditions are not
# evaluated, so "(a if b)" yields "a" even if b is not installed = safe superset.
# - names like libc.so.6(GLIBC_2.34)(64bit), perl(Foo::Bar), /bin/sh, config(x) are kept verbatim
su_cap_names() {
local cap tok w depth cond skip o c n
set -f
while IFS= read -r cap; do
case $cap in
''|rpmlib\(*) ;;
\(*)
depth=0; cond=-1; skip=0
for tok in $cap; do
w=$tok; n=0
while [ "${w:0:1}" = '(' ]; do w=${w:1}; n=$((n+1)); done # structural opens (a cap never starts with '(')
depth=$((depth+n))
o=${w//[^(]/}; c=${w//[^)]/}; n=$(( ${#c} - ${#o} )) # structural closes = surplus ')' at the end
[ "$n" -lt 0 ] && n=0
[ "$n" -gt 0 ] && w=${w%"${w: -$n}"}
if [ "$skip" = 1 ]; then skip=0
else
case $w in
if|unless) cond=$depth ;;
else) [ "$cond" = "$depth" ] && cond=-1 ;;
and|or|with|without|'') ;;
'='|'<'|'>'|'<='|'>='|'==') skip=1 ;; # next token is a version
*) [ "$cond" -lt 0 ] && printf '%s\n' "$w" ;;
esac
fi
depth=$((depth-n))
[ "$cond" -ge 0 ] && [ "$depth" -lt "$cond" ] && cond=-1
done ;;
*) printf '%s\n' "$cap" ;;
esac
done
set +f
}
# su_closure PKG...: installed packages (plain names, sorted; the roots themselves are not listed) that PKG...
# need, transitively. Breadth-first; 2 rpm calls per level. Identical results to
# `dnf repoquery --installed --requires --resolve --recursive` on dnf4 (dnf5 has no such option).
su_closure() {
local -A seen=(); local -a frontier=("$@") caps provs next out=(); local p
for p in "$@"; do seen[$p]=1; done
while [ "${#frontier[@]}" -gt 0 ]; do
mapfile -t caps < <(rpm -q --qf '[%{REQUIRENAME}\n]' "${frontier[@]}" 2>/dev/null | sort -u | su_cap_names | sort -u)
[ "${#caps[@]}" -gt 0 ] || break
mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" 2>/dev/null | grep -v -e '^no package provides ' | sort -u)
next=()
for p in "${provs[@]}"; do
[ -n "$p" ] && [ -z "${seen[$p]:-}" ] && { seen[$p]=1; next+=("$p"); out+=("$p"); }
done
frontier=("${next[@]}")
done
[ "${#out[@]}" -gt 0 ] && printf '%s\n' "${out[@]}" | sort -u
return 0
}
# su_req_table FILE: "capability<TAB>requiring-package" for every installed package, sorted (LC_ALL=C).
# NB: inside [...] the scalar tag MUST be written %{=NAME}; plain %{NAME} makes rpm fail with
# "array iterator used with different sized arrays" for ~50% of packages (all rpm versions 4.16..6.0).
su_req_table() {
local out=$1 cap name t all
all=$(mktemp)
rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' > "$all"
{
grep -v -e '^rpmlib(' -e '^(' "$all" || true
{ grep -e '^(' "$all" || true; } | while IFS=$'\t' read -r cap name; do
su_cap_names <<<"$cap" | while IFS= read -r t; do printf '%s\t%s\n' "$t" "$name"; done
done
} | LC_ALL=C sort -u > "$out"
rm -f "$all"
}
# su_rdeps PKG: installed packages (plain names, sorted) that DIRECTLY require something PKG provides: its name,
# any Provides (sonames, virtual capabilities; versions ignored) or any file it owns (file deps like /bin/sh).
# Same result as `dnf repoquery --installed --whatrequires PKG` (verified); plain `rpm -q --whatrequires PKG`
# only matches the literal capability "PKG" and misses e.g. 51 of 52 dependents of bash on EL9.
# (Set SU_REQ_TAB=<file made by su_req_table> to reuse one table across many calls.)
su_rdeps() {
local pkg=$1 tab caps own=0
if [ -n "${SU_REQ_TAB:-}" ] && [ -s "$SU_REQ_TAB" ]; then tab=$SU_REQ_TAB; else tab=$(mktemp); su_req_table "$tab"; own=1; fi
caps=$(mktemp)
{ rpm -q --qf '[%{PROVIDENAME}\n]' "$pkg"; rpm -ql "$pkg" | grep -v -e '^(contains no files)$'; } | LC_ALL=C sort -u > "$caps"
LC_ALL=C join -t $'\t' "$caps" "$tab" | cut -f2 | LC_ALL=C sort -u | grep -v -x -F -e "$pkg" || true
rm -f "$caps"; [ "$own" = 1 ] && rm -f "$tab"
return 0
}
# su_rdeps_recursive PKG...: everything installed that (transitively) depends on PKG... (roots excluded).
# NB: "direct" dependents can be far fewer than you expect: on Tumbleweed `/bin/sh` is provided by bash-sh, not
# bash, so aaa_base/binutils/... are dependents of bash-sh (which requires bash), i.e. only in the recursive set.
su_rdeps_recursive() {
local -A seen=(); local -a frontier=("$@") next out=(); local p r SU_REQ_TAB
SU_REQ_TAB=$(mktemp); su_req_table "$SU_REQ_TAB"
for p in "$@"; do seen[$p]=1; done
while [ "${#frontier[@]}" -gt 0 ]; do
next=()
for p in "${frontier[@]}"; do
while IFS= read -r r; do
[ -n "$r" ] && [ -z "${seen[$r]:-}" ] && { seen[$r]=1; next+=("$r"); out+=("$r"); }
done < <(su_rdeps "$p")
done
frontier=("${next[@]}")
done
rm -f "$SU_REQ_TAB"
[ "${#out[@]}" -gt 0 ] && printf '%s\n' "${out[@]}" | sort -u
return 0
}
# ---------------------------------------------------------------- 6. upgrade with per-run exclusions
# su_zypper_excluding NAME... -- zypper-args...
# zypper has no --exclude. Per-run locks WITHOUT touching /etc/zypp/locks: point libzypp at a private
# zypp.conf (env ZYPP_CONF) whose locksfile.path is a temp copy of the current locks, add the exclusions there,
# run zypper, delete the temp dir. Nothing to restore, nothing persisted, safe on Ctrl-C.
su_zypper_excluding() {
local -a ex=(); local d src locks rc
while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done
[ "${1:-}" = -- ] && shift
d=$(mktemp -d) || return 1
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf # Tumbleweed ships only /usr/etc
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1)
: "${locks:=/etc/zypp/locks}"
cp "$src" "$d/zypp.conf" || { rm -rf "$d"; return 1; }
if [ -e "$locks" ]; then cp "$locks" "$d/locks"; else : > "$d/locks"; fi
printf '\n[main]\nlocksfile.path = %s\n' "$d/locks" >> "$d/zypp.conf"
if [ "${#ex[@]}" -gt 0 ]; then
ZYPP_CONF=$d/zypp.conf zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || { rm -rf "$d"; return 1; }
fi
ZYPP_CONF=$d/zypp.conf zypper --non-interactive "$@"; rc=$?
rm -rf "$d"
return $rc
}
# su_upgrade [--exclude NAME]... [-- extra-pm-args...] (non-interactive; exit status of the package manager)
# dnf : dnf -y upgrade --exclude=a,b (comma list works on dnf4 AND dnf5; repeated flags too)
# zypper : update (Leap/SLE) or dup (Tumbleweed) with temporary locks (see su_zypper_excluding)
su_upgrade() {
local -a ex=() extra=()
while [ $# -gt 0 ]; do
case $1 in
--exclude) ex+=("$2"); shift 2 ;;
--) shift; extra=("$@"); break ;;
*) echo "su_upgrade: bad arg $1" >&2; return 2 ;;
esac
done
case "$(su_backend)" in
dnf)
local ox=(); if [ "${#ex[@]}" -gt 0 ]; then local IFS=,; ox=("--exclude=${ex[*]}"); fi
dnf -y --setopt=skip_if_unavailable=0 upgrade "${ox[@]}" "${extra[@]}" ;;
zypper)
su_zypper_excluding "${ex[@]}" -- "$(su_zypper_verb)" "${extra[@]}" ;;
esac
}
# su_zypper_excluding_snapshot NAME... -- zypper-args...
# Alternative recipe (what you would do if ZYPP_CONF were unavailable): add real locks to /etc/zypp/locks
# and restore the EXACT previous file afterwards. `zypper removelock NAME` is NOT a safe undo: it deletes ALL
# locks matching NAME (including a pre-existing user lock) and leaves an empty /etc/zypp/locks behind even if the
# file did not exist before. Hence: byte copy (cp -p) of the file, or "file absent" flag, restored by a trap.
# Runs in a subshell so the traps are local. Not safe against two concurrent runs (use su_zypper_excluding).
su_zypper_excluding_snapshot() (
ex=(); while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
bak=$(mktemp) || exit 1; had=0; restored=0
if [ -e "$locks" ]; then cp -p -- "$locks" "$bak" || exit 1; had=1; fi
_restore() {
[ "$restored" = 1 ] && return 0; restored=1
if [ "$had" = 1 ]; then cp -p -- "$bak" "$locks"; else rm -f -- "$locks"; fi
rm -f -- "$bak"
}
trap '_restore' EXIT; trap '_restore; exit 130' INT; trap '_restore; exit 143' TERM HUP
if [ "${#ex[@]}" -gt 0 ]; then zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || exit 1; fi
zypper --non-interactive "$@"
)
# ---------------------------------------------------------------- 7. plan preview (display only)
# su_plan [--exclude NAME]... -> section<TAB>name<TAB>old<TAB>new<TAB>repo<TAB>arch<TAB>replaces
# section = upgrade | install | remove | downgrade | reinstall | skipped | other ; old/replaces are "-" if n/a.
# Shows what `su_upgrade` would do INCLUDING new dependencies, removals and obsolete-replacements, which the
# upgradable list (check-update / list-updates) never shows. Nothing is changed.
su_plan_zypper() {
local out
out=$(su_zypper_excluding "$@" -- --xmlout "$(su_zypper_verb)" --dry-run) || return $?
# <install-summary><to-upgrade><solvable type="package" name= edition= arch= repository= edition-old= .../>...</to-upgrade><to-install>...
printf '%s\n' "$out" | tr '\n' ' ' | sed -e 's/<to-/\n<to-/g' -e 's/<solvable /\n<solvable /g' |
{
local line sec=other tag n e o a r
while IFS= read -r line || [ -n "$line" ]; do
case $line in
'<to-'*) sec=${line#<to-}; sec=${sec%%>*} ;;
'<solvable type="package" '*)
tag=${line%%>*}
_su_xattr "$tag" name; n=$REPLY
_su_xattr "$tag" edition; e=$REPLY
_su_xattr "$tag" edition-old && o=$REPLY || o=-
_su_xattr "$tag" arch; a=$REPLY
_su_xattr "$tag" repository; r=$REPLY
printf '%s\t%s\t%s\t%s\t%s\t%s\t-\n' "$sec" "$n" "$o" "$e" "$r" "$a" ;;
esac
done
}
}
# dnf: no machine-readable transaction output exists in dnf4; parse the (stable, non-tty) table printed by
# `dnf --assumeno upgrade`. Section headers are col-0 lines ending in ':'; rows start with one space:
# dnf4: " name arch evr repo size unit" , then optionally " replacing old.arch oldevr"
# dnf5: " name arch 0:evr repo size unit", then optionally " replacing old arch 0:oldevr repo size unit"
su_plan_dnf() {
local -a ex=(); local ox=() out l a b c d rest sec=other
local ps= pn= po=- pv= pr= pa= pp=- # pending row (printed when the next row/section/summary arrives)
while [ $# -gt 0 ]; do case $1 in --exclude) ex+=("$2"); shift 2 ;; *) shift ;; esac; done
if [ "${#ex[@]}" -gt 0 ]; then ox=("--exclude=$(IFS=,; echo "${ex[*]}")"); fi
out=$(dnf --assumeno --setopt=skip_if_unavailable=0 upgrade "${ox[@]}" 2>/dev/null) # --assumeno => rc 1 is normal
printf '%s\n' "$out" | grep -q '^Transaction Summary' || return 1
_flush() {
[ -n "$pn" ] || return 0
if [ "$ps" = upgrade ] && [ "$po" = - ]; then # dnf4 prints no "replacing" line for plain upgrades
po=$(rpm -q --qf '%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' "$pn.$pa" 2>/dev/null | LC_ALL=C sort -V | tail -n1); po=${po#0:}; : "${po:=-}"
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$ps" "$pn" "$po" "$pv" "$pr" "$pa" "$pp"; pn=; po=-; pp=-
}
while IFS= read -r l; do
case $l in
'Transaction Summary'*) break ;;
[A-Z]*:) _flush
case $l in Upgrading*) sec=upgrade ;; Installing*) sec=install ;; Removing*) sec=remove ;;
Downgrading*) sec=downgrade ;; Reinstalling*) sec=reinstall ;; Skipping*) sec=skipped ;; *) sec=other ;; esac ;;
' '*)
read -r a b c d rest <<<"$l"
case $a in
Package|=*|'') ;;
replacing)
if [ -n "$d" ]; then c=${d#0:}; else b=${b%.*}; c=${c#0:}; fi # dnf5: name arch evr repo ; dnf4: name.arch evr
if [ "$sec" = upgrade ]; then po=$c; else pp=$b; fi ;;
*) _flush; ps=$sec; pn=$a; pa=$b; pv=${c#0:}; pr=$d ;;
esac ;;
esac
done <<<"$out"
_flush
}
su_plan() {
case "$(su_backend)" in dnf) su_plan_dnf "$@" ;; zypper)
local -a ex=(); while [ $# -gt 0 ]; do case $1 in --exclude) ex+=("$2"); shift 2 ;; *) shift ;; esac; done
su_plan_zypper "${ex[@]}" ;; esac
}
# ---------------------------------------------------------------- 8. remove (+ unneeded dependencies)
# dnf: clean_requirements_on_remove=1 is the default on dnf4 and dnf5 -> unneeded deps go too.
# (A dependency installed via plain `rpm -i` is NOT considered unneeded; only deps installed by dnf are.)
# Exit status is 0 even if the package did not exist -> always verify with rpm -q.
# zypper: --clean-deps (-u). `remove` of an unknown name exits 104.
su_remove() {
case "$(su_backend)" in
dnf) dnf -y remove "$@" ;;
zypper) zypper --non-interactive remove --clean-deps "$@" ;;
esac
}
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# proof.sh - runs lib-rpm.sh against the fixture and asserts the behaviour documented in rpm.md.
# docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/proof.sh
# Prints PASS/FAIL lines + sample output; exit code = number of failures.
export LC_ALL=C
D=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
now() { printf '%s' "${EPOCHREALTIME/./}"; } # microseconds
T_START=$(now)
FAILS=0
pass() { printf 'PASS %s\n' "$1"; }
fail() { printf 'FAIL %s\n' "$1"; FAILS=$((FAILS+1)); }
check() { local name=$1; shift; if "$@" >/dev/null 2>&1; then pass "$name"; else fail "$name"; fi; }
eq() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1: got [$2] want [$3]"; fi; }
exec 3>&1 # fd 3 = real stdout, so tm's timing is not swallowed by redirections
tm() { local t0=$(now); "$@"; local rc=$?; printf ' (took %d ms)\n' $(( ($(now)-t0)/1000 )) >&3; return $rc; }
sec() { printf '\n== %s\n' "$*"; }
sec "fixture"
t0=$(now); SU_FIXTURE_EXTRAS=1 bash "$D/fixture.sh" 2>&1 | sed 's/^/ /' | tail -4; [ "${PIPESTATUS[0]}" -eq 0 ] || { echo "FIXTURE FAILED"; exit 99; }
FIXTURE_MS=$(( ($(now)-t0)/1000 ))
. "$D/lib-rpm.sh"
. /etc/os-release
BACKEND=$(su_backend)
echo "image=$PRETTY_NAME ID=$ID backend=$BACKEND dnf_major=$([ $BACKEND = dnf ] && su_dnf_major || echo -) zypper_verb=$([ $BACKEND = zypper ] && su_zypper_verb || echo -)"
sec "1. refresh"
t0=$(now); su_refresh; rc=$?; REFRESH_MS=$(( ($(now)-t0)/1000 )); eq "su_refresh exit code" "$rc" 0; echo " (took $REFRESH_MS ms)"
sec "2. upgradable list (name old new repo arch)"
t0=$(now); su_upgradable > /tmp/up.tsv; rc=$?; LIST_MS=$(( ($(now)-t0)/1000 )); eq "su_upgradable exit code" "$rc" 0
echo " (took $LIST_MS ms; $(wc -l </tmp/up.tsv) rows) first 5 rows:"; head -5 /tmp/up.tsv | cat -A | sed 's/^/ /'
for p in a b c; do
arch=noarch; [ $p = c ] && arch=$(rpm -q --qf '%{ARCH}' su-dummy-c)
grep -q -x -F "su-dummy-$p"$'\t'"1.0-1"$'\t'"2.0-1"$'\t'"su-dummy"$'\t'"$arch" /tmp/up.tsv && pass "row su-dummy-$p 1.0-1 -> 2.0-1 [su-dummy] $arch" || fail "row su-dummy-$p missing"
done
check "no su-dummy-d (not upgradable)" test "$(grep -c '^su-dummy-d' /tmp/up.tsv)" -eq 0
check "no '.arch' suffix in name column" test "$(cut -f1 /tmp/up.tsv | grep -c '\.\(noarch\|x86_64\|aarch64\)$')" -eq 0
sec "3. summary + description (candidate if upgradable, else installed)"
su_describe su-dummy-a > /tmp/d_a.txt; echo "--- su-dummy-a (candidate):"; sed 's/^/ /' /tmp/d_a.txt
eq "candidate summary" "$(head -n1 /tmp/d_a.txt)" "skeptical-updater dummy su-dummy-a (v2.0)"
check "candidate description has 2nd paragraph" grep -q 'Second paragraph of su-dummy-a 2.0' /tmp/d_a.txt
check "paragraph break preserved" test "$(sed -n 3p /tmp/d_a.txt)" = ""
su_describe su-dummy-d > /tmp/d_d.txt; echo "--- su-dummy-d (installed only):"; sed 's/^/ /' /tmp/d_d.txt
eq "installed-only summary" "$(head -n1 /tmp/d_d.txt)" "skeptical-updater dummy su-dummy-d (v1.0)"
rpm -qi su-dummy-a | sed -n '/^Summary/p' | sed 's/^/ rpm -qi: /'
sec "4. installed reverse dependencies"
eq "rdeps su-dummy-b" "$(su_rdeps su-dummy-b | tr '\n' ' ')" "su-dummy-a su-dummy-d "
eq "rdeps su-dummy-a" "$(su_rdeps su-dummy-a | tr '\n' ' ')" "su-dummy-d "
eq "rdeps su-dummy-c (only via rich dep (c if b))" "$(su_rdeps su-dummy-c | tr '\n' ' ')" "su-dummy-d "
eq "rdeps su-dummy-d (leaf)" "$(su_rdeps su-dummy-d | tr '\n' ' ')" ""
n_mine=$(su_rdeps bash | wc -l); n_naive=$(rpm -q --qf '%{NAME}\n' --whatrequires bash 2>/dev/null | sort -u | wc -l)
echo " bash: su_rdeps=$n_mine naive 'rpm -q --whatrequires bash'=$n_naive"
provider_sh=$(rpm -q --whatprovides --qf '%{NAME}\n' /bin/sh | head -n1)
check "su_rdeps($provider_sh) finds su-dummy-d via file dep /bin/sh" bash -c ". $D/lib-rpm.sh; su_rdeps $provider_sh | grep -qx su-dummy-d"
[ "$n_mine" -gt "$n_naive" ] && pass "su_rdeps(bash) > naive rpm --whatrequires" || fail "su_rdeps(bash) not > naive"
if [ "$BACKEND" = dnf ]; then
dnf -q repoquery --installed --whatrequires bash --queryformat $'%{name}\n' 2>/dev/null | sed '/^$/d' | sort -u > /tmp/native_rd.txt
su_rdeps bash > /tmp/mine_rd.txt; eq "su_rdeps(bash) == dnf repoquery --installed --whatrequires" "$(cat /tmp/mine_rd.txt | tr '\n' ' ')" "$(cat /tmp/native_rd.txt | tr '\n' ' ')"
else
zypper -q -n search --installed-only -t package --requires-pkg bash 2>/dev/null | grep -E '^[iv ][+ ]? *\|' | cut -d'|' -f2 | tr -d ' ' | grep -v -x Name | sort -u > /tmp/native_rd.txt
su_rdeps bash > /tmp/mine_rd.txt; su_rdeps_recursive bash > /tmp/mine_rrd.txt
echo " zypper se --requires-pkg bash : $(wc -l </tmp/native_rd.txt) pkgs"; echo " su_rdeps bash (direct) : $(wc -l </tmp/mine_rd.txt) pkgs: $(tr '\n' ' ' </tmp/mine_rd.txt)"
echo " su_rdeps_recursive bash : $(wc -l </tmp/mine_rrd.txt) pkgs"
echo " native minus recursive : $(LC_ALL=C comm -23 /tmp/native_rd.txt /tmp/mine_rrd.txt | tr '\n' ' ')"
fi
eq "su_rdeps_recursive(su-dummy-b)" "$(su_rdeps_recursive su-dummy-b | tr '\n' ' ')" "su-dummy-a su-dummy-d "
eq "su_rdeps_recursive(su-dummy-d) (leaf)" "$(su_rdeps_recursive su-dummy-d | tr '\n' ' ')" ""
sec "5. recursive installed dependency closure"
tm su_closure su-dummy-d > /tmp/clo_d.txt
echo " closure(su-dummy-d): $(wc -l </tmp/clo_d.txt) pkgs: $(head -c 300 /tmp/clo_d.txt | tr '\n' ' ')..."
for p in su-dummy-a su-dummy-b su-dummy-c glibc; do grep -qx "$p" /tmp/clo_d.txt && pass "closure(d) contains $p" || fail "closure(d) missing $p"; done
provider_sh=$(rpm -q --whatprovides --qf '%{NAME}\n' /bin/sh | head -n1); grep -qx "$provider_sh" /tmp/clo_d.txt && pass "closure(d) contains $provider_sh (provider of file dep /bin/sh)" || fail "closure(d) missing $provider_sh"
! grep -qx 'su-dummy-d' /tmp/clo_d.txt && pass "closure(d) excludes root" || fail "closure(d) contains root"
grep -q 'rpmlib' /tmp/clo_d.txt && fail "closure contains rpmlib" || pass "no rpmlib(...) leakage"
eq "closure(su-dummy-a)" "$(su_closure su-dummy-a | tr '\n' ' ')" "su-dummy-b "
if [ "$BACKEND" = dnf ] && [ "$(su_dnf_major)" = 4 ]; then
dnf -q repoquery --installed --requires --resolve --recursive --queryformat $'%{name}\n' su-dummy-d 2>/dev/null | sed '/^$/d' | sort -u > /tmp/clo_native.txt
eq "closure(d) == dnf4 repoquery --requires --resolve --recursive" "$(tr '\n' ' ' </tmp/clo_d.txt)" "$(tr '\n' ' ' </tmp/clo_native.txt)"
fi
if [ "$BACKEND" = dnf ] && [ "$(su_dnf_major)" = 5 ]; then
dnf -q repoquery --installed --requires --resolve --recursive su-dummy-d >/dev/null 2>&1; echo " dnf5 repoquery --requires --resolve --recursive -> rc=$? (unsupported)"
fi
sec "6. upgrade with per-run exclusion (a,b excluded) -- c must upgrade, a,b must stay"
if [ $BACKEND = dnf ]; then RESTRICT=(--disablerepo='*' --enablerepo=su-dummy); else RESTRICT=(); fi
LOCKS_BEFORE=$( [ -e /etc/zypp/locks ] && cksum < /etc/zypp/locks || echo absent )
tm su_upgrade --exclude su-dummy-a --exclude su-dummy-b -- "${RESTRICT[@]}" > /tmp/up_ex.log 2>&1; rc=$?
eq "exit code of excluded upgrade" "$rc" 0
eq "versions after excluded upgrade" "$(rpm -q --qf '%{NAME}=%{VERSION} ' su-dummy-a su-dummy-b su-dummy-c)" "su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0 "
if [ $BACKEND = zypper ]; then
LOCKS_AFTER=$( [ -e /etc/zypp/locks ] && cksum < /etc/zypp/locks || echo absent )
eq "/etc/zypp/locks untouched ($LOCKS_BEFORE)" "$LOCKS_AFTER" "$LOCKS_BEFORE"
check "no leftover temp zypp dirs" bash -c 'ls /tmp | grep -q "^tmp\." && exit 1 || exit 0'
fi
su_upgradable | cut -f1,2,3 | grep '^su-dummy' | sed 's/^/ still upgradable: /'
check "a and b still listed as upgradable" bash -c ". $D/lib-rpm.sh; su_upgradable | cut -f1 | grep -c '^su-dummy-[ab]$' | grep -qx 2"
echo "--- now without exclusions"
tm su_upgrade -- "${RESTRICT[@]}" > /tmp/up_all.log 2>&1; rc=$?; eq "exit code of plain upgrade" "$rc" 0
eq "versions after full upgrade" "$(rpm -q --qf '%{NAME}=%{VERSION} ' su-dummy-a su-dummy-b su-dummy-c)" "su-dummy-a=2.0 su-dummy-b=2.0 su-dummy-c=2.0 "
check "nothing left for su-dummy-*" test "$(su_upgradable | grep -c '^su-dummy')" -eq 0
sec "7. remove (+ unneeded deps)"
if [ $BACKEND = dnf ]; then dnf -y -q install su-dummy-r >/dev/null 2>&1; else zypper -n -q install su-dummy-r >/dev/null 2>&1; fi
eq "installed r and s" "$(rpm -q --qf '%{NAME} ' su-dummy-r su-dummy-s)" "su-dummy-r su-dummy-s "
su_remove su-dummy-r >/tmp/rm.log 2>&1; rc=$?; eq "remove exit code" "$rc" 0
eq "r AND its auto-installed dep s removed" "$(rpm -q su-dummy-r su-dummy-s 2>&1 | sed 's/ is not installed//;s/^package //' | tr '\n' ' ')" "su-dummy-r su-dummy-s "
su_remove su-dummy-a >/tmp/rm2.log 2>&1
eq "rpm-installed dep b NOT removed with a (not marked auto-installed)" "$(rpm -q su-dummy-b)" "su-dummy-b-2.0-1.noarch"
su_remove no-such-pkg-xyz >/tmp/rm3.log 2>&1; echo " remove of unknown pkg -> exit $?"
sec "8. fixture re-run resets state"
SU_FIXTURE_EXTRAS=1 bash "$D/fixture.sh" >/dev/null 2>&1; su_upgradable > /tmp/up2.tsv
check "after re-run a,b,c upgradable again" test "$(grep -c '^su-dummy-[abc] 1.0-1 2.0-1' /tmp/up2.tsv)" -eq 3
sec "9. plan preview (display only) incl. an obsoleting package"
TOP=/var/tmp/su-fixture; REPO=/var/lib/su-dummy-repo
mkp() { # mkp name ver [extra spec header lines...] -> builds noarch rpm, echoes path
local n=$1 v=$2; shift 2; local f=$TOP/SPECS/$n-$v.spec
{ echo "Name: $n"; echo "Version: $v"; echo "Release: 1"; echo "Summary: dummy $n"; echo "License: MIT"; echo "BuildArch: noarch"
local l; for l in "$@"; do echo "$l"; done
echo '%description'; echo "d"; echo '%install'; echo 'mkdir -p %{buildroot}/usr/share/su-dummy'; echo "echo $n > %{buildroot}/usr/share/su-dummy/$n"
echo '%files'; echo "/usr/share/su-dummy/$n"; } > "$f"
rpmbuild -bb --quiet --define "_topdir $TOP" "$f" >/dev/null 2>&1; ls "$TOP"/RPMS/noarch/$n-$v-1.*.rpm
}
rpm -Uvh "$(mkp su-dummy-old 1.0)" >/dev/null
cp "$(mkp su-dummy-new 1.0 'Obsoletes: su-dummy-old < 2' 'Provides: su-dummy-old = 2')" "$REPO"/ && createrepo_c --quiet --update "$REPO" >/dev/null
su_refresh
su_plan --exclude su-dummy-a > /tmp/plan.tsv; rc=$?; eq "su_plan exit code" "$rc" 0
echo " su_plan --exclude su-dummy-a (section name old new repo arch replaces), su-dummy rows only:"; grep -E $'\tsu-dummy-' /tmp/plan.tsv | sed 's/^/ /'
check "plan: b upgrade 1.0-1 -> 2.0-1" grep -q -x -F $'upgrade\tsu-dummy-b\t1.0-1\t2.0-1\tsu-dummy\tnoarch\t-' /tmp/plan.tsv
check "plan: excluded su-dummy-a is absent" test "$(grep -c $'\tsu-dummy-a\t' /tmp/plan.tsv)" -eq 0
check "plan: obsoleting install of su-dummy-new is visible" grep -q $'^install\tsu-dummy-new\t' /tmp/plan.tsv
echo " (upgradable list does NOT show the obsoleter:) $(su_upgradable | cut -f1 | grep -c '^su-dummy-new$') row(s) for su-dummy-new"
rpm -e su-dummy-old >/dev/null 2>&1
if [ $BACKEND = zypper ]; then
sec "10. zypper lock save/restore recipe (su_zypper_excluding_snapshot)"
zypper -n -q addlock -m "user lock" su-dummy-d >/dev/null
sum() { [ -e /etc/zypp/locks ] && { cksum < /etc/zypp/locks; stat -c %Y /etc/zypp/locks; } | tr '\n' ' ' || echo absent; }
B=$(sum); echo " before: $B"
su_zypper_excluding_snapshot su-dummy-a su-dummy-b -- "$(su_zypper_verb)" --dry-run > /tmp/snap.log 2>&1; rc=$?
eq "snapshot recipe exit code (dry-run)" "$rc" 0
eq "locks file byte-identical + same mtime after normal run" "$(sum)" "$B"
check "user lock still present afterwards" grep -q 'solvable_name: su-dummy-d' /etc/zypp/locks
check "temporary locks are gone" test "$(grep -c 'solvable_name: su-dummy-[ab]$' /etc/zypp/locks)" -eq 0
echo "--- why not addlock + removelock: a pre-existing user lock on the same name is destroyed"
zypper -n -q addlock su-dummy-a >/dev/null # the 'user' lock on su-dummy-a
zypper -n -q removelock su-dummy-a >/dev/null
check "NAIVE removelock destroyed the user's own lock on su-dummy-a (why we snapshot)" test "$(grep -c 'solvable_name: su-dummy-a$' /etc/zypp/locks)" -eq 0
zypper -n -q addlock su-dummy-a >/dev/null; B3=$(sum)
su_zypper_excluding_snapshot su-dummy-a -- "$(su_zypper_verb)" --dry-run >/dev/null 2>&1
eq "snapshot recipe keeps pre-existing lock on su-dummy-a" "$(sum)" "$B3"
echo "--- file absent before => absent after (removelock would leave a 0-byte file)"
rm -f /etc/zypp/locks
su_zypper_excluding_snapshot su-dummy-a -- "$(su_zypper_verb)" --dry-run >/dev/null 2>&1
eq "locks file absent after" "$(sum)" absent
echo "--- SIGTERM in the middle of the run => trap restores"
zypper -n -q addlock -m "user lock" su-dummy-d >/dev/null; B4=$(sum)
zypper() { if [ "$3" = "$(su_zypper_verb)" ] || [ "$2" = "$(su_zypper_verb)" ]; then kill -TERM $BASHPID; sleep 5; else command zypper "$@"; fi; }
su_zypper_excluding_snapshot su-dummy-a su-dummy-b -- "$(su_zypper_verb)" > /dev/null 2>&1; rc=$?
unset -f zypper
eq "killed run exit code (143 = 128+SIGTERM)" "$rc" 143
eq "locks restored after SIGTERM" "$(sum)" "$B4"
fi
TOTAL_MS=$(( ($(now)-T_START)/1000 ))
printf '\n== timings: fixture=%d ms refresh=%d ms list=%d ms total=%d ms\n' "$FIXTURE_MS" "$REFRESH_MS" "$LIST_MS" "$TOTAL_MS"
printf '== RESULT: %d failure(s)\n' "$FAILS"
exit "$FAILS"
+226
View File
@@ -0,0 +1,226 @@
# skeptical-updater — design
A distro-agnostic, single-file bash applet for a *manual, conscious* update cycle.
It wraps the native package manager (apt, dnf/yum, pacman/pman, zypper), lists
upgradable packages, lets the user interrogate each one, and only then runs the
native upgrade. Packages the user has explicitly acknowledged are remembered in a
statefile and never questioned again.
## Goals
- One file, no runtime dependencies beyond bash >= 4.3 and the distro's package manager.
- Never reimplement package management: every action shells out to the native tool.
- Deterministic, testable: `--dry-run` prints the commands it would run; stdin is
the command channel (works piped, no TTY needed).
- Statefile is plain text, hand-editable, atomic writes.
## Backend detection
Order (first match wins, overridable with `--backend=<name>`):
| backend | detected by | notes |
|----------|-----------------------------|--------------------------------------------|
| `pacman` | `pacman` in PATH | `pman` present => label shown as `pman`, same commands; honor pacman `IgnorePkg` (`pacman -Qu` marks them `[ignored]`); the refresh syncs a throwaway `--dbpath` copy; at `<enter>` its reviewed databases are installed and applied with `pacman -Su` |
| `apt` | `apt-get` in PATH | Debian/Ubuntu |
| `dnf` | `dnf` in PATH (else `yum`) | dnf4 (EL9) and dnf5 (EL10) differences hidden in the backend |
| `zypper` | `zypper` in PATH | `dist-upgrade` on Tumbleweed (ID=opensuse-tumbleweed), `update` on Leap; on Tumbleweed the review list is the `dist-upgrade --dry-run` plan |
Each backend implements the same function set (see "Backend contract").
## Statefile
Directory: `/var/lib/skeptical-updater/` (override: `SKEPTICAL_UPDATER_STATE_DIR`).
Created `0755 root:root` on first write. The tool runs as root (it must, to upgrade);
when not root it re-execs itself through `sudo` if available.
File: `trusted.tsv` — tab-separated, UTF-8, LF, one record per line, sorted and
unique on `(backend, package)`. Lines starting with `#` are comments and are kept.
```
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
apt nodejs user 2026-10-09T09:40:12Z
apt libnode115 dep:nodejs 2026-10-09T09:40:12Z
```
- `reason` is `user` for a package the user confirmed with `N+`, or `dep:<root>`
for a package pulled in as part of the installed dependency closure of `<root>`
at the time of confirmation.
- `backend` is recorded so the file stays correct on hosts with more than one
package manager and stays readable when copied between hosts.
- Writes are atomic: write to `trusted.tsv.tmp.$$` in the same directory, `mv` over.
- Writes happen under a lock (`trusted.lock`: `flock(1)` when available, else an atomic
`mkdir` lock: a directory created under a temporary name with the owner's pid inside and
renamed into place; a lock whose owner is gone - a process of another user counts as alive -
is taken over by renaming it away first and checking its pid again there, so two waiters can
never remove a live lock; never busy-waited on; leftovers of crashed acquirers are removed).
At save time the
file is re-read and this session's additions/removals are replayed onto it, so a `trust` run
in another terminal meanwhile is not lost.
- Comment lines are kept, in their order, at the top of the file when it is rewritten;
malformed lines are kept verbatim (with a warning) in the same block.
- A package is "trusted" if a row with the current backend and that name exists.
Reason does not matter for filtering.
Why a stored closure instead of computing it every run: closure computation across
40+ upgradable packages on every run is slow on apt/rpm; computing it once at `N+`
time and storing `dep:<root>` rows keeps every run O(upgradable). A new dependency
that appears later is *new to the system* and deserves to be questioned once;
`skeptical-updater refresh` recomputes closures for all `user` roots when the user
wants to re-trust them wholesale.
No other state, except two crash-recovery files that only exist while an upgrade runs (or
after one was killed): `holds.added`, the apt hold journal (used under `holds.lock`, which is
kept until the holds are really released), and `zypper-private`, the path of the private zypper
configuration (used under `zypper.lock`). Both are replayed/removed at the start of the next root
run, before anything is listed - only when their lock is free, i.e. not while another run uses
them. Per-run exclusions (`N-`) are
intentionally not persisted: a skeptical user wants to be asked again next time. Permanent holds belong to the
package manager (`apt-mark hold`, `IgnorePkg`, `zypper addlock`, dnf `exclude=`).
## Interactive session
```
$ sudo skeptical-updater
Backend: apt (Debian GNU/Linux 12)
Refreshing package lists... done.
12 upgradable, 9 trusted (hidden, use --all to show), 3 to review:
[1] nodejs 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security
[2] libnode108 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security
[3] zsh 5.9-4 -> 5.9-4+deb12u1 bookworm
<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
> 1?
```
Command grammar (one line, whitespace-separated tokens, each `N` is the number in brackets):
| 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 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 |
| `N+` | trust it: write it and its installed dependency closure to the statefile; the row leaves the list (with `--all` it stays, marked `=`). Rows that this trust hides are re-included if they were excluded |
| `*+` / `*-` | apply `+` / `-` to every listed package; `*-` is an all-or-nothing toggle: when every listed row is already excluded it re-includes them all |
| `l` | redraw the list |
| `q` | quit without upgrading (exit 0) |
| `h`, `?` | help |
Numbers are stable for the whole session (removed rows leave a gap, never renumber).
All tokens of a line are validated first: an unknown token or a number that is not listed
prints an error, runs nothing of that line and re-prompts. EOF on stdin aborts without
upgrading (exit 2) so a piped run can never upgrade by accident unless it sends an empty
line. When purges leave no row at all, the session ends ("Nothing left to upgrade.", exit 0).
Flags shown after a row: `[held]` = the package manager will not upgrade it on its own: on apt
a hold (`apt-mark hold`) or a package apt keeps back (the "kept back" block of
`apt-get -s --with-new-pkgs upgrade`), on pacman an `IgnorePkg` (`[ignored]` in `pacman -Qu`).
dnf excludes/versionlocks and zypper locks hide the package from the native listing instead.
Notes after the rows report what the backend says beyond them (new dependencies or
replacements the upgrade installs, packages a Tumbleweed `dist-upgrade` removes); with no row
but such pending work the prompt is still offered ("0 to review, but ... reports pending
changes").
After `<enter>`:
1. Build the exclusion set = `N-` rows.
2. Apply native per-run exclusion (`pacman --ignore a,b`, `dnf --exclude`,
apt temporary `apt-mark hold` of the packages not already held, journalled first and
released afterwards - also on failure, Ctrl-C, TERM, HUP, QUIT, a closed stdout - so the hold
set is exactly the prior one; zypper `addlock` in a private configuration whose lock file is
a copy of the effective one, so the real lock set is never written).
3. Run the native upgrade non-interactively, streaming its output, on exactly the metadata that
was reviewed: dnf `--setopt=*.metadata_expire=-1`, zypper `--no-refresh`; apt never
refreshes on upgrade. pacman: (a) `pacman -Sup --noconfirm --ignore ... --dbpath <throwaway>`
checks the reviewed transaction - if it fails (an ignored package another upgrade needs, a
conflict) its output is shown and the tool exits with its status, nothing changed; (b) the
throwaway `*.db` and `*.db.sig` files are installed into the system's sync directory under
pacman's own lock (`db.lck`, created atomically; an existing one = another pacman: nothing
changed). A system database that changed since the review and is newer than the reviewed
copy is never overwritten: the tool refuses ("package databases changed since the review
(<repos>)") with exit status 2 and changes nothing; identical ones are skipped. The install is
all-or-nothing: everything is copied to temporary names first, then all renames happen with
the signals ignored, each `.db.sig` right before its `.db`; `db.lck` is removed right before
(c) `pacman -Su --noconfirm --ignore ...` applies them, no further sync. If (c) fails or is
interrupted the system's sync database is newer than its installed packages: the tool (or its
exit handler) says so; finish with `pacman -Su` (or run the tool again) before any
`pacman -S`. With `--no-refresh` (or a failed refresh)
plain `pacman -Su` applies the existing databases. Throwaway databases carry their owner's
pid in the name (`/tmp/skeptical-updater-db.<pid>.XXXXXX`); those of runs that are gone are
removed at the start of the next root run.
4. Restore holds/locks, exit with the package manager's exit code.
Trusted packages are upgraded silently along with everything else; they are only
hidden from the questioning list.
## CLI
```
skeptical-updater [options] [command]
commands:
(none) interactive review + upgrade
list print the review list and exit (no prompt)
trusted print the statefile
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 same as N?
show PKG same as N??
options:
--backend=NAME force apt|dnf|pacman|zypper
--all also list trusted packages (marked with `=`)
--no-refresh skip the metadata refresh step
--dry-run print native commands instead of running mutating ones
--state-dir=DIR override /var/lib/skeptical-updater
--no-color disable ANSI colours (also honours NO_COLOR, non-tty stdout)
--version, --help
```
Exit codes: 0 ok (also `q`), 1 usage/backend error, 2 aborted (end of input),
otherwise the native package manager's exit code from the upgrade step.
When root is needed (refresh, upgrade, purge, writing a state directory the user cannot
write) and the user is not root, the tool re-runs itself as
`sudo -- /absolute/path/to/itself --sudo-reexec ARGS...` - the file itself, so a sudoers rule
for it keeps matching; only a copy that is not executable is run as
`sudo -- "$BASH" /absolute/path --sudo-reexec ARGS...`. The hidden `--sudo-reexec` (accepted
only as the first argument, never forwarded) makes a re-run that is still not root fail instead
of looping. Without sudo it fails with a clear message.
## Backend contract
Every backend is a set of bash functions with a common prefix. All output is
`LC_ALL=C`. All functions must work without a TTY.
```
<be>_refresh # update metadata; non-fatal on failure (warn)
<be>_upgradable # stdout: name<TAB>old<TAB>new<TAB>repo<TAB>flags (flags: "" | "held"; old "-" = new package)
# plus "#<note>" lines for pending work that is not a row
<be>_describe PKG # stdout: one-line summary
<be>_full_info PKG # stdout: native full dump (candidate version if available)
<be>_rdepends PKG # stdout: installed packages requiring PKG, one per line
<be>_closure PKG # stdout: installed recursive dependency closure of PKG (excluding PKG), one per line
<be>_purge_plan PKG # stdout: every package a purge of PKG and its now-unneeded dependencies removes
# (simulation, no cascade); non-zero = refused
<be>_purge PKG... # non-interactive native purge/remove of exactly the planned packages
<be>_upgrade EXCLUDED... # non-interactive native upgrade skipping EXCLUDED (handles hold/lock save+restore)
<be>_label # "apt" / "pman" / ...
```
Mutating calls go through `run_cmd` which honours `--dry-run` (prints `+ cmd` to
stdout instead of executing).
## Layout
```
skeptical-updater the applet (single bash file)
makefile install / lint / test / docker targets (no LLM needed)
docs/DESIGN.md this file
discovery/ verified per-family command notes and fixture scripts
tests/unit/*.bats bats-core tests with a fake backend (no root, no docker)
tests/e2e/*.bats bats-core tests run inside each distro container against real package managers
tests/docker/ images list, snapshot/cleanup scripts, per-distro Dockerfiles
```
+150
View File
@@ -0,0 +1,150 @@
#!make
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# makefile for skeptical-updater: install, lint and test targets.
# Plain GNU make + bash + Docker (+ curl once, for `make vendor`); no other tooling.
# `make` or `make help` lists the targets. Tests run entirely in Docker containers.
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
.ONESHELL:
.NOTPARALLEL:
.DEFAULT_GOAL := help
MAKEFLAGS += --no-print-directory
APPLET := skeptical-updater
PREFIX ?= /usr/local
DESTDIR ?=
# ---- vendored test tooling, pinned --------------------------------------------
# To bump: set the new tag and the sha256sum of its GitHub tag tarball
# (https://github.com/bats-core/<repo>/archive/refs/tags/v<version>.tar.gz),
# then `rm -rf tests/vendor && make vendor`.
BATS_VERSION := 1.14.0
BATS_SHA256 := bb537b70b15b732f6d8827dd6578e3d8ce166636ce1f18ea9a074184fcce9177
BATS_SUPPORT_VERSION := 0.3.0
BATS_SUPPORT_SHA256 := 7815237aafeb42ddcc1b8c698fc5808026d33317d8701d5ec2396e9634e2918f
BATS_ASSERT_VERSION := 2.2.4
BATS_ASSERT_SHA256 := e305df20c4c36cba4570e10f1cd824efb1ae71d88b5ef474550781ebee04192f
BATS_GITHUB := https://github.com/bats-core
VENDOR := tests/vendor
BATS := $(VENDOR)/bats-core/bin/bats
# ---- tests ----------------------------------------------------------------------
SHELLCHECK_IMAGE ?= koalaman/shellcheck:stable
UNIT_IMAGE ?= debian:12
E2E_IMAGES ?= tests/docker/images.txt
E2E_ONLY ?=
E2E_FAIL_FAST ?= 0
# discovery/ is frozen reference material (not shellcheck-clean, never edited): not linted.
LINT_FILES := $(wildcard $(APPLET) tests/docker/*.sh tests/e2e/*.sh tests/e2e/fixtures/*.sh \
tests/e2e/*.bash tests/e2e/*.bats tests/unit/*.bash tests/unit/*.bats \
tests/unit/fakes/*.bash tests/unit/fakes/*/* tests/unit/data/rpm/db.bash)
# $(call fetch,<repo>,<version>,<sha256>,<file that must exist>)
# Download github.com/bats-core/<repo> tag v<version> with curl into a temp dir,
# verify its sha256 and that <file> was extracted, then move it to tests/vendor/<repo>.
define fetch
tmp=$$(mktemp -d "$${TMPDIR:-/tmp}/skeptical-updater-vendor.XXXXXX")
trap 'rm -rf -- "$$tmp"' EXIT
url="$(BATS_GITHUB)/$(1)/archive/refs/tags/v$(2).tar.gz"
echo "vendor: $(1) v$(2) <- $$url"
curl -fsSL --retry 3 --proto '=https' -o "$$tmp/src.tar.gz" "$$url"
if ! echo "$(3) $$tmp/src.tar.gz" | sha256sum -c --status -; then echo "vendor: $(1) v$(2): sha256 mismatch (expected $(3))" >&2; exit 1; fi
mkdir -- "$$tmp/src"
tar -xzf "$$tmp/src.tar.gz" -C "$$tmp/src"
if [[ ! -f "$$tmp/src/$(1)-$(2)/$(4)" ]]; then echo "vendor: $(1) v$(2): $(4) not found in the tarball" >&2; exit 1; fi
mkdir -p -- "$(VENDOR)"
rm -rf -- "$(VENDOR)/$(1)"
mv -- "$$tmp/src/$(1)-$(2)" "$(VENDOR)/$(1)"
echo "$(1) v$(2) sha256:$(3) $$url" >"$(VENDOR)/$(1)/.vendored"
echo "vendor: $(1) v$(2) -> $(VENDOR)/$(1)"
endef
.PHONY: help install uninstall lint vendor docker-snapshot docker-cleanup docker-cleanup-dry-run \
test test-no-cleanup test-unit test-e2e clean
help: ## show this help (default target)
@echo "usage: make <target> [VAR=value ...]"
echo
echo "targets:"
grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | awk -F ':.*## ' '{ printf " %-24s %s\n", $$1, $$2 }'
echo
echo "variables:"
echo " PREFIX=$(PREFIX) DESTDIR=$(DESTDIR) install location"
echo " E2E_ONLY=<label>[,<label>...] run only these labels of $(E2E_IMAGES)"
echo " E2E_FAIL_FAST=1 stop test-e2e at the first failing distro"
install: ## install skeptical-updater as DESTDIR/PREFIX/bin/skeptical-updater, mode 0755
install -d -m 0755 "$(DESTDIR)$(PREFIX)/bin"
install -m 0755 $(APPLET) "$(DESTDIR)$(PREFIX)/bin/$(APPLET)"
uninstall: ## remove DESTDIR/PREFIX/bin/skeptical-updater (the statefile in /var/lib is kept)
rm -f -- "$(DESTDIR)$(PREFIX)/bin/$(APPLET)"
lint: ## shellcheck (in Docker) the applet, the test scripts, fixtures and fakes
ifeq ($(strip $(LINT_FILES)),)
@echo "lint: no shell files to check"
else
docker run --rm -v "$(CURDIR)":/mnt:ro $(SHELLCHECK_IMAGE) $(LINT_FILES)
endif
vendor: $(BATS) $(VENDOR)/bats-support/load.bash $(VENDOR)/bats-assert/load.bash ## download pinned bats-core, bats-support, bats-assert to tests/vendor (once)
$(BATS):
@$(call fetch,bats-core,$(BATS_VERSION),$(BATS_SHA256),bin/bats)
$(VENDOR)/bats-support/load.bash:
@$(call fetch,bats-support,$(BATS_SUPPORT_VERSION),$(BATS_SUPPORT_SHA256),load.bash)
$(VENDOR)/bats-assert/load.bash:
@$(call fetch,bats-assert,$(BATS_ASSERT_VERSION),$(BATS_ASSERT_SHA256),load.bash)
docker-snapshot: ## record the Docker image set (never overwrites; re-baseline: tests/docker/snapshot.sh --force)
tests/docker/snapshot.sh
docker-cleanup: ## restore the Docker image set to the snapshot (re-tag stolen tags, remove new images)
tests/docker/cleanup.sh
docker-cleanup-dry-run: ## print what docker-cleanup would do, without changing anything
tests/docker/cleanup.sh --dry-run
test: docker-snapshot ## test-unit + test-e2e, then ALWAYS docker-cleanup; exits with the tests' status
@status=0
trap 'status=130' INT TERM
$(MAKE) test-unit test-e2e || status=$$?
trap - INT TERM
echo "== test: restoring the Docker image set (tests exited with status $$status) =="
$(MAKE) docker-cleanup || { rc=$$?; if (( status == 0 )); then status=$$rc; fi; }
exit $$status
test-no-cleanup: docker-snapshot test-unit test-e2e ## same as test but keep pulled images (iterate; run docker-cleanup when done)
test-unit: vendor ## run tests/unit/*.bats with the vendored bats in a debian:12 container
docker run --rm -i -v "$(CURDIR)":/work:ro -w /work \
-e SKEPTICAL_UPDATER_STATE_DIR=/tmp/su-state \
$(UNIT_IMAGE) bash $(BATS) --tap tests/unit
test-e2e: vendor ## run the E2E suite in each distro of tests/docker/images.txt (E2E_ONLY, E2E_FAIL_FAST)
@only="$(strip $(E2E_ONLY))"
only=",$${only//[[:space:]]/,},"
ran=0
failed=()
while read -r -u 3 label image _ || [[ -n "$${label:-}" ]]; do
case "$$label" in ''|'#'*) continue ;; esac
if [[ "$$only" != ",," && "$$only" != *",$$label,"* ]]; then continue; fi
if [[ -z "$${image:-}" ]]; then echo "test-e2e: $(E2E_IMAGES): no image for label '$$label'" >&2; exit 1; fi
ran=$$((ran + 1))
if ! tests/docker/run-e2e.sh "$$label" "$$image" </dev/null; then
failed+=("$$label")
if [[ "$(E2E_FAIL_FAST)" == 1 ]]; then echo "test-e2e: E2E_FAIL_FAST=1: stopping after $$label" >&2; break; fi
fi
done 3<"$(E2E_IMAGES)"
if (( ran == 0 )); then echo "test-e2e: no label in $(E2E_IMAGES) matches E2E_ONLY='$(E2E_ONLY)'" >&2; exit 1; fi
if (( $${#failed[@]} )); then echo "test-e2e: $${#failed[@]} of $$ran distro(s) FAILED: $${failed[*]} (logs: tests/docker/logs/)" >&2; exit 1; fi
echo "test-e2e: all $$ran distro(s) passed"
clean: ## remove tests/docker/logs and .bats-tmp (never tests/vendor, never the Docker snapshot)
rm -rf -- tests/docker/logs .bats-tmp
+2371
View File
File diff suppressed because it is too large. Load diff
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/docker/cleanup.sh - restore the Docker image set recorded by snapshot.sh.
#
# Algorithm, in this order:
# a. read the "before" maps from the snapshot; compute the "after" maps the same way
# b. re-tag stolen names: every pre-existing repo:tag that now points at another
# image (or vanished) is pointed back with `docker tag <id_before> <repo:tag>`
# (e.g. a pre-existing archlinux:latest whose tag was taken by a newer pull)
# c. untag new names: every repo:tag not in the snapshot -> `docker rmi --no-prune`
# (only untags when the image has other names; deletes it on its last name)
# d. delete new ids: every image id not in the snapshot -> `docker rmi -f --no-prune`
# e. print a summary; exit 1 if a pre-existing repo:tag is missing or points elsewhere
#
# Safety rules:
# - containers are never stopped or removed; an image used by ANY container
# (running or stopped) is left in place and reported as left over;
# - an image id that is in the snapshot is never deleted: --no-prune keeps
# untagged parents, and the last name of a pre-existing image is never removed;
# - idempotent: running it again, or when nothing changed, is a no-op.
#
# usage: cleanup.sh [--dry-run]
# exit: 0 ok, 1 a pre-existing repo:tag is still missing/wrong, 2 usage/environment error
set -euo pipefail
prog=${0##*/}
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
tags_file=$here/images-before.tsv
ids_file=$here/image-ids-before.txt
usage() {
cat <<USAGE
usage: $prog [--dry-run]
Restore the Docker image set to the snapshot in
$tags_file
$ids_file
(re-tag stolen pre-existing tags, untag new tags, delete new image ids).
-n, --dry-run print the docker commands instead of running them; the plan is
simulated in memory and the summary shows the predicted end state
-h, --help this help
USAGE
}
dry_run=0
for arg in "$@"; do
case $arg in
-n | --dry-run) dry_run=1 ;;
-h | --help) usage; exit 0 ;;
*) printf '%s: unknown argument: %s\n' "$prog" "$arg" >&2; usage >&2; exit 2 ;;
esac
done
warn() { printf '%s: WARNING: %s\n' "$prog" "$*" >&2; }
die() { printf '%s: error: %s\n' "$prog" "$*" >&2; exit 2; }
short() { local h=${1#sha256:}; printf '%s' "${h:0:12}"; }
[[ -f $tags_file && -f $ids_file ]] ||
die "no snapshot (${tags_file##*/}, ${ids_file##*/} in $here); run tests/docker/snapshot.sh first"
command -v docker >/dev/null 2>&1 || die "docker CLI not found"
docker info >/dev/null 2>&1 || die "cannot talk to the Docker daemon"
# ---- state -----------------------------------------------------------------
declare -A before_tag=() # repo:tag -> id (snapshot)
declare -A before_id=() # id -> 1 (snapshot: every id, incl. untagged)
declare -A tag_id=() # repo:tag -> id (current; simulated under --dry-run)
declare -A tag_count=() # id -> number of repo:tag names on it
declare -A id_present=() # id -> 1
declare -A used_running=() # id -> "name (status), ..." running/paused/restarting containers
declare -A used_stopped=() # id -> "name (status), ..." created/exited/dead containers
declare -A id_names=() # id -> "repo:tag ..." as found at start (for messages only)
declare -a done_retag=() done_untag=() done_remove=() skipped=() failed=()
# keys_sorted NAME: keys of associative array NAME, one per line, C-sorted.
keys_sorted() {
local -n _a=$1
((${#_a[@]})) || return 0
printf '%s\n' "${!_a[@]}" | LC_ALL=C sort
}
# users_of ID: "running container(s): ...; stopped container(s): ..." or empty.
users_of() {
local s=
[[ -z ${used_running[$1]:-} ]] || s="running container(s): ${used_running[$1]}"
[[ -z ${used_stopped[$1]:-} ]] || s+="${s:+; }stopped container(s): ${used_stopped[$1]}"
printf '%s' "$s"
}
# run CMD...: print "+ CMD"; unless --dry-run, run it, show its output indented
# and leave a one-line error in $run_err on failure.
run_err=
run() {
local out rc=0
local -a lines
run_err=
printf ' + %s\n' "$*"
((!dry_run)) || return 0
out=$("$@" 2>&1) || rc=$?
if [[ -n $out ]]; then
mapfile -t lines <<<"$out"
printf ' %s\n' "${lines[@]}"
fi
((rc == 0)) || run_err=${out//$'\n'/ }
return "$rc"
}
skip() {
printf ' - skip: %s\n' "$*"
skipped+=("$*")
}
read_before() {
local rt id
while IFS=$'\t' read -r rt id || [[ -n $rt ]]; do
[[ -n $rt && $rt != \#* ]] || continue
if [[ ! $id =~ ^sha256:[0-9a-f]{64}$ ]]; then
warn "ignoring malformed line in ${tags_file##*/}: $rt $id"
continue
fi
before_id[$id]=1
[[ $rt == *'<none>'* ]] || before_tag[$rt]=$id
done <"$tags_file"
while read -r id || [[ -n $id ]]; do
[[ -n $id && $id != \#* ]] || continue
before_id[$id]=1
done <"$ids_file"
}
# read_after: (re)load the current image/tag/container state from the daemon.
read_after() {
local out rt id status name
local -a cids=()
tag_id=() tag_count=() id_present=() used_running=() used_stopped=()
out=$(docker images --no-trunc --format '{{.Repository}}:{{.Tag}}\t{{.ID}}') || die "docker images failed"
while IFS=$'\t' read -r rt id; do
[[ -n $rt && $rt != *'<none>'* ]] || continue
tag_id[$rt]=$id
tag_count[$id]=$((${tag_count[$id]:-0} + 1))
done <<<"$out"
out=$(docker images -a -q --no-trunc) || die "docker images -a failed"
while read -r id; do
[[ -z $id ]] || id_present[$id]=1
done <<<"$out"
# Containers can vanish between ps and inspect (other --rm runs finishing),
# so inspect errors for individual containers are ignored.
out=$(docker ps -a -q --no-trunc) || die "docker ps failed"
[[ -z $out ]] || mapfile -t cids <<<"$out"
((${#cids[@]})) || return 0
out=$(docker inspect --format '{{.Image}}{{"\t"}}{{.State.Status}}{{"\t"}}{{.Name}}' "${cids[@]}" 2>/dev/null) || true
while IFS=$'\t' read -r id status name; do
[[ -n $id ]] || continue
name="${name#/} ($status)"
case $status in
running | paused | restarting) used_running[$id]+="${used_running[$id]:+, }$name" ;;
*) used_stopped[$id]+="${used_stopped[$id]:+, }$name" ;;
esac
done <<<"$out"
}
# Model updates, applied after every successful (or, under --dry-run, planned)
# command so later steps see the resulting state.
model_tag() { # ID REF: REF now points at ID
local id=$1 rt=$2 old=${tag_id[$2]:-}
[[ -z $old ]] || tag_count[$old]=$((${tag_count[$old]:-1} - 1))
tag_id[$rt]=$id
tag_count[$id]=$((${tag_count[$id]:-0} + 1))
id_present[$id]=1
}
model_untag() { # REF: REF is gone; its image too if it was the last name and unused
local rt=$1 id=${tag_id[$1]} n
unset 'tag_id[$rt]'
n=$((${tag_count[$id]:-1} - 1))
tag_count[$id]=$n
if ((n <= 0)) && [[ -z ${before_id[$id]:-} && -z $(users_of "$id") ]]; then
unset 'id_present[$id]'
fi
}
model_remove() { # ID: image and all its names are gone
local id=$1 rt
unset 'id_present[$id]'
tag_count[$id]=0
for rt in "${!tag_id[@]}"; do
[[ ${tag_id[$rt]} != "$id" ]] || unset 'tag_id[$rt]'
done
}
# ---- b. re-tag pre-existing names that were taken or vanished ----------------
step_retag() {
local rt id cur from n=0
local -a keys
mapfile -t keys < <(keys_sorted before_tag)
for rt in "${keys[@]}"; do
id=${before_tag[$rt]}
cur=${tag_id[$rt]:-}
[[ $cur != "$id" ]] || continue
n=$((n + 1))
if [[ -z ${id_present[$id]:-} ]] && ! docker image inspect "$id" >/dev/null 2>&1; then
warn "!!! pre-existing image $(short "$id") for $rt NO LONGER EXISTS: cannot restore $rt (re-pull it by digest or re-snapshot)"
continue
fi
if run docker tag "$id" "$rt"; then
from="<missing>"
[[ -z $cur ]] || from=$(short "$cur")
done_retag+=("$rt: $from -> $(short "$id")")
model_tag "$id" "$rt"
else
failed+=("docker tag $(short "$id") $rt: $run_err")
fi
done
((n)) || echo " (nothing to do)"
}
# ---- c. untag names that did not exist before -------------------------------
step_untag() {
local rt id users n=0
local -a keys
mapfile -t keys < <(keys_sorted tag_id)
for rt in "${keys[@]}"; do
[[ -z ${before_tag[$rt]:-} ]] || continue
n=$((n + 1))
id=${tag_id[$rt]}
if ((${tag_count[$id]:-0} <= 1)); then
# Last name on this image: `docker rmi` would delete the image as well.
if [[ -n ${before_id[$id]:-} ]]; then
skip "tag $rt: only name of pre-existing image $(short "$id"), kept so that image is not deleted"
continue
fi
users=$(users_of "$id")
if [[ -n $users ]]; then
skip "tag $rt ($(short "$id")): image in use by $users"
continue
fi
fi
if run docker rmi --no-prune "$rt"; then
model_untag "$rt"
if [[ -n ${id_present[$id]:-} ]]; then
done_untag+=("$rt ($(short "$id"))")
else
done_untag+=("$rt ($(short "$id"), last name: image deleted)")
fi
else
failed+=("docker rmi $rt: $run_err")
fi
done
((n)) || echo " (nothing to do)"
}
# ---- d. delete image ids that did not exist before ---------------------------
step_remove() {
local id users label pass nskip=0
local -a keys todo=() retry=()
local -A err=()
mapfile -t keys < <(keys_sorted id_present)
for id in "${keys[@]}"; do
[[ -z ${before_id[$id]:-} ]] || continue
users=$(users_of "$id")
if [[ -n $users ]]; then
skip "image $(short "$id")${id_names[$id]:+ (${id_names[$id]})}: in use by $users"
nskip=$((nskip + 1))
continue
fi
todo+=("$id")
done
if ((${#todo[@]} == 0)); then
((nskip)) || echo " (nothing to do)"
return 0
fi
# With --no-prune a parent image can only go after its children: retry
# failures for as long as each pass makes progress.
for ((pass = 1; ${#todo[@]} > 0; pass++)); do
retry=()
for id in "${todo[@]}"; do
label="$(short "$id")${id_names[$id]:+ ${id_names[$id]}}"
if ((!dry_run)) && ! docker image inspect "$id" >/dev/null 2>&1; then
done_remove+=("$label (already gone)")
continue
fi
if run docker rmi -f --no-prune "$id"; then
done_remove+=("$label")
model_remove "$id"
else
retry+=("$id")
err[$id]=$run_err
fi
done
((${#retry[@]} < ${#todo[@]})) || break
todo=("${retry[@]}")
done
for id in "${retry[@]}"; do
failed+=("docker rmi -f $(short "$id"): ${err[$id]}")
done
}
# section TITLE ITEM...: print a summary section with its item count.
section() {
local title=$1
shift
printf '%-44s %d\n' "$title" "$#"
(($# == 0)) || printf ' %s\n' "$@"
}
# ---- main -------------------------------------------------------------------
read_before
read_after
while IFS= read -r rt; do
[[ -z $rt ]] || id_names[${tag_id[$rt]}]+="${id_names[${tag_id[$rt]}]:+ }$rt"
done < <(keys_sorted tag_id)
mode="cleanup"
((!dry_run)) || mode="cleanup DRY RUN (no docker command is executed)"
printf '== %s ==\n' "$mode"
printf 'snapshot: %d tags, %d ids (taken %s)\n' "${#before_tag[@]}" "${#before_id[@]}" \
"$(date -r "$tags_file" '+%Y-%m-%d %H:%M:%S %Z')"
printf 'now: %d tags, %d ids, %d image(s) used by containers\n' "${#tag_id[@]}" "${#id_present[@]}" \
"$(printf '%s\n' "${!used_running[@]}" "${!used_stopped[@]}" | sort -u | grep -c . || true)"
echo "-- b. re-tag pre-existing tags that were taken or vanished"
step_retag
((dry_run)) || read_after
echo "-- c. untag tags that are new since the snapshot"
step_untag
((dry_run)) || read_after
echo "-- d. delete image ids that are new since the snapshot"
step_remove
((dry_run)) || read_after
# ---- e. summary ---------------------------------------------------------------
declare -a bad=() left=() gone=()
while IFS= read -r rt; do
[[ -n $rt ]] || continue
id=${before_tag[$rt]}
cur=${tag_id[$rt]:-}
[[ $cur != "$id" ]] || continue
if [[ -z ${id_present[$id]:-} ]]; then
bad+=("$rt: snapshot image $(short "$id") no longer exists")
elif [[ -z $cur ]]; then
bad+=("$rt: tag missing (should be $(short "$id"))")
else
bad+=("$rt: points at $(short "$cur"), should be $(short "$id")")
fi
done < <(keys_sorted before_tag)
while IFS= read -r rt; do
[[ -n $rt && -z ${before_tag[$rt]:-} ]] || continue
users=$(users_of "${tag_id[$rt]}")
left+=("tag $rt -> $(short "${tag_id[$rt]}")${users:+ [in use by $users]}")
done < <(keys_sorted tag_id)
while IFS= read -r id; do
[[ -n $id && -z ${before_id[$id]:-} ]] || continue
users=$(users_of "$id")
left+=("image $(short "$id")${id_names[$id]:+ ${id_names[$id]}}${users:+ [in use by $users]}")
done < <(keys_sorted id_present)
while IFS= read -r id; do
[[ -n $id && -z ${id_present[$id]:-} ]] || continue
gone+=("$(short "$id")")
done < <(keys_sorted before_id)
echo
if ((dry_run)); then
echo "== summary (DRY RUN: nothing was changed; this is the predicted end state) =="
else
echo "== summary =="
fi
section "re-tagged (pre-existing tag restored):" "${done_retag[@]}"
section "untagged (new tag removed):" "${done_untag[@]}"
section "removed (new image deleted):" "${done_remove[@]}"
section "skipped:" "${skipped[@]}"
section "failed:" "${failed[@]}"
section "left over (new since snapshot, still here):" "${left[@]}"
section "pre-existing untagged ids gone (info only):" "${gone[@]}"
section "PRE-EXISTING TAGS MISSING OR WRONG:" "${bad[@]}"
if ((${#bad[@]})); then
echo "result: FAILED - ${#bad[@]} pre-existing repo:tag(s) not restored"
exit 1
fi
echo "result: OK - all ${#before_tag[@]} pre-existing repo:tags point at their snapshot ids"
+26
View File
@@ -0,0 +1,26 @@
# E2E distro matrix for `make test-e2e`: one "<label> <image>" per line.
# Blank lines and lines starting with # are ignored.
# <label> is passed to the container as SU_DISTRO and names the log file
# tests/docker/logs/<label>.log; run a single one with `make test-e2e E2E_ONLY=<label>`.
#
# Rocky Linux (see discovery/rpm.md, section 0.2):
# - the Docker Hub *library* image `rockylinux:9` is a stale Rocky 9.3 build from
# 2023-11-28 (96 pending upgrades on day one), so the upstream-maintained
# `rockylinux/rockylinux:9` (Rocky 9.8, same image as quay.io/rockylinux/rockylinux:9)
# is used instead;
# - `rockylinux:10` does not exist in the library at all; `rockylinux/rockylinux:10`
# is the official Rocky 10 image (same image id as quay.io/rockylinux/rockylinux:10).
#
# label image
debian-12 debian:12
debian-13 debian:13
ubuntu-22.04 ubuntu:22.04
ubuntu-24.04 ubuntu:24.04
ubuntu-latest ubuntu:latest
archlinux archlinux:latest
almalinux-9 almalinux:9
almalinux-10 almalinux:10
rockylinux-9 rockylinux/rockylinux:9
rockylinux-10 rockylinux/rockylinux:10
opensuse-tumbleweed opensuse/tumbleweed:latest
opensuse-leap opensuse/leap:latest
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/docker/run-e2e.sh - run the E2E suite in ONE distro container.
#
# usage: run-e2e.sh <label> <image>
# e.g. run-e2e.sh debian-12 debian:12
#
# The repository is mounted read-only at /work and tests/e2e/entrypoint.sh is run
# with bash inside <image>. SU_DISTRO=<label> tells the suite which distro it is
# on. Output is shown and also written to tests/docker/logs/<label>.log.
# Exit status is the container's (i.e. the suite's) exit status.
set -euo pipefail
prog=${0##*/}
if (($# != 2)); then
printf 'usage: %s <label> <image>\n' "$prog" >&2
exit 1
fi
label=$1 image=$2
if [[ ! $label =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
printf '%s: invalid label %q (allowed: letters, digits, . _ -)\n' "$prog" "$label" >&2
exit 1
fi
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." # repository root
if [[ ! -f tests/e2e/entrypoint.sh ]]; then
printf '%s: tests/e2e/entrypoint.sh not found in %s\n' "$prog" "$PWD" >&2
exit 1
fi
log_dir=tests/docker/logs
log=$log_dir/$label.log
mkdir -p -- "$log_dir"
printf '== e2e %s (%s) %s ==\n' "$label" "$image" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" | tee -- "$log"
status=0
docker run --rm -i \
-v "$PWD":/work:ro -w /work \
-e LC_ALL=C -e DEBIAN_FRONTEND=noninteractive -e SU_DISTRO="$label" \
"$image" bash tests/e2e/entrypoint.sh 2>&1 | tee -a -- "$log" || status=${PIPESTATUS[0]}
if ((status == 0)); then result=PASS; else result=FAIL; fi
printf '== e2e %s: %s (exit %d), log: %s ==\n' "$label" "$result" "$status" "$log" | tee -a -- "$log"
exit "$status"
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/docker/snapshot.sh - record the host's Docker image set BEFORE tests run,
# so tests/docker/cleanup.sh can restore it afterwards.
#
# Writes, next to this script:
# images-before.tsv repo:tag<TAB>image-id (docker images --no-trunc, sorted)
# image-ids-before.txt every image id, incl. untagged (docker images -a -q --no-trunc, sort -u)
#
# An existing snapshot is NEVER overwritten without --force: running `make test`
# twice must not "launder" images pulled by the first run into the baseline.
# Without --force an existing snapshot is kept and the script exits 0, so
# `make docker-snapshot` / `make test` can call it every time.
#
# usage: snapshot.sh [--force]
set -euo pipefail
prog=${0##*/}
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
tags_file=$here/images-before.tsv
ids_file=$here/image-ids-before.txt
usage() {
cat <<USAGE
usage: $prog [--force]
Record the current Docker image set to
$tags_file
$ids_file
An existing snapshot is kept unless --force is given. Only use --force when
the images currently on the host are exactly the ones you want to keep: every
image present at snapshot time is treated as pre-existing by cleanup.sh.
USAGE
}
die() {
printf '%s: error: %s\n' "$prog" "$*" >&2
exit 1
}
force=0
for arg in "$@"; do
case $arg in
--force) force=1 ;;
-h | --help) usage; exit 0 ;;
*) printf '%s: unknown argument: %s\n' "$prog" "$arg" >&2; usage >&2; exit 1 ;;
esac
done
if [[ -e $tags_file && -e $ids_file ]]; then
if ((!force)); then
printf '%s: snapshot already exists (taken %s), NOT overwriting it.\n' \
"$prog" "$(date -r "$tags_file" '+%Y-%m-%d %H:%M:%S %Z')" >&2
printf '%s: to re-baseline on purpose: %s --force\n' "$prog" "$0" >&2
exit 0
fi
elif [[ -e $tags_file || -e $ids_file ]]; then
((force)) || die "incomplete snapshot (only one of ${tags_file##*/}, ${ids_file##*/} exists); inspect it, then re-run with --force"
fi
docker info >/dev/null 2>&1 || die "cannot talk to the Docker daemon (is it running, are you in the docker group?)"
tmp_tags=$(mktemp "$tags_file.XXXXXX.tmp")
tmp_ids=$(mktemp "$ids_file.XXXXXX.tmp")
trap 'rm -f -- "$tmp_tags" "$tmp_ids"' EXIT
# Exactly the commands of the original manual snapshot (plain `sort`, so the
# order follows the caller's locale, byte-identical to running them by hand).
# Consumers must not depend on the order: cleanup.sh reads both files into maps.
docker images --no-trunc --format '{{.Repository}}:{{.Tag}}\t{{.ID}}' | sort >"$tmp_tags"
docker images -a -q --no-trunc | sort -u >"$tmp_ids"
if [[ -e $tags_file && -e $ids_file ]]; then
# --force over an existing snapshot: show what the baseline gains, so an
# accidental re-baseline after a test run is at least visible.
gained_tags=$(LC_ALL=C comm -13 <(LC_ALL=C sort "$tags_file") <(LC_ALL=C sort "$tmp_tags"))
gained_ids=$(LC_ALL=C comm -13 <(LC_ALL=C sort -u "$ids_file") <(LC_ALL=C sort -u "$tmp_ids"))
if [[ -n $gained_tags || -n $gained_ids ]]; then
printf '%s: WARNING: the new baseline adopts images that were not in the old one:\n' "$prog" >&2
if [[ -n $gained_tags ]]; then mapfile -t lines <<<"$gained_tags"; printf ' tag %s\n' "${lines[@]}" >&2; fi
if [[ -n $gained_ids ]]; then mapfile -t lines <<<"$gained_ids"; printf ' id %s\n' "${lines[@]}" >&2; fi
fi
fi
chmod 0644 "$tmp_tags" "$tmp_ids"
mv -f -- "$tmp_tags" "$tags_file"
mv -f -- "$tmp_ids" "$ids_file"
trap - EXIT
printf '%s: snapshot written: %d tags -> %s, %d ids -> %s\n' "$prog" \
"$(wc -l <"$tags_file")" "${tags_file#"$PWD"/}" "$(wc -l <"$ids_file")" "${ids_file#"$PWD"/}"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/e2e/entrypoint.sh - runs INSIDE one distro container (started by tests/docker/run-e2e.sh,
# as root, repository mounted read-only at /work, SU_DISTRO=<label>):
# 1. installs the applet as /usr/local/bin/skeptical-updater
# 2. builds the deterministic fixture for the package-manager family (su-dummy-{a,b,c}
# 1.0 installed, 2.0 offered by a local repo; the distro's own repos are disabled;
# su-dummy-orphan: an unrelated, automatically installed package nothing needs)
# 3. runs tests/e2e/*.bats with the vendored bats-core against the REAL package manager
# Exit status: the bats status (or 1 if the setup failed).
set -euo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." # repository root (read-only)
log() { printf '== entrypoint: %s\n' "$*"; }
die() {
printf '== entrypoint: ERROR: %s\n' "$*" >&2
exit 1
}
[[ $(id -u) -eq 0 ]] || die "must run as root"
t0=$SECONDS
install -m 0755 skeptical-updater /usr/local/bin/skeptical-updater
if command -v pacman >/dev/null 2>&1; then
family=pacman
# FIXTURE_SYNC=1: a system whose sync database was synced once (--no-refresh lists from it)
FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1 FIXTURE_SYNC=1 bash tests/e2e/fixtures/pacman.sh
elif command -v apt-get >/dev/null 2>&1; then
family=apt
SU_FIXTURE_OFFLINE=1 SU_FIXTURE_ORPHAN=1 SU_FIXTURE_B_AUTO=1 bash tests/e2e/fixtures/apt.sh
elif command -v zypper >/dev/null 2>&1 || command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then
family=rpm
SU_FIXTURE_ISOLATE=1 SU_FIXTURE_ORPHAN=1 SU_FIXTURE_B_AUTO=1 bash tests/e2e/fixtures/rpm.sh
else
die "no supported package manager in this image"
fi
export SU_FAMILY=$family
# shellcheck source=/dev/null
log "${SU_DISTRO:-?}: family=$family, fixture ready after $((SECONDS - t0))s ($(. /etc/os-release && printf '%s' "$PRETTY_NAME"), bash $BASH_VERSION)"
TMPDIR=$(mktemp -d /tmp/su-e2e.XXXXXX)
export TMPDIR
# bats needs find(1) to expand a directory argument; openSUSE base images may lack it
if command -v find >/dev/null 2>&1; then
targets=(tests/e2e)
else
targets=(tests/e2e/*.bats)
fi
status=0
bash tests/vendor/bats-core/bin/bats --tap "${targets[@]}" || status=$?
log "${SU_DISTRO:-?}: bats exited $status after $((SECONDS - t0))s"
exit "$status"
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# apt.sh - APT e2e fixture for skeptical-updater (Debian/Ubuntu containers).
# Copy of discovery/apt/fixture.sh; the only changes are the SU_FIXTURE_STRICT_DEP knob
# (su-dummy-a 2.0 depends on plain su-dummy-b by default, like the pacman and rpm
# fixtures, so excluding su-dummy-b does not keep su-dummy-a back), SU_FIXTURE_ORPHAN and
# SU_FIXTURE_B_AUTO.
#
# Builds a local *flat* APT repository at /srv/su-repo containing dummy packages
# and installs the old (1.0) versions, so that after `apt-get update` the package
# manager reports deterministic upgrades:
#
# su-dummy-a 1.0 -> 2.0 (Depends: su-dummy-b, ships a conffile;
# SU_FIXTURE_STRICT_DEP=1: 2.0 Depends: su-dummy-b (>= 2.0))
# su-dummy-b 1.0 -> 2.0 (dependency of su-dummy-a)
# su-dummy-c 1.0 -> 2.0 (standalone)
#
# Optional extras (SU_FIXTURE_EXTRA=1) for "kept back" / removal / virtual-package
# scenarios:
# su-dummy-d 1.0 -> 2.0 2.0 adds a NEW dependency (su-dummy-newdep, not installed)
# => plain `upgrade` keeps it back, `--with-new-pkgs upgrade`
# and `dist-upgrade` install it
# su-dummy-e 1.0 -> 2.0 2.0 Conflicts: su-dummy-f (installed, no 2.0 exists)
# => only `dist-upgrade` can do it (removes su-dummy-f) - except with apt 3.x
# solver3 (ubuntu:latest = 26.04) which refuses to remove the manually
# installed su-dummy-f and keeps su-dummy-e back even for dist-upgrade
# su-dummy-f 1.0 installed, never upgradable
# su-dummy-w 1.0 -> 2.0 Depends: su-virtual | su-dummy-alt (virtual + alternative)
# su-dummy-v 1.0 Provides: su-virtual
# (+ su-dummy-newdep 1.0 and su-dummy-alt 1.0: only in the repo, never pre-installed)
#
# Environment knobs (all optional):
# SU_REPO_DIR=/srv/su-repo where the repo is created (wiped first; must match /srv/su-*, /tmp/su-*, /var/tmp/su-*)
# SU_OLD_DIR=/srv/su-old where the 1.0 .debs are kept (NOT in the repo)
# SU_FIXTURE_INDEXER=shell shell | scanpackages | ftparchive
# shell = no extra tools needed (dpkg-deb -f + sha256sum)
# scanpackages = dpkg-scanpackages (installs dpkg-dev if missing)
# ftparchive = apt-ftparchive (installs apt-utils if missing)
# SU_FIXTURE_OFFLINE=1 disable the distro's real sources (hermetic: no network at all)
# SU_FIXTURE_EXTRA=1 add the extra scenario packages listed above
# SU_FIXTURE_BAD=1 add su-dummy-bad: 1.0 installed, 2.0 has a postinst that fails
# (apt-get upgrade then exits 100) - for error-path tests
# SU_FIXTURE_STRICT_DEP=1 su-dummy-a 2.0 depends on "su-dummy-b (>= 2.0)" (as in discovery/apt):
# holding su-dummy-b then also keeps su-dummy-a back
# SU_FIXTURE_ORPHAN=1 also install su-dummy-orphan 1.0 marked automatically installed with
# nothing depending on it (a pre-existing autoremove candidate)
# SU_FIXTURE_B_AUTO=1 mark su-dummy-b automatically installed (a dependency of su-dummy-a,
# like the pacman fixture's --asdeps)
#
# Must run as root inside a throwaway container, e.g.:
# docker run --rm -i -v "$PWD/discovery/apt:/d:ro" debian:12 bash /d/fixture.sh
set -euo pipefail
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
REPO_DIR=${SU_REPO_DIR:-/srv/su-repo}
OLD_DIR=${SU_OLD_DIR:-/srv/su-old}
INDEXER=${SU_FIXTURE_INDEXER:-shell}
OFFLINE=${SU_FIXTURE_OFFLINE:-0}
EXTRA=${SU_FIXTURE_EXTRA:-0}
BAD=${SU_FIXTURE_BAD:-0}
STRICT_DEP=${SU_FIXTURE_STRICT_DEP:-0}
ORPHAN=${SU_FIXTURE_ORPHAN:-0}
B_AUTO=${SU_FIXTURE_B_AUTO:-0}
SOURCES_FILE=/etc/apt/sources.list.d/su-repo.list
T0=$(date +%s)
log() { printf '[fixture] %s\n' "$*"; }
die() { printf '[fixture] ERROR: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "must run as root"
command -v apt-get >/dev/null || die "apt-get not found (not a Debian/Ubuntu image?)"
command -v dpkg-deb >/dev/null || die "dpkg-deb not found"
case $INDEXER in shell|scanpackages|ftparchive) ;; *) die "bad SU_FIXTURE_INDEXER=$INDEXER" ;; esac
# the directories are wiped with rm -rf: only accept obviously fixture-owned locations
for d in "$REPO_DIR" "$OLD_DIR"; do
case $d in /srv/su-*|/tmp/su-*|/var/tmp/su-*) ;; *) die "refusing to wipe '$d' (must match /srv/su-*, /tmp/su-* or /var/tmp/su-*)" ;; esac
done
# ---------------------------------------------------------------- build debs
# mkdeb NAME VERSION OUTDIR [extra control line]...
# If $MKDEB_POSTINST is set it becomes the body of the package's postinst script.
mkdeb() {
local name=$1 ver=$2 out=$3 root line
shift 3
root=$(mktemp -d)
mkdir -p "$root/DEBIAN" "$root/usr/share/su-dummy" "$root/etc/su-dummy"
{
printf 'Package: %s\nVersion: %s\nSection: misc\nPriority: optional\n' "$name" "$ver"
printf 'Architecture: all\nMaintainer: Skeptical Updater Fixture <su-fixture@example.invalid>\n'
for line in "$@"; do printf '%s\n' "$line"; done
printf 'Description: dummy package %s for skeptical-updater tests\n' "$name"
printf ' This package only exists to exercise the package manager.\n'
printf ' .\n'
printf ' It ships one data file and one conffile (version %s).\n' "$ver"
} > "$root/DEBIAN/control"
echo "$ver" > "$root/usr/share/su-dummy/$name"
echo "version=$ver" > "$root/etc/su-dummy/$name.conf"
echo "/etc/su-dummy/$name.conf" > "$root/DEBIAN/conffiles"
if [ -n "${MKDEB_POSTINST:-}" ]; then
printf '#!/bin/sh\n%s\n' "$MKDEB_POSTINST" > "$root/DEBIAN/postinst"
chmod 755 "$root/DEBIAN/postinst"
fi
mkdir -p "$out"
# -Zgzip: readable by every dpkg in the supported images; --root-owner-group: files owned by root
dpkg-deb --root-owner-group -Zgzip --build "$root" "$out/${name}_${ver}_all.deb" >/dev/null
rm -rf "$root"
}
rm -rf "$REPO_DIR" "$OLD_DIR"
mkdir -p "$REPO_DIR" "$OLD_DIR"
# old versions (installed straight away with dpkg, NOT published in the repo)
mkdeb su-dummy-a 1.0 "$OLD_DIR" 'Depends: su-dummy-b'
mkdeb su-dummy-b 1.0 "$OLD_DIR"
mkdeb su-dummy-c 1.0 "$OLD_DIR"
# new versions (published in the repo)
if [ "$STRICT_DEP" = 1 ]; then
mkdeb su-dummy-a 2.0 "$REPO_DIR" 'Depends: su-dummy-b (>= 2.0)'
else
mkdeb su-dummy-a 2.0 "$REPO_DIR" 'Depends: su-dummy-b'
fi
mkdeb su-dummy-b 2.0 "$REPO_DIR"
mkdeb su-dummy-c 2.0 "$REPO_DIR"
if [ "$ORPHAN" = 1 ]; then
mkdeb su-dummy-orphan 1.0 "$OLD_DIR" # installed, never upgradable
fi
if [ "$EXTRA" = 1 ]; then
mkdeb su-dummy-d 1.0 "$OLD_DIR"
mkdeb su-dummy-d 2.0 "$REPO_DIR" 'Depends: su-dummy-newdep'
mkdeb su-dummy-newdep 1.0 "$REPO_DIR" # only in the repo, never pre-installed
mkdeb su-dummy-e 1.0 "$OLD_DIR"
mkdeb su-dummy-e 2.0 "$REPO_DIR" 'Conflicts: su-dummy-f'
mkdeb su-dummy-f 1.0 "$OLD_DIR"
mkdeb su-dummy-v 1.0 "$OLD_DIR" 'Provides: su-virtual'
mkdeb su-dummy-alt 1.0 "$REPO_DIR" # alternative target, not installed
mkdeb su-dummy-w 1.0 "$OLD_DIR" 'Depends: su-virtual | su-dummy-alt'
mkdeb su-dummy-w 2.0 "$REPO_DIR" 'Depends: su-virtual | su-dummy-alt'
fi
if [ "$BAD" = 1 ]; then
mkdeb su-dummy-bad 1.0 "$OLD_DIR"
# shellcheck disable=SC2016 # the postinst body is expanded by the package's /bin/sh, not here
MKDEB_POSTINST='[ "$1" = configure ] && { echo "su-dummy-bad: postinst failing on purpose" >&2; exit 1; }; exit 0' \
mkdeb su-dummy-bad 2.0 "$REPO_DIR"
fi
# ---------------------------------------------------------------- real sources
if [ "$OFFLINE" = 1 ]; then
mkdir -p /srv/su-disabled-sources
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do
[ -e "$f" ] || continue
case $f in "$SOURCES_FILE") continue ;; esac
mv "$f" "/srv/su-disabled-sources/$(basename "$f")"
done
log "OFFLINE: real sources moved to /srv/su-disabled-sources"
fi
# ---------------------------------------------------------------- indexing
install_tool() { # install_tool PACKAGE : needs the real mirrors
[ "$OFFLINE" = 1 ] && die "SU_FIXTURE_INDEXER=$INDEXER needs network to install $1; use shell with OFFLINE"
log "installing $1 (needs network)"
apt-get update -qq >/dev/null
apt-get install -y -qq --no-install-recommends "$1" >/dev/null
}
index_shell() {
local f
: > Packages
for f in ./*.deb; do
f=${f#./}
dpkg-deb -f "$f" >> Packages
printf 'Filename: ./%s\nSize: %s\nMD5sum: %s\nSHA256: %s\n\n' \
"$f" "$(stat -c %s "$f")" "$(md5sum < "$f" | cut -d' ' -f1)" "$(sha256sum < "$f" | cut -d' ' -f1)" >> Packages
done
{
printf 'Origin: su-fixture\nLabel: su-fixture\nSuite: su-repo\nCodename: su-repo\n'
printf 'Date: %s\n' "$(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
printf 'SHA256:\n %s %s Packages\n' "$(sha256sum < Packages | cut -d' ' -f1)" "$(stat -c %s Packages)"
} > Release
}
index_scanpackages() {
command -v dpkg-scanpackages >/dev/null || install_tool dpkg-dev
dpkg-scanpackages --multiversion . /dev/null > Packages 2>/dev/null
# dpkg-scanpackages does not write a Release file; make a minimal one
{
printf 'Origin: su-fixture\nLabel: su-fixture\nSuite: su-repo\nCodename: su-repo\n'
printf 'Date: %s\n' "$(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
printf 'SHA256:\n %s %s Packages\n' "$(sha256sum < Packages | cut -d' ' -f1)" "$(stat -c %s Packages)"
} > Release
}
index_ftparchive() {
command -v apt-ftparchive >/dev/null || install_tool apt-utils
apt-ftparchive packages . > Packages
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=su-fixture \
-o APT::FTPArchive::Release::Label=su-fixture \
-o APT::FTPArchive::Release::Suite=su-repo \
-o APT::FTPArchive::Release::Codename=su-repo \
release . > Release
}
( cd "$REPO_DIR" && "index_$INDEXER" )
log "repo $REPO_DIR indexed with '$INDEXER': $(grep -c '^Package:' "$REPO_DIR/Packages") packages"
echo "deb [trusted=yes] file:$REPO_DIR ./" > "$SOURCES_FILE"
# ---------------------------------------------------------------- install old versions
# --force-confnew + </dev/null: never block on a conffile prompt if the fixture is re-run in a dirty container
dpkg --force-confnew -i "$OLD_DIR"/*.deb >/dev/null </dev/null
if [ "$ORPHAN" = 1 ]; then
apt-mark auto su-dummy-orphan >/dev/null
fi
if [ "$B_AUTO" = 1 ]; then
apt-mark auto su-dummy-b >/dev/null
fi
log "installed (old): $(dpkg-query -W -f '${db:Status-Abbrev} ${Package}=${Version}\n' 'su-dummy-*' 2>/dev/null | awk '$1 == "ii" {printf "%s ", $2}')"
# ---------------------------------------------------------------- refresh + verify
# drop stale lists of our own repo first: apt compares mtimes of file: indexes with 1s resolution,
# so a repo regenerated within the same second would otherwise be seen as "unchanged"
rm -f /var/lib/apt/lists/"$(printf '%s' "$REPO_DIR" | tr / _)"_*
apt-get update -qq >/dev/null
fail=0
upg=$(apt list --upgradable 2>/dev/null | awk -F/ '/^su-dummy-/{print $1}' | sort | tr '\n' ' ')
for p in su-dummy-a su-dummy-b su-dummy-c; do
case " $upg" in *" $p "*) ;; *) log "MISSING from upgradable: $p"; fail=1 ;; esac
done
sim=$(apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk '/^Inst su-dummy-(a|b|c) /{n++} END{print n+0}')
[ "$sim" -ge 3 ] || { log "simulated upgrade only covers $sim of 3 core packages"; fail=1; }
[ "$fail" -eq 0 ] || die "fixture verification failed"
log "upgradable dummy packages: $upg"
if [ "$ORPHAN" = 1 ]; then
apt-get -s autoremove 2>/dev/null | grep -q '^Remv su-dummy-orphan ' || die "su-dummy-orphan is not an autoremove candidate"
log "pre-existing orphan (autoremove candidate): su-dummy-orphan"
fi
real=$(apt list --upgradable 2>/dev/null | awk -F/ 'NR>1 && $1 !~ /^su-dummy-/{print $1}' | tr '\n' ' ')
if [ -n "$real" ]; then
log "NOTE: real-world upgradable packages present in this base image: $real"
else
log "NOTE: no real-world upgradable packages in this base image right now"
fi
# shellcheck source=/dev/null
log "OK in $(( $(date +%s) - T0 ))s (image: $(. /etc/os-release; echo "$PRETTY_NAME"))"
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Deterministic pacman fixture for skeptical-updater E2E tests.
# (tests/e2e/fixtures/pacman.sh: copy of discovery/pacman/fixture.sh plus the FIXTURE_ORPHAN
# knob; tests/e2e/entrypoint.sh runs it with FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1.)
#
# Creates three dummy packages installed at 1.0 and a local pacman repo
# ([su-dummy], file:///srv/su-repo) that offers them at 2.0:
#
# su-dummy-a depends=(su-dummy-b) installed explicitly
# su-dummy-b installed as a dependency (--asdeps)
# su-dummy-c installed explicitly
#
# It also pre-wires /etc/pacman.d/ignorepkg (empty) and the matching
# "Include" line inside the [options] section of /etc/pacman.conf, i.e. the
# same layout that pman expects (IgnorePkg = <pkg> lines).
#
# Run as root inside an Arch container:
# docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
#
# Environment (all optional):
# FIXTURE_BUILDER tar (default) | makepkg
# tar : hand-built .pkg.tar.zst (.PKGINFO + bsdtar). No network, no
# fakeroot, no build user. ~0.1 s.
# makepkg : real makepkg run as the unprivileged user "su-builder" via
# runuser. Needs fakeroot (installed with `pacman -Sy fakeroot`
# if missing, so it needs network). Slower.
# FIXTURE_ISOLATE 0 (default) | 1
# 1 comments out every repo section except [su-dummy], so `pacman -Qu`
# reports EXACTLY the three dummy packages (no real upgrades, no network
# needed after the fixture is built).
# FIXTURE_STRICT_DEP 0 (default) | 1
# 1 makes su-dummy-a 2.0 depend on "su-dummy-b>=2.0" (instead of plain
# "su-dummy-b"), so excluding su-dummy-b from an upgrade (--ignore /
# IgnorePkg) while upgrading su-dummy-a fails with unsatisfied deps.
# FIXTURE_ORPHAN 0 (default) | 1 -> also install su-dummy-orphan 1.0 --asdeps with nothing
# requiring it (a pre-existing orphan, `pacman -Qdtq` lists it)
# FIXTURE_REPO repo directory (default /srv/su-repo)
# FIXTURE_SYNC 0 (default) | 1 -> run `pacman -Sy` at the end
#
# The fixture deliberately does NOT sync by default: the tool under test is
# expected to perform the refresh itself (pacman -Sy), after which
# `pacman -Qu` lists su-dummy-{a,b,c} (1.0-1 -> 2.0-1).
export LC_ALL=C
FIXTURE_BUILDER=${FIXTURE_BUILDER:-tar}
FIXTURE_ISOLATE=${FIXTURE_ISOLATE:-0}
FIXTURE_STRICT_DEP=${FIXTURE_STRICT_DEP:-0}
FIXTURE_REPO=${FIXTURE_REPO:-/srv/su-repo}
FIXTURE_SYNC=${FIXTURE_SYNC:-0}
FIXTURE_ORPHAN=${FIXTURE_ORPHAN:-0}
FIXTURE_REPO_NAME=su-dummy
FIXTURE_EPOCH=1700000000 # fixed build date => reproducible tarballs
fx_log() { printf 'fixture: %s\n' "$*" >&2; }
fx_die() { fx_log "ERROR: $*"; exit 1; }
# pacman wrapper: stdout dropped, the harmless "database file for X does not
# exist" warnings (fresh image, no sync yet) filtered, real errors kept, exit
# status preserved.
fx_pacman() {
local rc=0
pacman "$@" 2>&1 >/dev/null | { grep -v "^warning: database file for" >&2 || true; } || rc=$?
return $rc
}
# fx_build_tar <outdir> <name> <pkgver> <pkgdesc> [depend...]
# Hand-built package: only a .PKGINFO and the payload are required by pacman -U
# and repo-add (no .MTREE / .BUILDINFO needed; `pacman -Qk` is happy too).
fx_build_tar() {
local out=$1 name=$2 ver=$3 desc=$4; shift 4
local w payload
w=$(mktemp -d)
payload="usr/share/su-dummy/$name"
mkdir -p "$w/$payload"
printf '%s\n' "$ver" >"$w/$payload/VERSION"
{
printf 'pkgname = %s\n' "$name"
printf 'pkgbase = %s\n' "$name"
printf 'pkgver = %s-1\n' "$ver"
printf 'pkgdesc = %s\n' "$desc"
printf 'url = https://example.invalid/su-dummy\n'
printf 'builddate = %s\n' "$FIXTURE_EPOCH"
printf 'packager = su-fixture <su-fixture@example.invalid>\n'
printf 'size = %s\n' "$(wc -c <"$w/$payload/VERSION")"
printf 'arch = any\n'
printf 'license = custom\n'
local d
for d in "$@"; do printf 'depend = %s\n' "$d"; done
} >"$w/.PKGINFO"
(cd "$w" && bsdtar --zstd --uid 0 --gid 0 --uname root --gname root \
-cf "$out/$name-$ver-1-any.pkg.tar.zst" .PKGINFO usr)
rm -rf "$w"
}
# fx_build_makepkg <outdir> <name> <pkgver> <pkgdesc> [depend...]
# Real makepkg. makepkg refuses to run as root, so run it as an unprivileged
# user with `runuser -u` (no sudo needed; `su -s /bin/bash -c` also works).
fx_build_makepkg() {
local out=$1 name=$2 ver=$3 desc=$4; shift 4
local w depends="" d
for d in "$@"; do depends+="'$d' "; done
w=/home/su-builder/build/$name-$ver
mkdir -p "$w"
cat >"$w/PKGBUILD" <<EOF
pkgname=$name
pkgver=$ver
pkgrel=1
pkgdesc='$desc'
arch=('any')
url='https://example.invalid/su-dummy'
license=('custom')
options=('!debug' '!strip') # arch=any payload: no debugedit/binutils needed
depends=($depends)
package() {
install -Dm644 /dev/stdin "\$pkgdir/usr/share/su-dummy/\$pkgname/VERSION" <<<"\$pkgver"
}
EOF
chown -R su-builder: "$w"
runuser -u su-builder -- env \
PKGDEST="$out" SOURCE_DATE_EPOCH="$FIXTURE_EPOCH" \
PACKAGER='su-fixture <su-fixture@example.invalid>' \
bash -c "cd '$w' && makepkg --force --nodeps --noconfirm --nocolor >/dev/null 2>&1" \
|| { runuser -u su-builder -- bash -c "cd '$w' && makepkg --force --nodeps --noconfirm --nocolor" >&2; fx_die "makepkg failed for $name"; }
}
fx_prepare_makepkg() {
if ! command -v fakeroot >/dev/null; then
fx_log "makepkg builder: installing fakeroot (needs network)"
fx_pacman -Sy --noconfirm --needed fakeroot
fi
id su-builder >/dev/null 2>&1 || useradd --create-home su-builder
}
fx_build() { # fx_build <outdir> <name> <ver> <desc> [dep...]
case $FIXTURE_BUILDER in
tar) fx_build_tar "$@" ;;
makepkg) fx_build_makepkg "$@" ;;
*) fx_die "unknown FIXTURE_BUILDER=$FIXTURE_BUILDER" ;;
esac
}
fx_main() {
set -euo pipefail
[ "$(id -u)" -eq 0 ] || fx_die "must run as root"
local old new
old=$(mktemp -d); new=$FIXTURE_REPO
rm -rf "$FIXTURE_REPO"; mkdir -p "$FIXTURE_REPO"
chmod 755 "$old"
if [ "$FIXTURE_BUILDER" = makepkg ]; then
fx_prepare_makepkg
chown su-builder: "$old" "$new" # PKGDEST dirs must be writable by the build user
fi
# 1.0 packages (installed) and 2.0 packages (offered by the repo)
fx_build "$old" su-dummy-a 1.0 'Skeptical-updater dummy package A' su-dummy-b
fx_build "$old" su-dummy-b 1.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build "$old" su-dummy-c 1.0 'Skeptical-updater dummy package C (independent)'
local a2_dep=su-dummy-b
[ "$FIXTURE_STRICT_DEP" = 1 ] && a2_dep='su-dummy-b>=2.0'
fx_build "$new" su-dummy-a 2.0 'Skeptical-updater dummy package A' "$a2_dep"
fx_build "$new" su-dummy-b 2.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build "$new" su-dummy-c 2.0 'Skeptical-updater dummy package C (independent)'
# Local repository database (repo-add ships with pacman)
(cd "$FIXTURE_REPO" && repo-add --quiet "$FIXTURE_REPO_NAME.db.tar.gz" ./*.pkg.tar.zst)
chmod -R a+rX "$FIXTURE_REPO"; chmod a+rx /srv 2>/dev/null || true
# pacman.conf: register the repo (appended at the end => lowest priority)
if ! grep -q "^\[$FIXTURE_REPO_NAME\]" /etc/pacman.conf; then
{
printf '\n[%s]\n' "$FIXTURE_REPO_NAME"
printf 'SigLevel = Optional TrustAll\n'
printf 'Server = file://%s\n' "$FIXTURE_REPO"
} >>/etc/pacman.conf
fi
# pman-style ignore file, included from the [options] section
# (inserted right after the FIRST "[options]" header; appending at EOF would
# land in whatever section happens to be last).
: >/etc/pacman.d/ignorepkg
if ! grep -q '^Include *= */etc/pacman.d/ignorepkg' /etc/pacman.conf; then
sed -i '0,/^\[options\]/s||[options]\nInclude = /etc/pacman.d/ignorepkg|' /etc/pacman.conf
fi
if [ "$FIXTURE_ISOLATE" = 1 ]; then
# comment out all repos except [su-dummy]; keep [options] intact
awk -v keep="$FIXTURE_REPO_NAME" '
/^\[.*\][[:space:]]*$/ { sec=$0; sub(/^\[/,"",sec); sub(/\].*$/,"",sec); active = (sec=="options" || sec==keep) }
{ if (sec != "" && !active && $0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*$/) print "#" $0; else print }
' /etc/pacman.conf >/etc/pacman.conf.new && mv /etc/pacman.conf.new /etc/pacman.conf
fi
# Install the 1.0 packages: b as a dependency, a and c explicit.
fx_pacman -U --noconfirm "$old"/su-dummy-a-1.0-1-any.pkg.tar.zst \
"$old"/su-dummy-b-1.0-1-any.pkg.tar.zst \
"$old"/su-dummy-c-1.0-1-any.pkg.tar.zst
fx_pacman -D --asdeps su-dummy-b
if [ "$FIXTURE_ORPHAN" = 1 ]; then
fx_build "$old" su-dummy-orphan 1.0 'Skeptical-updater dummy package ORPHAN (nothing requires it)'
fx_pacman -U --noconfirm --asdeps "$old"/su-dummy-orphan-1.0-1-any.pkg.tar.zst
pacman -Qdtq | grep -qx su-dummy-orphan || fx_die "su-dummy-orphan is not an orphan"
fx_log "pre-existing orphan: su-dummy-orphan"
fi
rm -rf "$old"
if [ "$FIXTURE_SYNC" = 1 ]; then fx_pacman -Sy; fi
fx_log "builder=$FIXTURE_BUILDER isolate=$FIXTURE_ISOLATE repo=$FIXTURE_REPO"
fx_log "installed: $(pacman -Q su-dummy-a su-dummy-b su-dummy-c 2>/dev/null | tr '\n' ';')"
fx_log "repo list: $(pacman-conf --repo-list | tr '\n' ' ')"
}
# Run when executed; do nothing when sourced (so tests can reuse fx_build_tar etc.
# without inheriting `set -e`).
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
fx_main "$@"
fi
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# rpm.sh - deterministic E2E fixture for RPM distros (dnf: Alma/Rocky/..., zypper: openSUSE)
# Copy of discovery/rpm/fixture.sh; the only changes are the SU_FIXTURE_ISOLATE,
# SU_FIXTURE_ORPHAN and SU_FIXTURE_B_AUTO knobs.
#
# Builds three dummy packages with rpmbuild, installs version 1.0 of each, publishes
# version 2.0 in a local file:// repo and registers that repo, so that the package
# manager reports exactly these upgrades:
#
# su-dummy-a 1.0-1 -> 2.0-1 (noarch, Requires: su-dummy-b)
# su-dummy-b 1.0-1 -> 2.0-1 (noarch)
# su-dummy-c 1.0-1 -> 2.0-1 (ARCH-SPECIFIC, so the ".x86_64" suffix path is exercised)
#
# Optional (SU_FIXTURE_EXTRAS=1):
# - installs su-dummy-d 1.0 (NOT upgradable): Requires su-dummy-a, /bin/sh (file dep),
# "su-dummy-b >= 1.0" (versioned), "(su-dummy-c if su-dummy-b)" (rich dep) and rpmlib(...)
# -> for reverse-dep / dependency-closure tests;
# - publishes su-dummy-r 1.0 (Requires su-dummy-s) and su-dummy-s 1.0 in the repo, NOT installed
# -> for install/remove/--clean-deps tests.
#
# Optional (SU_FIXTURE_ISOLATE=1): after the build tools are installed, move the distro's
# own repository (and, on zypper, service) definitions to /srv/su-disabled-repos, so that
# only the su-dummy repo is left: the upgradable list is exactly su-dummy-{a,b,c} and later
# refreshes/upgrades do not depend on the state of the real mirrors.
#
# Optional (SU_FIXTURE_ORPHAN=1): su-dummy-orphan 1.0 is installed as a dependency (of
# su-dummy-orphan-parent, installed through the package manager and then removed with rpm -e),
# 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.
#
# Re-runnable: re-running resets everything to the "1.0 installed, 2.0 available" state.
#
# Usage (inside the container, as root): bash fixture.sh
# docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh
set -euo pipefail
export LC_ALL=C
T0=$SECONDS
log() { printf '[fixture] %s\n' "$*" >&2; }
die() { printf '[fixture] ERROR: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "must run as root"
# ---- 1. backend detection ---------------------------------------------------
BACKEND=
if command -v zypper >/dev/null 2>&1; then BACKEND=zypper
elif command -v dnf >/dev/null 2>&1; then BACKEND=dnf
elif command -v yum >/dev/null 2>&1; then BACKEND=yum
else die "neither zypper nor dnf/yum found"; fi
log "backend=$BACKEND"
TOP=/var/tmp/su-fixture # rpmbuild topdir
REPO=/var/lib/su-dummy-repo # file:// repo (only 2.0 packages)
SPEC_DIR=$TOP/SPECS
# ---- 2. build tools ---------------------------------------------------------
t=$SECONDS
case $BACKEND in
zypper) zypper --non-interactive --quiet install --no-recommends rpm-build createrepo_c >/dev/null ;;
dnf|yum) "$BACKEND" -y -q install --setopt=install_weak_deps=False rpm-build createrepo_c >/dev/null ;;
esac
command -v rpmbuild >/dev/null || die "rpmbuild missing after install"
command -v createrepo_c >/dev/null || die "createrepo_c missing after install"
log "build tools installed in $((SECONDS - t))s"
# ---- 2b. optional isolation: only the su-dummy repo stays configured ----------
if [ "${SU_FIXTURE_ISOLATE:-0}" = 1 ]; then
mkdir -p /srv/su-disabled-repos
case $BACKEND in
zypper) defs=(/etc/zypp/repos.d/*.repo /etc/zypp/services.d/*.service) ;;
dnf|yum) defs=(/etc/yum.repos.d/*.repo) ;;
esac
for f in "${defs[@]}"; do
[ -e "$f" ] || continue
case ${f##*/} in su-dummy.repo) continue ;; esac
mv "$f" "/srv/su-disabled-repos/${f##*/}"
done
log "ISOLATE: real repository definitions moved to /srv/su-disabled-repos"
fi
# ---- 3. build RPMs ----------------------------------------------------------
rm -rf "$TOP" "$REPO"
mkdir -p "$TOP"/{BUILD,BUILDROOT,RPMS,SRPMS,SOURCES,SPECS} "$REPO"
# mkspec <name> <version> <arch|noarch> <extra spec header lines...>
mkspec() {
local name=$1 ver=$2 arch=$3; shift 3
local f=$SPEC_DIR/$name-$ver.spec
{
echo "Name: $name"
echo "Version: $ver"
echo "Release: 1"
echo "Summary: skeptical-updater dummy $name (v$ver)"
echo "License: MIT"
[ "$arch" = noarch ] && echo "BuildArch: noarch"
local l; for l in "$@"; do echo "$l"; done
echo "%global debug_package %{nil}"
echo "%description"
echo "Dummy package $name version $ver for skeptical-updater tests."
echo ""
echo "Second paragraph of $name $ver (blank line above on purpose)."
echo "%install"
echo "mkdir -p %{buildroot}/usr/share/su-dummy"
echo "echo '$name $ver' > %{buildroot}/usr/share/su-dummy/$name"
echo "%files"
echo "/usr/share/su-dummy/$name"
} > "$f"
printf '%s\n' "$f"
}
build() { rpmbuild -bb --quiet --define "_topdir $TOP" "$1" >/dev/null 2>"$TOP/rpmbuild.err" \
|| { cat "$TOP/rpmbuild.err" >&2; die "rpmbuild failed for $1"; }; }
t=$SECONDS
for v in 1.0 2.0; do
build "$(mkspec su-dummy-a $v noarch 'Requires: su-dummy-b')"
build "$(mkspec su-dummy-b $v noarch)"
build "$(mkspec su-dummy-c $v arch)"
done
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then
build "$(mkspec su-dummy-s 1.0 noarch)" # published in the repo, NOT installed
build "$(mkspec su-dummy-r 1.0 noarch 'Requires: su-dummy-s')" # (used to test remove/--clean-deps)
build "$(mkspec su-dummy-d 1.0 noarch \
'Requires: su-dummy-a' \
'Requires: /bin/sh' \
'Requires: su-dummy-b >= 1.0' \
'Requires: (su-dummy-c if su-dummy-b)' \
'Requires: rpmlib(CompressedFileNames)')"
fi
if [ "${SU_FIXTURE_ORPHAN:-0}" = 1 ]; then
build "$(mkspec su-dummy-orphan 1.0 noarch)"
build "$(mkspec su-dummy-orphan-parent 1.0 noarch 'Requires: su-dummy-orphan')"
fi
log "rpmbuild done in $((SECONDS - t))s"
# ---- 4. install 1.0 (rpm directly: distro-agnostic, resets to 1.0 on re-run) ----
shopt -s nullglob
v1=( "$TOP"/RPMS/*/su-dummy-[abc]-1.0-1.*.rpm )
[ "${#v1[@]}" -eq 3 ] || die "expected 3 built 1.0 rpms, got ${#v1[@]}"
rpm -Uvh --oldpackage --replacepkgs "${v1[@]}" >/dev/null
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then
rpm -Uvh --oldpackage --replacepkgs "$TOP"/RPMS/noarch/su-dummy-d-1.0-1.*.rpm >/dev/null
fi
# ---- 5. publish 2.0 as a local repo -------------------------------------------
v2=( "$TOP"/RPMS/*/su-dummy-[abc]-2.0-1.*.rpm )
[ "${#v2[@]}" -eq 3 ] || die "expected 3 built 2.0 rpms, got ${#v2[@]}"
cp "${v2[@]}" "$REPO"/
if [ "${SU_FIXTURE_EXTRAS:-0}" = 1 ]; then cp "$TOP"/RPMS/noarch/su-dummy-[rs]-1.0-1.*.rpm "$REPO"/; fi
if [ "${SU_FIXTURE_ORPHAN:-0}" = 1 ]; then cp "$TOP"/RPMS/noarch/su-dummy-orphan*-1.0-1.*.rpm "$REPO"/; fi
createrepo_c --quiet "$REPO" >/dev/null
# ---- 6. register repo ---------------------------------------------------------
case $BACKEND in
zypper)
zypper --non-interactive --quiet removerepo su-dummy >/dev/null 2>&1 || true
zypper --non-interactive --quiet addrepo --no-gpgcheck --no-check --refresh "file://$REPO" su-dummy >/dev/null
t=$SECONDS
zypper --non-interactive --quiet refresh su-dummy >/dev/null
;;
dnf|yum)
cat > /etc/yum.repos.d/su-dummy.repo <<REPOEOF
[su-dummy]
name=skeptical-updater dummy packages
baseurl=file://$REPO
enabled=1
gpgcheck=0
repo_gpgcheck=0
metadata_expire=0
REPOEOF
t=$SECONDS
"$BACKEND" -q makecache --repo su-dummy >/dev/null
;;
esac
log "repo refresh in $((SECONDS - t))s"
# ---- 6b. optional pre-existing orphan: installed as a dependency, its parent removed ----------
if [ "${SU_FIXTURE_ORPHAN:-0}" = 1 ]; then
case $BACKEND in
zypper) zypper --non-interactive --quiet install su-dummy-orphan-parent >/dev/null ;;
dnf|yum) "$BACKEND" -y -q install su-dummy-orphan-parent >/dev/null 2>&1 ;;
esac
rpm -e su-dummy-orphan-parent
case $BACKEND in
zypper) unneeded=$(zypper --non-interactive --quiet packages --unneeded 2>/dev/null || true) ;;
dnf|yum) unneeded=$("$BACKEND" -q repoquery --unneeded 2>/dev/null || true) ;;
esac
rpm -q su-dummy-orphan >/dev/null || die "su-dummy-orphan is not installed"
printf '%s\n' "$unneeded" | grep -q su-dummy-orphan || die "su-dummy-orphan is not an unneeded package"
log "pre-existing orphan (unneeded): su-dummy-orphan"
fi
# ---- 6c. optional: su-dummy-b installed as a dependency (of su-dummy-a) ----------------------
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 ;;
esac
log "su-dummy-b marked as installed as a dependency"
fi
# ---- 7. verify: the three must be listed as upgradable --------------------------
want="su-dummy-a su-dummy-b su-dummy-c"
case $BACKEND in
zypper) listing=$(zypper --non-interactive --quiet list-updates 2>&1 || true) ;;
dnf|yum) listing=$("$BACKEND" -q check-update 2>&1 || true) ;;
esac
bad=0
for p in $want; do
printf '%s\n' "$listing" | grep -Eq "(^|[| ])$p([. ]|$)" || { log "NOT listed as upgradable: $p"; bad=1; }
done
if [ "$bad" -ne 0 ]; then printf '%s\n' "$listing" >&2; die "fixture verification failed"; fi
log "OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total ${SECONDS}s, since start $((SECONDS - T0))s)"
+226
View File
@@ -0,0 +1,226 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/e2e/helpers.bash - shared helpers for the E2E suite (sourced by tests/e2e/*.bats).
# Runs as root inside a distro container prepared by tests/e2e/entrypoint.sh.
# shellcheck shell=bash
load '../vendor/bats-support/load'
load '../vendor/bats-assert/load'
STATE_DIR=/var/lib/skeptical-updater
STATE_FILE=$STATE_DIR/trusted.tsv
FAMILY=${SU_FAMILY:?SU_FAMILY not set (run through tests/e2e/entrypoint.sh)}
case $FAMILY in
apt | pacman) BE=$FAMILY ;;
rpm) if command -v zypper >/dev/null 2>&1; then BE=zypper; else BE=dnf; fi ;;
*) BE=unknown ;;
esac
export STATE_DIR STATE_FILE FAMILY BE
unset SKEPTICAL_UPDATER_STATE_DIR NO_COLOR
# shellcheck source=/dev/null
pretty_name() { (. /etc/os-release && printf '%s' "$PRETTY_NAME"); }
# pkg_ver NAME -> upstream version of the installed package (1.0 / 2.0) or "absent"
pkg_ver() {
local v=''
case $BE in
apt)
v=$(dpkg-query -W -f '${db:Status-Abbrev} ${Version}' "$1" 2>/dev/null) || v=''
# ${db:Status-Abbrev}: 1st char = selection (i, h = hold, ...), 2nd = state (i = installed)
if [[ ${v:1:1} == i ]]; then v=${v##* }; else v=''; fi
;;
pacman)
v=$(pacman -Q "$1" 2>/dev/null) || v=''
v=${v#* }
v=${v%-*}
;;
dnf | zypper) v=$(rpm -q --qf '%{VERSION}' "$1" 2>/dev/null) || v='' ;;
esac
printf '%s' "${v:-absent}"
}
versions() { printf 'a=%s b=%s c=%s' "$(pkg_ver su-dummy-a)" "$(pkg_ver su-dummy-b)" "$(pkg_ver su-dummy-c)"; }
# holds_snapshot: the complete native hold/lock/ignore/exclude state, as text
holds_snapshot() {
case $BE in
apt) apt-mark showhold | sort ;;
pacman)
echo "--- ignorepkg"
cat /etc/pacman.d/ignorepkg
echo "--- pacman-conf IgnorePkg"
pacman-conf IgnorePkg
;;
dnf) cat /etc/dnf/dnf.conf ;;
zypper) if [[ -e /etc/zypp/locks ]]; then cat /etc/zypp/locks; else echo "(no /etc/zypp/locks)"; fi ;;
esac
}
# hold_c: a pre-existing, admin-made hold/lock/IgnorePkg/exclude on su-dummy-c
hold_c() {
case $BE in
apt) apt-mark hold su-dummy-c >/dev/null ;;
pacman) echo 'IgnorePkg = su-dummy-c' >/etc/pacman.d/ignorepkg ;;
dnf) sed -i '/^\[main\]/a excludepkgs=su-dummy-c' /etc/dnf/dnf.conf ;;
zypper) zypper --non-interactive --quiet addlock su-dummy-c >/dev/null ;;
esac
}
e2e_setup_file() {
# remember the pristine configuration once per container
case $BE in
pacman)
# 1.0 packages for e2e_reset (re-running the fixture would rebuild the repo with new
# bytes in the same second, leaving the already synced database stale)
if [[ ! -d /srv/su-old-pacman ]]; then
mkdir -p /srv/su-old-pacman
(
# shellcheck source=fixtures/pacman.sh
. "$BATS_TEST_DIRNAME/fixtures/pacman.sh"
fx_build_tar /srv/su-old-pacman su-dummy-a 1.0 'Skeptical-updater dummy package A' su-dummy-b
fx_build_tar /srv/su-old-pacman su-dummy-b 1.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build_tar /srv/su-old-pacman su-dummy-c 1.0 'Skeptical-updater dummy package C (independent)'
)
fi
;;
dnf) [[ -e /srv/su-orig-dnf.conf ]] || cp -p /etc/dnf/dnf.conf /srv/su-orig-dnf.conf ;;
zypper)
rm -f /etc/zypp/zypp.conf.d/50-su-e2e.conf /etc/zypp/locks-su-e2e
if [[ ! -e /srv/su-orig-locks.state ]]; then
if [[ -e /etc/zypp/locks ]]; then
cp -p /etc/zypp/locks /srv/su-orig-locks
echo present >/srv/su-orig-locks.state
else
echo absent >/srv/su-orig-locks.state
fi
fi
;;
esac
}
# e2e_reset: back to "su-dummy-{a,b,c} 1.0 installed, 2.0 available, no holds, no statefile"
e2e_reset() {
rm -rf -- "$STATE_DIR"
case $BE in
apt)
local -a held=()
mapfile -t held < <(apt-mark showhold)
((${#held[@]} == 0)) || apt-mark unhold "${held[@]}" >/dev/null
dpkg --force-confnew -i /srv/su-old/*.deb </dev/null >/dev/null 2>&1
;;
pacman)
: >/etc/pacman.d/ignorepkg
pacman -U --noconfirm /srv/su-old-pacman/*.pkg.tar.zst >/dev/null 2>&1
pacman -D --asexplicit su-dummy-a su-dummy-c >/dev/null
pacman -D --asdeps su-dummy-b >/dev/null
;;
dnf | zypper)
if [[ $BE == dnf ]]; then
cp -p /srv/su-orig-dnf.conf /etc/dnf/dnf.conf
else
rm -f /etc/zypp/zypp.conf.d/50-su-e2e.conf /etc/zypp/locks-su-e2e
fi
if [[ $BE == dnf ]]; then
:
elif [[ $(cat /srv/su-orig-locks.state) == present ]]; then
cp -p /srv/su-orig-locks /etc/zypp/locks
else
rm -f /etc/zypp/locks
fi
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
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 ;;
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 ]] || {
echo "e2e_reset: unexpected state: $(versions) orphan=$(pkg_ver su-dummy-orphan)" >&2
return 1
}
}
# the native upgrade command line printed by --dry-run (without exclusions)
native_upgrade_regex() {
case $BE in
apt) printf '%s' '^\+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade$' ;;
pacman) printf '%s' '^\+ pacman -Su --noconfirm$' ;; # after the reviewed databases were installed
dnf) printf '%s' '^\+ (dnf|yum) -y --setopt=skip_if_unavailable=0 --setopt=\\\*\.metadata_expire=-1 upgrade$' ;;
zypper) printf '%s' '^\+ zypper --non-interactive --no-refresh (update|dist-upgrade)$' ;;
esac
}
# rpm_publish NAME VERSION: build NAME-VERSION-1 (arch-specific for su-dummy-c, like the fixture)
# with the fixture's rpmbuild tree and add it to the su-dummy repository
rpm_publish() {
local name=$1 ver=$2 top=/var/tmp/su-fixture repo=/var/lib/su-dummy-repo spec
spec=$top/SPECS/$name-$ver.spec
{
echo "Name: $name"
echo "Version: $ver"
echo "Release: 1"
echo "Summary: skeptical-updater dummy $name (v$ver)"
echo "License: MIT"
[[ $name == su-dummy-c ]] || echo "BuildArch: noarch"
echo "%global debug_package %{nil}"
echo "%description"
echo "Dummy package $name version $ver."
echo "%install"
echo "mkdir -p %{buildroot}/usr/share/su-dummy"
echo "echo '$name $ver' > %{buildroot}/usr/share/su-dummy/$name"
echo "%files"
echo "/usr/share/su-dummy/$name"
} >"$spec"
rpmbuild -bb --quiet --define "_topdir $top" "$spec" >/dev/null 2>&1
cp "$top"/RPMS/*/"$name-$ver"-1.*.rpm "$repo"/
createrepo_c --quiet --update "$repo" >/dev/null
}
rpm_unpublish() {
rm -f /var/lib/su-dummy-repo/"$1-$2"-1.*.rpm
createrepo_c --quiet --update /var/lib/su-dummy-repo >/dev/null
}
# pacman_sync_snapshot: the real sync database directory, with full timestamps
pacman_sync_snapshot() { ls -la --time-style=full-iso /var/lib/pacman/sync 2>&1; }
# pacman_publish NAME VERSION / pacman_unpublish: add a package to the fixture's [su-dummy] repo,
# and rebuild that repo with only the fixture's 2.0 packages again
pacman_publish() {
(
# shellcheck source=fixtures/pacman.sh
. "$BATS_TEST_DIRNAME/fixtures/pacman.sh"
fx_build_tar /srv/su-repo "$1" "$2" "Skeptical-updater dummy package $1 $2"
)
(cd /srv/su-repo && repo-add --quiet su-dummy.db.tar.gz "./$1-$2-1-any.pkg.tar.zst")
}
pacman_unpublish() {
(cd /srv/su-repo && rm -f su-dummy.db* su-dummy.files* &&
for f in ./*.pkg.tar.zst; do case $f in ./su-dummy-[abc]-2.0-1-any.pkg.tar.zst) ;; *) rm -f "$f" ;; esac; done &&
repo-add --quiet su-dummy.db.tar.gz ./su-dummy-a-2.0-1-any.pkg.tar.zst ./su-dummy-b-2.0-1-any.pkg.tar.zst ./su-dummy-c-2.0-1-any.pkg.tar.zst)
}
# session_start ARGS...: run skeptical-updater ARGS in the background with a FIFO as stdin and wait
# for its prompt; sets SESSION_PID and SESSION_FD (write the input to it; fd 3 belongs to bats)
session_start() {
mkfifo "$BATS_TEST_TMPDIR/in"
skeptical-updater "$@" <"$BATS_TEST_TMPDIR/in" >"$BATS_TEST_TMPDIR/out" 2>&1 &
SESSION_PID=$!
exec {SESSION_FD}>"$BATS_TEST_TMPDIR/in"
for _ in $(seq 1 600); do
grep -q '^<enter> upgrade' "$BATS_TEST_TMPDIR/out" && return 0
sleep 0.1
done
return 1
}
# session_finish INPUT: send INPUT, close stdin, wait; sets SESSION_RC (read by the .bats files)
# shellcheck disable=SC2034
session_finish() {
printf '%s\n' "$1" >&"$SESSION_FD"
exec {SESSION_FD}>&-
SESSION_RC=0
wait "$SESSION_PID" || SESSION_RC=$?
}
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# E2E: skeptical-updater against the REAL package manager of the container
# (apt, pacman, dnf or zypper), on the su-dummy-{a,b,c} fixture: 1.0 installed,
# 2.0 available, su-dummy-a depends on su-dummy-b. Every test starts from that
# state (e2e_reset) with no statefile and no holds/locks.
load helpers
setup_file() { e2e_setup_file; }
setup() { e2e_reset; }
# shellcheck source=/dev/null
zypper_verb() { case $(. /etc/os-release && printf '%s' "$ID") in opensuse-tumbleweed | opensuse-slowroll) echo dist-upgrade ;; *) echo update ;; esac; }
@test "banner shows the detected backend and the distro, refresh succeeds" {
run skeptical-updater list
assert_success
assert_line --index 0 "Backend: $BE ($(pretty_name))"
assert_line --index 1 "Refreshing package lists... done."
}
@test "list shows exactly the three dummies 1.0 -> 2.0" {
run skeptical-updater list --no-refresh
assert_success
assert_line "3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:"
assert_line --regexp '^ \[1\] su-dummy-a +1\.0(-1)? +-> +2\.0(-1)? +[^ ]+$'
assert_line --regexp '^ \[2\] su-dummy-b +1\.0(-1)? +-> +2\.0(-1)? +[^ ]+$'
assert_line --regexp '^ \[3\] su-dummy-c +1\.0(-1)? +-> +2\.0(-1)? +[^ ]+$'
refute_line --regexp '^.\[4\]'
}
@test "N? prints the description; su-dummy-b is required by su-dummy-a" {
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'
assert_line 'Quit: nothing was upgraded.'
}
@test "N?? prints the native full info" {
run skeptical-updater --no-refresh <<<$'1??\nq'
assert_success
assert_line --regexp '^(Package: su-dummy-a|Name +: su-dummy-a)$'
}
@test "N+ writes the user row and the dep row; list hides both and counts them" {
run skeptical-updater --no-refresh <<<$'1+\nq'
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
[[ -f $STATE_FILE ]]
[[ $(stat -c %a "$STATE_DIR") == 755 ]]
run cat "$STATE_FILE"
assert_line --index 0 '# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)'
assert_line --index 1 --regexp "^$BE"$'\t'"su-dummy-a"$'\t'"user"$'\t''[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$'
assert_line --index 2 --regexp "^$BE"$'\t'"su-dummy-b"$'\t'"dep:su-dummy-a"$'\t''[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$'
run skeptical-updater list --no-refresh
assert_success
assert_line "3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:"
refute_output --partial 'su-dummy-a'
refute_output --partial 'su-dummy-b'
assert_line --regexp '^ \[1\] su-dummy-c '
run skeptical-updater list --no-refresh --all
assert_success
assert_line "3 upgradable, 2 trusted (marked with =), 1 to review:"
assert_line --regexp '^=\[1\] su-dummy-a '
assert_line --regexp '^=\[2\] su-dummy-b '
assert_line --regexp '^ \[3\] su-dummy-c '
}
@test "trust / trusted / refresh / untrust from the command line" {
run skeptical-updater trust su-dummy-a
assert_success
assert_output 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
run skeptical-updater trusted
assert_success
assert_line --partial "$BE"$'\t'"su-dummy-a"$'\t'"user"$'\t'
assert_line --partial "$BE"$'\t'"su-dummy-b"$'\t'"dep:su-dummy-a"$'\t'
# refresh recomputes the closure: a removed dep row comes back
sed -i '/dep:su-dummy-a/d' "$STATE_FILE"
run skeptical-updater refresh
assert_success
assert_output 'refreshed the dependency closures of 1 trusted root: 1 added, 0 removed'
run grep -c $'\tdep:su-dummy-a\t' "$STATE_FILE"
assert_output 1
run skeptical-updater untrust su-dummy-a
assert_success
assert_output 'untrusted su-dummy-a (2 rows removed)'
run skeptical-updater trusted
assert_success
assert_output '# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)'
}
@test "excluding su-dummy-b (2- then enter) upgrades the others" {
before=$(holds_snapshot)
run skeptical-updater <<<$'2-\n'
assert_success
assert_line 'excluded from this run: su-dummy-b'
assert_line 'Excluded from this run: su-dummy-b'
assert_line 'Upgrade finished.'
[[ $(versions) == "a=2.0 b=1.0 c=2.0" ]] || fail "versions after the run: $(versions)"
[[ $(holds_snapshot) == "$before" ]] || fail "hold/lock state changed: $(holds_snapshot)"
run skeptical-updater list --no-refresh
assert_line --regexp '^ \[1\] su-dummy-b +1\.0'
}
@test "a pre-existing hold/lock/IgnorePkg on su-dummy-c survives a run that excludes su-dummy-b" {
hold_c
before=$(holds_snapshot)
[[ -n $before ]]
run skeptical-updater list --no-refresh
assert_line --regexp '^ \[2\] su-dummy-b '
run skeptical-updater <<<$'2-\n'
assert_success
assert_line 'Excluded from this run: su-dummy-b'
[[ $(versions) == "a=2.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
after=$(holds_snapshot)
[[ $after == "$before" ]] || fail $'hold/lock state changed.\nbefore:\n'"$before"$'\nafter:\n'"$after"
if [[ $BE == apt ]]; then [[ ! -e $STATE_DIR/holds.added ]]; fi
}
@test "trusting a package re-includes its excluded dependency" {
run skeptical-updater <<<$'2-\n1+\n'
assert_success
assert_line ' (su-dummy-b is trusted now: no longer excluded from this run)'
assert_line 'Excluded from this run: none'
[[ $(versions) == "a=2.0 b=2.0 c=2.0" ]] || fail "versions after the run: $(versions)"
}
@test "enter upgrades everything" {
run skeptical-updater <<<''
assert_success
assert_line 'Excluded from this run: none'
[[ $(versions) == "a=2.0 b=2.0 c=2.0" ]] || fail "versions after the run: $(versions)"
run skeptical-updater list --no-refresh
assert_success
assert_output --partial 'Nothing to upgrade.'
}
@test "Nx purges su-dummy-c after a y confirmation" {
run skeptical-updater --no-refresh <<<$'3x\ny\nl\nq'
assert_success
assert_line 'Purge su-dummy-c now? [y/N] y'
assert_line 'purged: su-dummy-c'
assert_line '2 upgradable, 0 trusted (hidden, use --all to show), 2 to review:'
[[ $(versions) == "a=1.0 b=1.0 c=absent" ]] || fail "versions after the run: $(versions)"
[[ $(pkg_ver su-dummy-orphan) == 1.0 ]] || fail "the unrelated orphan was removed"
}
@test "Nx on su-dummy-b is refused: su-dummy-a depends on it; both stay, the row stays listed" {
run skeptical-updater --no-refresh <<<$'2x\nl\nq'
assert_success
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
refute_output --partial '[y/N]'
[[ $(grep -c '^ \[2\] su-dummy-b ' <<<"$output") -eq 2 ]] || fail "row 2 left the list"
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
}
@test "Nx on su-dummy-a removes su-dummy-b with it (its dependency, nothing else needs it); the orphan survives" {
run skeptical-updater --no-refresh <<<$'1x\ny\nl\nq'
assert_success
assert_line 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N] y'
assert_line 'purged: su-dummy-a su-dummy-b'
assert_line '1 upgradable, 0 trusted (hidden, use --all to show), 1 to review:'
[[ $(versions) == "a=absent b=absent c=1.0" ]] || fail "versions after the run: $(versions)"
[[ $(pkg_ver su-dummy-orphan) == 1.0 ]] || fail "the unrelated orphan was removed"
}
@test "purging every package ends the session cleanly" {
run skeptical-updater --no-refresh <<<$'1x\ny\n3x\ny'
assert_success
assert_line 'Nothing left to upgrade.'
[[ $(versions) == "a=absent b=absent c=absent" ]] || fail "versions after the run: $(versions)"
[[ $(pkg_ver su-dummy-orphan) == 1.0 ]] || fail "the unrelated orphan was removed"
}
@test "Nx answered with anything but y keeps the package" {
run skeptical-updater --no-refresh <<<$'3x\nn\nq'
assert_success
assert_line 'not purged: su-dummy-c'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
}
@test "q leaves everything untouched" {
before=$(holds_snapshot)
run skeptical-updater <<<$'2-\n1-\nq'
assert_success
assert_line 'Quit: nothing was upgraded.'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
[[ $(holds_snapshot) == "$before" ]]
[[ ! -e $STATE_FILE ]]
}
@test "EOF aborts with exit 2 and changes nothing" {
before=$(holds_snapshot)
run skeptical-updater <<<'2-'
assert_failure 2
assert_output --partial 'end of input: aborted, nothing was upgraded'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
[[ $(holds_snapshot) == "$before" ]]
[[ ! -e $STATE_FILE ]]
}
@test "--dry-run: enter prints the native upgrade command and changes nothing" {
before=$(holds_snapshot)
run skeptical-updater --dry-run <<<''
assert_success
assert_line --regexp "$(native_upgrade_regex)"
assert_line 'Dry run: nothing was changed.'
# (3+: trusting su-dummy-a would hide su-dummy-b and so re-include it)
run skeptical-updater --dry-run <<<$'2-\n3+\n'
assert_success
case $BE in
apt)
assert_line '+ apt-mark hold su-dummy-b'
assert_line '+ apt-mark unhold su-dummy-b'
;;
pacman)
assert_line --regexp '^\+ cp -p -- /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+/sync/su-dummy\.db /var/lib/pacman/sync/$'
assert_line '+ pacman -Su --noconfirm --ignore su-dummy-b'
;;
dnf) assert_line --regexp '^\+ (dnf|yum) -y --setopt=skip_if_unavailable=0 --setopt=\\\*\.metadata_expire=-1 upgrade --exclude=su-dummy-b$' ;;
zypper)
assert_line --regexp '^\+ env ZYPP_CONF=.*/zypp\.conf zypper --non-interactive --quiet addlock su-dummy-b$'
assert_line --regexp "^\+ env ZYPP_CONF=.*/zypp\.conf zypper --non-interactive --no-refresh $(zypper_verb)\$"
;;
esac
assert_line --partial "+ $STATE_FILE add: $BE su-dummy-c user"
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
[[ $(holds_snapshot) == "$before" ]]
[[ ! -e $STATE_DIR ]]
}
@test "read-only commands work unprivileged" {
if command -v setpriv >/dev/null 2>&1; then
asuser() { setpriv --reuid=65534 --regid=65534 --clear-groups -- env TMPDIR=/tmp "$@"; }
elif command -v python3 >/dev/null 2>&1; then # almalinux:10 has neither setpriv nor runuser/su
asuser() { python3 -c 'import os, sys; os.setgroups([]); os.setgid(65534); os.setuid(65534); os.execvp(sys.argv[1], sys.argv[1:])' env TMPDIR=/tmp "$@"; }
else
skip "neither setpriv nor python3 available to drop privileges"
fi
skeptical-updater trust su-dummy-c >/dev/null
run asuser skeptical-updater trusted
assert_success
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'
run asuser skeptical-updater --dry-run --no-refresh <<<''
assert_success
assert_line --regexp "$(native_upgrade_regex)"
run asuser skeptical-updater upgrade-me-please
assert_failure 1
# a real upgrade without root and without sudo is refused
if ! command -v sudo >/dev/null 2>&1; then
run asuser skeptical-updater <<<''
assert_failure 1
assert_output --partial 'upgrading needs root and sudo is not available'
fi
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
# a state directory an unprivileged user cannot enter is an error, not "no trusted packages"
chmod 0000 /var/lib/skeptical-updater
run asuser skeptical-updater trusted
chmod 0755 /var/lib/skeptical-updater
assert_failure 1
assert_output --partial 'cannot access the state directory /var/lib/skeptical-updater (permission denied)'
}
@test "pacman: list, q, end of input and --dry-run never touch the real sync database" {
[[ $BE == pacman ]] || skip "pacman only"
before=$(pacman_sync_snapshot)
run skeptical-updater list
assert_success
run skeptical-updater <<<'q'
assert_success
run skeptical-updater <<<'2-'
assert_failure 2
run skeptical-updater --dry-run <<<''
assert_success
assert_line 'Checking the reviewed transaction (pacman -Sup)... ok.'
assert_line '+ pacman -Su --noconfirm'
after=$(pacman_sync_snapshot)
[[ $after == "$before" ]] || fail $'the real sync database changed.\nbefore:\n'"$before"$'\nafter:\n'"$after"
run ls /tmp
refute_output --partial 'skeptical-updater-db.'
}
@test "apt: temporary holds wait for a busy dpkg lock instead of failing" {
[[ $BE == apt ]] || skip "apt only"
command -v perl >/dev/null 2>&1 || skip "perl not available"
# hold dpkg's frontend lock for 4 s (fcntl, as dpkg does), starting now
perl -e 'use Fcntl; open(my $f, ">>", "/var/lib/dpkg/lock-frontend") or die; my $l = pack("s s l l i", F_WRLCK, 0, 0, 0, 0); fcntl($f, F_SETLKW, $l) or die; sleep 4;' &
locker=$!
sleep 0.5
run skeptical-updater --no-refresh <<<$'2-\n'
wait "$locker" || true
assert_success
assert_line 'Excluded from this run: su-dummy-b'
[[ $(versions) == "a=2.0 b=1.0 c=2.0" ]] || fail "versions after the run: $(versions)"
[[ -z $(apt-mark showhold) ]]
}
@test "zypper: a lock whose locksfile.path comes from a zypp.conf.d drop-in is honoured" {
[[ $BE == zypper ]] || skip "zypper only"
mkdir -p /etc/zypp/zypp.conf.d
printf '[main]\nlocksfile.path = /etc/zypp/locks-su-e2e\n' >/etc/zypp/zypp.conf.d/50-su-e2e.conf
zypper --non-interactive --quiet addlock su-dummy-c >/dev/null
grep -q 'solvable_name: su-dummy-c' /etc/zypp/locks-su-e2e
before=$(cat /etc/zypp/locks-su-e2e)
run skeptical-updater <<<$'2-\n'
assert_success
assert_line 'Excluded from this run: su-dummy-b'
[[ $(versions) == "a=2.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
[[ $(cat /etc/zypp/locks-su-e2e) == "$before" ]]
}
@test "zypper: the reviewed metadata is applied (no refresh between review and upgrade)" {
[[ $BE == zypper ]] || skip "zypper only (libdnf reads file:// repositories live; apt never refreshes on upgrade)"
mkfifo "$BATS_TEST_TMPDIR/in"
skeptical-updater <"$BATS_TEST_TMPDIR/in" >"$BATS_TEST_TMPDIR/out" 2>&1 &
pid=$!
exec {w}>"$BATS_TEST_TMPDIR/in" # (fd 3 belongs to bats)
for _ in $(seq 1 600); do
grep -q '^<enter> upgrade' "$BATS_TEST_TMPDIR/out" && break
sleep 0.1
done
# a newer su-dummy-c appears in the repository after the review
rpm_publish su-dummy-c 3.0
echo >&"$w"
exec {w}>&-
rc=0
wait "$pid" || rc=$?
rpm_unpublish su-dummy-c 3.0
[[ $rc -eq 0 ]] || fail "exit $rc: $(cat "$BATS_TEST_TMPDIR/out")"
[[ $(versions) == "a=2.0 b=2.0 c=2.0" ]] || fail "versions after the run: $(versions)"
}
@test "pacman: package databases synced elsewhere during the review: refused (exit 2), nothing changed" {
[[ $BE == pacman ]] || skip "pacman only"
session_start
sleep 1
pacman_publish su-dummy-c 3.0
pacman -Sy >/dev/null 2>&1 # another terminal: the system's database is now newer than the reviewed one
synced=$(sha256sum /var/lib/pacman/sync/su-dummy.db | cut -d' ' -f1)
session_finish ''
pacman_unpublish
pacman -Sy >/dev/null 2>&1
[[ $SESSION_RC -eq 2 ]] || fail "exit $SESSION_RC: $(cat "$BATS_TEST_TMPDIR/out")"
grep -q 'package databases changed since the review (su-dummy); run skeptical-updater again' "$BATS_TEST_TMPDIR/out"
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
[[ ! -e /var/lib/pacman/db.lck ]]
[[ -n $synced ]]
}
@test "pacman: <enter> installs exactly the reviewed metadata (the real sync DB becomes the reviewed one)" {
[[ $BE == pacman ]] || skip "pacman only"
# the system's database is older than what the review will see (su-dummy-c 3.0) ...
# (1 s apart from the previous repo rebuild: pacman compares database mtimes in seconds)
sleep 1
pacman_publish su-dummy-c 3.0
session_start
reviewed=$(sha256sum /tmp/skeptical-updater-db."$SESSION_PID".*/sync/su-dummy.db | cut -d' ' -f1)
# ... and su-dummy-c 4.0 appears in the repository after the review
sleep 1
pacman_publish su-dummy-c 4.0
session_finish ''
pacman_unpublish
[[ $SESSION_RC -eq 0 ]] || fail "exit $SESSION_RC: $(cat "$BATS_TEST_TMPDIR/out")"
grep -qE '^\+ cp -p -- /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+/sync/su-dummy\.db /var/lib/pacman/sync/$' "$BATS_TEST_TMPDIR/out"
[[ -n $reviewed ]]
[[ $(sha256sum /var/lib/pacman/sync/su-dummy.db | cut -d' ' -f1) == "$reviewed" ]] || fail "the system's su-dummy.db is not the reviewed one"
[[ $(versions) == "a=2.0 b=2.0 c=3.0" ]] || fail "versions after the run: $(versions)"
[[ ! -e /var/lib/pacman/db.lck ]]
run ls /tmp
refute_output --partial 'skeptical-updater-db.'
}
+787
View File
@@ -0,0 +1,787 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# The exact native commands issued per backend: refresh, upgrade + per-run exclusion
# (apt hold journal, pacman --ignore, dnf --exclude, zypper private locks), purge plans.
# each @test runs in its own subshell: per-test exports are intended
# shellcheck disable=SC2030,SC2031
load test_helper
setup() { common_setup; }
APT_UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade'
DNF_UPGRADE='dnf -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade'
# ---- apt ----------------------------------------------------------------------------------------
@test "apt: refresh is apt-get update -qq -o APT::Update::Error-Mode=any" {
run skup list
assert_success
assert_line 'Refreshing package lists... done.'
log_has 'apt-get update -qq -o APT::Update::Error-Mode=any'
}
@test "apt: a failed refresh warns and goes on with the cached lists" {
FAKE_APT_UPDATE_RC=100 run skup list
assert_success
assert_line 'Refreshing package lists... FAILED.'
assert_output --partial "E: Failed to fetch http://deb.debian.org/debian/dists/bookworm/InRelease"
assert_output --partial 'warning: metadata refresh failed (apt-get update -qq -o APT::Update::Error-Mode=any exited 100); continuing with the cached package lists'
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
}
@test "apt: the upgrade command line" {
run skup --no-refresh <<<''
assert_success
log_has "$APT_UPGRADE"
assert_line "+ $APT_UPGRADE"
}
@test "apt: exclusions are temporary holds; a package the admin held is never held nor released by us" {
echo su-dummy-c >"$FAKE_DIR/holds"
FAKE_APT_SIM=held run skup --no-refresh <<<$'2-\n3-\n'
assert_success
assert_line 'Excluded from this run: su-dummy-b su-dummy-c'
log_has 'apt-mark showhold'
log_has 'apt-mark hold su-dummy-b'
log_has 'holds during upgrade: su-dummy-b su-dummy-c '
log_has 'apt-mark unhold su-dummy-b'
log_lacks '^apt-mark (un)?hold .*su-dummy-c'
run cat "$FAKE_DIR/holds"
assert_output 'su-dummy-c'
[[ ! -e $STATE/holds.added ]]
# order: hold, upgrade, unhold
run grep -nE '^apt-mark (un)?hold|^apt-get -y' "$FAKE_LOG"
assert_line --index 0 --partial 'apt-mark hold su-dummy-b'
assert_line --index 1 --partial "$APT_UPGRADE"
assert_line --index 2 --partial 'apt-mark unhold su-dummy-b'
}
@test "apt: the holds are written to the journal before apt-mark hold runs" {
# a hold that fails half-way: the journal still lets the restore release it
FAKE_APT_MARK_HOLD_RC=100 run skup --no-refresh <<<$'1-\n2-\n'
assert_failure 1
assert_output --partial 'could not place the temporary holds'
log_has 'apt-mark unhold su-dummy-a su-dummy-b'
run cat "$FAKE_DIR/holds"
assert_output ''
log_lacks '^apt-get -y'
}
@test "apt: holds are released even when the upgrade fails" {
FAKE_APT_UPGRADE_RC=100 run skup --no-refresh <<<$'2-\n'
assert_failure 100
log_has 'apt-mark unhold su-dummy-b'
run cat "$FAKE_DIR/holds"
assert_output ''
}
@test "apt: holds are released when the run is killed (SIGTERM) during the upgrade" {
FAKE_APT_UPGRADE_KILL=TERM run skup --no-refresh <<<$'2-\n'
assert_failure 143
assert_output --partial 'interrupted'
log_has 'apt-mark unhold su-dummy-b'
run cat "$FAKE_DIR/holds"
assert_output ''
[[ ! -e $STATE/holds.added ]]
}
@test "apt: holds are released on SIGQUIT and SIGHUP too" {
FAKE_APT_UPGRADE_KILL=QUIT run skup --no-refresh <<<$'2-\n'
assert_failure 131
log_has 'apt-mark unhold su-dummy-b'
: >"$FAKE_LOG"
FAKE_APT_UPGRADE_KILL=HUP run skup --no-refresh <<<$'2-\n'
assert_failure 129
log_has 'apt-mark unhold su-dummy-b'
run cat "$FAKE_DIR/holds"
assert_output ''
}
@test "apt: a journal left by a crashed run is replayed at the start of the next root run (even a list)" {
printf '%s\n' su-dummy-b su-dummy-c >"$FAKE_DIR/holds" # b leaked by the crash, c is the admin's
mkdir -p "$STATE"
printf '%s\n' su-dummy-b su-dummy-e >"$STATE/holds.added" # e: leaked, but the admin released it since
run skup list --no-refresh
assert_success
assert_line 'Released temporary holds left over by an interrupted run: su-dummy-b'
log_has 'apt-mark unhold su-dummy-b'
log_lacks 'unhold .*su-dummy-(c|e)'
run cat "$FAKE_DIR/holds"
assert_output 'su-dummy-c'
[[ ! -e $STATE/holds.added ]]
# the replay happens before the listing
run grep -nE '^apt-mark unhold|^apt-get -s' "$FAKE_LOG"
assert_line --index 0 --partial 'apt-mark unhold su-dummy-b'
}
@test "apt: a journal whose holds are all gone is just removed" {
mkdir -p "$STATE"
echo su-dummy-e >"$STATE/holds.added"
run skup list --no-refresh
assert_success
assert_line 'Removed a stale hold journal of an interrupted run (nothing was still held).'
log_lacks 'unhold'
[[ ! -e $STATE/holds.added ]]
}
@test "apt: no replay as non-root or under --dry-run" {
mkdir -p "$STATE"
echo su-dummy-b >"$STATE/holds.added"
echo su-dummy-b >"$FAKE_DIR/holds"
run skup --dry-run list --no-refresh
assert_success
SKEPTICAL_UPDATER_EUID=1000 run skup list --no-refresh
assert_success
log_lacks 'unhold'
[[ -e $STATE/holds.added ]]
}
@test "apt: the replay leaves the journal alone while another run holds its lock" {
mkdir -p "$STATE"
echo su-dummy-b >"$STATE/holds.added"
echo su-dummy-b >"$FAKE_DIR/holds"
exec 9>>"$STATE/holds.lock"
flock 9
run skup list --no-refresh
exec 9>&-
assert_success
assert_output --partial "another skeptical-updater run is using $STATE/holds.added; leaving its temporary holds alone."
log_lacks 'unhold'
[[ -e $STATE/holds.added ]]
}
@test "apt: apt-mark retries while dpkg's frontend lock is busy (it ignores DPkg::Lock::Timeout)" {
FAKE_APT_MARK_LOCKED=2 run skup --no-refresh <<<$'2-\n'
assert_success
[[ $(grep -c '^apt-mark hold su-dummy-b' "$FAKE_LOG") -eq 3 ]]
log_has 'apt-mark unhold su-dummy-b'
run cat "$FAKE_DIR/holds"
assert_output ''
}
@test "apt: when the restore fails, the holds lock stays held for the exit handler's retry" {
FAKE_APT_MARK_UNHOLD_RC=100 run skup --no-refresh <<<$'2-\n'
assert_failure 1
assert_output --partial 'could not release the temporary holds (su-dummy-b); they are recorded in'
[[ $(grep -c '^holds.lock during unhold: ' "$FAKE_LOG") -eq 2 ]]
log_lacks '^holds.lock during unhold: free'
[[ -e $STATE/holds.added ]]
}
@test "apt: the dpkg 1.22+ wording of the busy lock is retried as well" {
FAKE_APT_MARK_LOCKED=1 FAKE_APT_MARK_LOCK_MSG='dpkg: error: dpkg frontend lock was locked by /usr/bin/perl process with pid 3297' \
run skup --no-refresh <<<$'2-\n'
assert_success
[[ $(grep -c '^apt-mark hold su-dummy-b' "$FAKE_LOG") -eq 2 ]]
}
@test "apt: an apt-mark failure whose message merely contains 'lock' (libblockdev) is not retried" {
SECONDS=0
FAKE_APT_MARK_HOLD_RC=100 FAKE_APT_MARK_FAIL_MSG='E: Unable to locate package libblockdev-crypto3' run skup --no-refresh <<<$'2-\n'
assert_failure 1
[[ $(grep -c '^apt-mark hold' "$FAKE_LOG") -eq 1 ]]
((SECONDS < 20))
}
@test "apt: Nx on a package others depend on is refused (apt-cache rdepends gate), nothing simulated or removed" {
run skup --no-refresh <<<$'2x\nl\nq'
assert_success
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
refute_output --partial '[y/N]'
log_lacks 'purge'
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
}
@test "apt: purge plan = the simulated purge minus pre-existing orphans; the target's own orphaned dependencies go too" {
FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x\ny\nq'
assert_success
log_has 'apt-get -s autoremove'
log_has 'apt-get -s purge --auto-remove su-dummy-a'
log_has 'apt-get -s purge su-dummy-a su-dummy-b'
assert_output --partial 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N]'
run grep -E '^apt-get -y' "$FAKE_LOG"
assert_output 'apt-get -y purge su-dummy-a su-dummy-b'
refute_output --partial 'su-dummy-orphan'
log_lacks 'autoremove --purge|-y autoremove'
}
@test "apt: belt and braces - a simulated purge that also removes a non-dependency is refused" {
FAKE_APT_SIM_EXTRA=su-dummy-c:su-dummy-w run skup --no-refresh <<<$'3x\nq'
assert_success
assert_output --partial 'cannot purge su-dummy-c: apt would also remove su-dummy-w, which su-dummy-c does not depend on (dependents of it?); nothing was removed'
refute_output --partial '[y/N]'
log_lacks '^apt-get -y'
}
# ---- pacman -------------------------------------------------------------------------------------
REAL_SYNC_RE='/pacman-db/sync'
@test "pacman: the refresh syncs a throwaway --dbpath (pid in its name); the real sync database is never touched" {
use_fakes pacman
echo "old" >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
run skup list
assert_success
run grep -E '^-Sy ' "$FAKE_DIR/pacman-dbpath"
assert_output --regexp '^-Sy /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+$'
db=${output#-Sy }
run grep -E '^-(Qu|Sl|Sup) ' "$FAKE_DIR/pacman-dbpath"
refute_output --partial ' real'
assert_output --partial "-Qu $db"
[[ ! -e $db ]]
log_has "pacman -Sy --dbpath $db --logfile /dev/null"
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == old ]]
}
@test "pacman: the throwaway database is seeded with the system's databases AND their signatures" {
use_fakes pacman
echo old >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
echo old-sig >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db.sig"
run skup list
assert_success
log_has 'seeded: su-dummy.db su-dummy.db.sig'
}
@test "pacman: q, EOF and --dry-run never touch the real sync database" {
use_fakes pacman
echo old >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
run skup <<<'q'
assert_success
run skup <<<'1?'
assert_failure 2
run skup --dry-run <<<''
assert_success
assert_line 'Checking the reviewed transaction (pacman -Sup)... ok.'
assert_line --regexp "^\+ cp -p -- /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+/sync/su-dummy\.db .*$REAL_SYNC_RE/\$"
assert_line '+ pacman -Su --noconfirm'
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == old ]]
log_lacks '^pacman -Su '
run grep -E ' real$' "$FAKE_DIR/pacman-dbpath"
refute_output --regexp '^-S(y|u|yu) '
run ls /tmp
refute_output --partial 'skeptical-updater-db.'
}
@test "pacman: <enter> checks the reviewed transaction, installs the reviewed .db and .db.sig, then runs -Su" {
use_fakes pacman
echo old >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
FAKE_PACMAN_SIG=1 run skup <<<$'1-\n3-\n'
assert_success
# the order: sync the throwaway db, pre-check against it, -Su on the installed copy
run grep -nE '^pacman (-Sy|-Sup|-Su|--dbpath)|^real sync' "$FAKE_LOG"
assert_output --regexp 'pacman -Sy --dbpath /tmp/skeptical-updater-db\.'
assert_output --regexp 'pacman -Sup --noconfirm --ignore su-dummy-a,su-dummy-c --dbpath /tmp/skeptical-updater-db\.[^ ]+ --print-format %n %v'
log_has 'pacman -Su --noconfirm --ignore su-dummy-a,su-dummy-c'
log_has 'real sync at -Su: reviewed 1 / signature 1'
log_lacks '^pacman -Syu'
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == "reviewed 1" ]]
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db.sig") == "signature 1" ]]
run ls -A "$FAKE_PACMAN_DBPATH/sync"
assert_output "$(printf '%s\n' su-dummy.db su-dummy.db.sig)"
}
@test "pacman: a failing pre-check (ignored-dependency trap) changes nothing and exits with its status" {
use_fakes pacman
echo old >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
FAKE_PACMAN_SUP_RC=1 run skup <<<$'2-\n'
assert_failure 1
assert_line 'Checking the reviewed transaction (pacman -Sup)... FAILED.'
assert_output --partial ":: unable to satisfy dependency 'su-dummy-b>=2.0' required by su-dummy-a"
assert_output --partial 'the reviewed upgrade cannot be done (pacman -Sup exited 1): nothing was changed'
assert_output --partial 'hint: pacman aborts the whole transaction under --noconfirm'
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == old ]]
log_lacks '^pacman -Su '
log_lacks '^real sync'
}
@test "pacman: a failing -Su after the databases were installed warns how to finish" {
use_fakes pacman
FAKE_PACMAN_SU_RC=1 run skup <<<''
assert_failure 1
assert_output --partial "the system's pacman sync database now holds the reviewed metadata but 'pacman -Su' did not complete: it is newer than the installed packages. Finish the upgrade with 'pacman -Su' (or run skeptical-updater again)"
[[ $(grep -c "did not complete" <<<"$output") -eq 1 ]]
}
@test "pacman: an existing db.lck (another pacman) changes nothing and is left alone" {
use_fakes pacman
echo old >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
: >"$FAKE_PACMAN_DBPATH/db.lck"
run skup <<<''
assert_failure 1
assert_output --partial "the pacman database is locked by another process ($FAKE_PACMAN_DBPATH/db.lck); nothing was changed"
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == old ]]
[[ -e $FAKE_PACMAN_DBPATH/db.lck ]]
log_lacks '^pacman -Su '
}
@test "pacman: db.lck is ours during the install and gone before -Su runs" {
use_fakes pacman
echo old >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
touch -d '2 hours ago' "$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
run skup <<<''
assert_success
log_has 'db.lck at -Su: no'
[[ ! -e $FAKE_PACMAN_DBPATH/db.lck ]]
}
@test "pacman: a system database that changed since the review and is newer: refused (exit 2), nothing changed" {
use_fakes pacman
echo "synced elsewhere" >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
touch -d '+1 hour' "$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
run skup <<<''
assert_failure 2
assert_output --partial 'package databases changed since the review (su-dummy); run skeptical-updater again'
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == "synced elsewhere" ]]
[[ ! -e $FAKE_PACMAN_DBPATH/db.lck ]]
log_lacks '^pacman -Su '
}
@test "pacman: an identical system database is not copied again" {
use_fakes pacman
echo "reviewed 1" >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
touch -d '2 hours ago' "$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
before=$(stat -c %Y "$FAKE_PACMAN_DBPATH/sync/su-dummy.db")
run skup <<<''
assert_success
assert_line "The system's package databases are the reviewed ones already."
refute_line --regexp '^\+ cp '
[[ $(stat -c %Y "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == "$before" ]]
log_has 'pacman -Su --noconfirm'
}
@test "pacman: the install is all-or-nothing: a failed copy renames nothing" {
use_fakes pacman
echo old >"$FAKE_PACMAN_DBPATH/sync/su-dummy.db"
rm -f "$SYSBIN/cp"
FAKE_PACMAN_SIG=1 run skup <<<''
assert_failure 1
assert_output --partial 'cannot copy the reviewed package databases into'
[[ $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db") == old ]]
[[ ! -e $FAKE_PACMAN_DBPATH/sync/su-dummy.db.sig ]]
run ls -A "$FAKE_PACMAN_DBPATH/sync"
assert_output 'su-dummy.db'
[[ ! -e $FAKE_PACMAN_DBPATH/db.lck ]]
log_lacks '^pacman -Su '
}
@test "pacman: Ctrl-C during -Su after the install: the exit handler says how to finish" {
use_fakes pacman
FAKE_PACMAN_SU_KILL=INT run skup <<<''
assert_failure 130
assert_output --partial "the system's pacman sync database now holds the reviewed metadata but 'pacman -Su' did not complete"
assert_output --partial "Finish the upgrade with 'pacman -Su'"
}
@test "pacman: errors pacman prints about the throwaway database are shown in the listing" {
use_fakes pacman
FAKE_PACMAN_STDERR="error: could not open database su-dummy: invalid or corrupted database (PGP signature)" run skup list
assert_success
assert_output --partial 'warning: pacman: error: could not open database su-dummy: invalid or corrupted database (PGP signature)'
FAKE_PACMAN_STDERR="warning: database file for 'core' does not exist (use '-Sy' to download)" run skup list
refute_output --partial 'warning: pacman:'
}
@test "pacman: --no-refresh (or a failed refresh) upgrades with plain -Su" {
use_fakes pacman
run skup --no-refresh <<<''
assert_success
log_has 'pacman -Su --noconfirm'
log_lacks '^pacman -Sup --noconfirm'
: >"$FAKE_LOG"
FAKE_PACMAN_SY_RC=1 run skup <<<''
assert_success
assert_line 'Refreshing package lists... FAILED.'
log_has 'pacman -Su --noconfirm'
log_lacks '^pacman -Sup --noconfirm'
}
@test "pacman: a throwaway database that cannot be set up is reported; no refresh, plain -Su" {
use_fakes pacman
rm -f "$SYSBIN/ln"
run skup <<<''
assert_success
assert_output --partial 'cannot set up the temporary pacman database /tmp/skeptical-updater-db.'
assert_output --partial '(chmod/ln/mkdir failed): not refreshing'
log_lacks '^pacman -Sy '
log_has 'pacman -Su --noconfirm'
}
@test "pacman: throwaway databases of dead runs are removed at the start of a root run, a live run's stays" {
use_fakes pacman
sleep 0 &
dead=$!
wait "$dead"
mkdir -p "/tmp/skeptical-updater-db.$dead.Stale1/sync" "/tmp/skeptical-updater-db.$$.Live01"
ln -s "$FAKE_PACMAN_DBPATH/local" "/tmp/skeptical-updater-db.$dead.Stale1/local"
run skup list --no-refresh
assert_success
assert_line "Removed the temporary pacman database of an interrupted run (/tmp/skeptical-updater-db.$dead.Stale1)."
[[ ! -e /tmp/skeptical-updater-db.$dead.Stale1 ]]
[[ -d /tmp/skeptical-updater-db.$$.Live01 ]]
[[ -d $FAKE_PACMAN_DBPATH/local ]] # the symlink target is untouched
rm -rf "/tmp/skeptical-updater-db.$$.Live01"
SKEPTICAL_UPDATER_EUID=1000 run skup list --no-refresh
refute_output --partial 'Removed the temporary pacman database'
}
@test "pacman: Nx on a package others depend on is refused (Required By), nothing removed" {
use_fakes pacman
run skup --no-refresh <<<$'2x\nq'
assert_success
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
refute_output --partial '[y/N]'
log_lacks '^pacman -R'
}
@test "pacman: purge plan from -Rs --print (no cascade), purge with -Rns" {
use_fakes pacman
run skup --no-refresh <<<$'1x\ny\nq'
assert_success
log_has 'pacman -Rs --print --print-format %n -- su-dummy-a'
assert_output --partial 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N]'
log_has 'pacman -Rns --noconfirm -- su-dummy-a su-dummy-b'
log_lacks 'Rsc|Rnsc'
}
@test "pacman: when pacman itself refuses (breaks dependency) its message is shown and nothing is asked" {
use_fakes pacman
FAKE_PACMAN_RS_REFUSE=1 run skup --no-refresh <<<$'3x\nq'
assert_success
assert_output --partial ":: removing su-dummy-c breaks dependency 'su-dummy-c' required by su-dummy-a"
assert_output --partial 'cannot purge su-dummy-c: pacman refuses (see above); nothing was removed'
refute_output --partial '[y/N]'
log_lacks '^pacman -Rns'
}
@test "pacman: belt and braces - a plan with a non-dependency is refused" {
use_fakes pacman
FAKE_PACMAN_RS_EXTRA=su-dummy-c:expat run skup --no-refresh <<<$'3x\nq'
assert_success
assert_output --partial 'cannot purge su-dummy-c: pacman would also remove expat, which su-dummy-c does not depend on'
log_lacks '^pacman -Rns'
}
@test "pacman: a failed upgrade with exclusions explains the --noconfirm dependency trap" {
use_fakes pacman
FAKE_PACMAN_SU_RC=1 run skup --no-refresh <<<$'2-\n'
assert_failure 1
assert_output --partial 'hint: pacman aborts the whole transaction under --noconfirm when an ignored package is needed at a newer version'
}
@test "pacman: IgnorePkg rows ([ignored]) are not added to --ignore (pacman applies them itself)" {
use_fakes pacman
FAKE_PACMAN_QU=real run skup --no-refresh <<<''
assert_success
log_has 'pacman -Su --noconfirm'
}
# ---- dnf ----------------------------------------------------------------------------------------
@test "dnf: refresh is dnf -q --refresh --setopt=skip_if_unavailable=0 makecache" {
use_fakes dnf rpm
run skup list
assert_success
log_has 'dnf -q --refresh --setopt=skip_if_unavailable=0 makecache'
}
@test "dnf: a failed refresh warns and goes on" {
use_fakes dnf rpm
FAKE_DNF_MAKECACHE_RC=1 run skup list
assert_success
assert_line 'Refreshing package lists... FAILED.'
assert_output --partial 'warning: metadata refresh failed'
}
@test "dnf: exclusions are --exclude=a,b; the reviewed metadata is frozen (*.metadata_expire=-1)" {
use_fakes dnf rpm
run skup --no-refresh <<<$'3-\n4-\n'
assert_success
assert_line 'Excluded from this run: su-dummy-a su-dummy-b'
log_has "$DNF_UPGRADE --exclude=su-dummy-a,su-dummy-b"
assert_line "+ dnf -y --setopt=skip_if_unavailable=0 --setopt=\\*.metadata_expire=-1 upgrade --exclude=su-dummy-a,su-dummy-b"
}
@test "dnf: no exclusion => no --exclude" {
use_fakes dnf rpm
run skup --no-refresh <<<''
assert_success
log_has "$DNF_UPGRADE"
}
@test "dnf: Nx on a package others depend on is refused (rpm reverse dependencies), nothing removed" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
run skup --no-refresh <<<$'4x\nq'
assert_success
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
refute_output --partial '[y/N]'
log_lacks 'remove'
}
@test "dnf: purge plan from dnf --assumeno remove (dnf4 table, unused dependencies), then dnf -y remove" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
FAKE_DNF_UNUSED=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'3x\ny\nl\nq'
assert_success
log_has 'dnf --assumeno remove su-dummy-a'
assert_output --partial 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N]'
log_has 'dnf -y remove su-dummy-a su-dummy-b'
[[ $(grep -c '\[4\] su-dummy-b' <<<"$output") -eq 1 ]]
[[ $(grep -c '\[3\] su-dummy-a' <<<"$output") -eq 1 ]]
}
@test "dnf: belt and braces - a removal table with a non-dependency is refused" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
FAKE_DNF_DEPENDENT=su-dummy-c:bash run skup --no-refresh <<<$'5x\nq'
assert_success
assert_output --partial 'cannot purge su-dummy-c: dnf would also remove bash, which su-dummy-c does not depend on'
log_lacks '^dnf -y remove'
}
@test "dnf5: the purge plan table is parsed as well" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
FAKE_DNF_MAJOR=5 FAKE_DNF_UNUSED=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'3x\ny\nq'
assert_success
assert_output --partial 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N]'
log_has 'dnf -y remove su-dummy-a su-dummy-b'
}
@test "dnf: a refused removal (protected package) asks nothing and removes nothing" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
FAKE_DNF_REMOVE_REFUSE=1 run skup --no-refresh <<<$'5x\nq'
assert_success
assert_output --partial 'Error: Problem: The operation would result in removing the following protected packages: su-dummy-c'
assert_output --partial 'cannot purge su-dummy-c: dnf refuses (see above); nothing was removed'
refute_output --partial '[y/N]'
log_lacks '^dnf -y remove'
}
@test "dnf: a failed upgrade with exclusions explains the dnf4 excluded-dependency rule" {
use_fakes dnf rpm
FAKE_DNF_UPGRADE_RC=1 run skup --no-refresh <<<$'4-\n'
assert_failure 1
assert_output --partial "hint: dnf4 refuses to upgrade a package whose required newer dependency is excluded (exit 1)"
}
@test "yum fallback: the same commands through yum" {
use_fakes yum rpm
run skup <<<$'4-\n'
assert_success
log_has 'yum -q --refresh --setopt=skip_if_unavailable=0 makecache'
log_has 'yum -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade --exclude=su-dummy-b'
}
# ---- zypper -------------------------------------------------------------------------------------
zypp_conf() { # a "real" zypp.conf + locks file with one user lock, in the test dir
mkdir -p "$BATS_TEST_TMPDIR/etc"
printf '[main]\n# locksfile.path = /etc/zypp/locks\nlocksfile.path = %s\n' "$BATS_TEST_TMPDIR/etc/locks" >"$BATS_TEST_TMPDIR/etc/zypp.conf"
printf '\ntype: package\nmatch_type: glob\ncase_sensitive: on\nsolvable_name: su-dummy-c\ncomment: user lock\n\n' >"$BATS_TEST_TMPDIR/etc/locks"
export ZYPP_CONF=$BATS_TEST_TMPDIR/etc/zypp.conf
}
@test "zypper: refresh is zypper --non-interactive --quiet refresh" {
use_fakes zypper rpm
run skup list
assert_success
log_has 'zypper --non-interactive --quiet refresh'
}
@test "zypper: a failed refresh (exit 4) warns and goes on" {
use_fakes zypper rpm
FAKE_ZYPPER_REFRESH_RC=4 run skup list
assert_success
assert_output --partial 'Could not refresh the repositories because of errors.'
assert_output --partial 'exited 4); continuing with the cached package lists'
}
@test "zypper: Leap (ID=opensuse-leap) upgrades with update --no-refresh" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-leap
run skup --no-refresh <<<''
assert_success
log_has 'zypper --non-interactive --no-refresh update'
}
@test "zypper: Tumbleweed (ID=opensuse-tumbleweed) upgrades with dist-upgrade --no-refresh" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed
run skup --no-refresh <<<''
assert_success
log_has 'zypper --non-interactive --no-refresh dist-upgrade'
}
@test "zypper: exclusions are locks in a private ZYPP_CONF seeded with the real locks; the real lock file is untouched" {
use_fakes zypper rpm
zypp_conf
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-leap
before=$(cksum <"$BATS_TEST_TMPDIR/etc/locks")
run skup --no-refresh <<<$'2-\n'
assert_success
run grep -E '^zypper ZYPP_CONF=.*/skeptical-updater\.' "$FAKE_LOG"
assert_line --index 0 --regexp "^zypper ZYPP_CONF=$BATS_TEST_TMPDIR/skeptical-updater\.[A-Za-z0-9]+/zypp\.conf --non-interactive --quiet addlock su-dummy-b\$"
assert_line --index 1 --regexp "^zypper ZYPP_CONF=$BATS_TEST_TMPDIR/skeptical-updater\.[A-Za-z0-9]+/zypp\.conf --non-interactive --no-refresh update\$"
# the private lock file seen by the upgrade: the user's lock + ours
run sed -n '/^--- locks seen by update:/,/^--- end/p' "$FAKE_LOG"
assert_line 'solvable_name: su-dummy-c'
assert_line 'comment: user lock'
assert_line 'solvable_name: su-dummy-b'
# real locks unchanged, private directory and its marker removed
[[ $(cksum <"$BATS_TEST_TMPDIR/etc/locks") == "$before" ]]
run grep -c 'solvable_name' "$BATS_TEST_TMPDIR/etc/locks"
assert_output 1
run ls "$BATS_TEST_TMPDIR"
refute_output --partial 'skeptical-updater.'
[[ ! -e $STATE/zypper-private ]]
}
@test "zypper: commented locksfile.path lines are ignored; the private one (last [main]) is used" {
use_fakes zypper rpm
zypp_conf
run skup --no-refresh <<<$'1-\n'
assert_success
run sed -n '/^--- locks seen by/,/^--- end/p' "$FAKE_LOG"
assert_line 'comment: user lock'
assert_line 'solvable_name: su-dummy-a'
}
@test "zypper: zypp.conf.d drop-ins are part of the private config; a drop-in locksfile.path is honoured" {
use_fakes zypper rpm
r=$BATS_TEST_TMPDIR/root
mkdir -p "$r/usr/etc/zypp/zypp.conf.d" "$r/etc/zypp/zypp.conf.d"
printf '[main]\n# vendor file\n' >"$r/usr/etc/zypp/zypp.conf"
printf '[main]\nlocksfile.path = /nonexistent/vendor-dropin-locks\n' >"$r/usr/etc/zypp/zypp.conf.d/10-vendor.conf"
printf '[main]\nsolver.onlyRequires = true\nlocksfile.path = %s\n' "$r/admin-locks" >"$r/etc/zypp/zypp.conf.d/50-admin.conf"
printf '\ntype: package\nmatch_type: glob\ncase_sensitive: on\nsolvable_name: su-dummy-c\ncomment: drop-in lock\n\n' >"$r/admin-locks"
export SKEPTICAL_UPDATER_ZYPP_ROOT=$r
run skup --no-refresh <<<$'1-\n'
assert_success
run sed -n '/^--- locks seen by/,/^--- end/p' "$FAKE_LOG"
assert_line 'comment: drop-in lock'
assert_line 'solvable_name: su-dummy-a'
[[ $(grep -c solvable_name "$r/admin-locks") -eq 1 ]]
}
@test "zypper: with a drop-in, the dry run names every file of the private configuration" {
use_fakes zypper rpm
r=$BATS_TEST_TMPDIR/root
mkdir -p "$r/etc/zypp/zypp.conf.d"
printf '[main]\n' >"$r/etc/zypp/zypp.conf"
printf '[main]\nlocksfile.path = %s\n' "$r/l" >"$r/etc/zypp/zypp.conf.d/50-admin.conf"
SKEPTICAL_UPDATER_ZYPP_ROOT=$r run skup --dry-run --no-refresh <<<$'1-\n'
assert_success
assert_line --regexp "^\+ # private .*/zypp\.conf: $r/etc/zypp/zypp\.conf \+ $r/etc/zypp/zypp\.conf\.d/50-admin\.conf with locksfile\.path = .*/locks \(a copy of $r/l\)\$"
}
@test "zypper: no exclusion => plain zypper --non-interactive --no-refresh VERB, no private config" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-leap
run skup --no-refresh <<<''
assert_success
log_lacks 'ZYPP_CONF='
log_lacks 'addlock'
}
@test "zypper: Nx on a package others depend on is refused (rpm reverse dependencies), nothing removed" {
use_fakes zypper rpm
export FAKE_RPM_NO_D=1
run skup --no-refresh <<<$'2x\nq'
assert_success
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
refute_output --partial '[y/N]'
log_lacks 'remove'
}
@test "zypper: purge plan from remove --dry-run --xmlout (--clean-deps), then remove --clean-deps" {
use_fakes zypper rpm
export FAKE_RPM_NO_D=1
FAKE_ZYPPER_CLEAN=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x\ny\nq'
assert_success
log_has 'zypper --non-interactive --no-refresh --xmlout remove --dry-run --clean-deps su-dummy-a'
assert_output --partial 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N]'
log_has 'zypper --non-interactive --no-refresh remove --clean-deps su-dummy-a su-dummy-b'
}
@test "zypper: belt and braces - a removal plan with a non-dependency is refused" {
use_fakes zypper rpm
export FAKE_RPM_NO_D=1
FAKE_ZYPPER_EXTRA=su-dummy-c:bash run skup --no-refresh <<<$'3x\nq'
assert_success
assert_output --partial 'cannot purge su-dummy-c: zypper would also remove bash, which su-dummy-c does not depend on'
log_lacks 'remove --clean-deps su-dummy-c'
}
@test "zypper: a package zypper does not know (exit 104) is not purged, nothing is asked" {
use_fakes zypper rpm
FAKE_ZYPPER_LU=real run skup --no-refresh <<<$'1x\nq'
assert_success
assert_output --partial "zypper: no installed package provides 'openSUSE-build-key' (exit 104)"
assert_output --partial 'cannot purge openSUSE-build-key: zypper refuses (see above); nothing was removed'
refute_output --partial '[y/N]'
}
@test "zypper: exit 4 from the upgrade is passed through, with a hint" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed
FAKE_ZYPPER_UPGRADE_RC=4 run skup --no-refresh <<<''
assert_failure 4
assert_output --partial "hint: zypper hit a dependency/vendor problem that --non-interactive cancels (exit 4); run 'zypper dist-upgrade' by hand"
}
@test "zypper: an unreadable zypp.conf with exclusions is an error, nothing is upgraded" {
use_fakes zypper rpm
export ZYPP_CONF=$BATS_TEST_TMPDIR/missing.conf
run skup --no-refresh <<<$'1-\n'
assert_failure 1
assert_output --partial 'cannot read the zypp configuration'
log_lacks ' (update|dist-upgrade)$'
}
@test "zypper: a private configuration left by an interrupted run is removed at the next start" {
use_fakes zypper rpm
mkdir -p "$STATE" "$BATS_TEST_TMPDIR/skeptical-updater.Leftov"
: >"$BATS_TEST_TMPDIR/skeptical-updater.Leftov/zypp.conf"
echo "$BATS_TEST_TMPDIR/skeptical-updater.Leftov" >"$STATE/zypper-private"
run skup list --no-refresh
assert_success
assert_line "Removed the private zypper configuration of an interrupted run ($BATS_TEST_TMPDIR/skeptical-updater.Leftov)."
[[ ! -e $BATS_TEST_TMPDIR/skeptical-updater.Leftov ]]
[[ ! -e $STATE/zypper-private ]]
}
@test "zypper: the replay leaves a running upgrade's private configuration alone" {
use_fakes zypper rpm
mkdir -p "$STATE" "$BATS_TEST_TMPDIR/skeptical-updater.Running"
echo "$BATS_TEST_TMPDIR/skeptical-updater.Running" >"$STATE/zypper-private"
exec 9>>"$STATE/zypper.lock"
flock 9
run skup info su-dummy-a
exec 9>&-
assert_success
assert_line 'Note: another skeptical-updater run is using its private zypper configuration; leaving it alone.'
[[ -d $BATS_TEST_TMPDIR/skeptical-updater.Running ]]
[[ -e $STATE/zypper-private ]]
}
@test "zypper: the upgrade holds zypper.lock while its private configuration exists" {
use_fakes zypper rpm
zypp_conf
run skup --no-refresh <<<$'1-\n'
assert_success
log_has 'zypper.lock during update: held'
flock -n "$STATE/zypper.lock" true
}
@test "zypper: the private configuration is removed when the upgrade is killed" {
use_fakes zypper rpm
zypp_conf
# an upgrade that is killed (SIGTERM to the applet) in the middle
FAKE_ZYPPER_UPGRADE_KILL=TERM run skup --no-refresh <<<$'1-\n'
assert_failure 143
run ls "$BATS_TEST_TMPDIR"
refute_output --partial 'skeptical-updater.'
[[ ! -e $STATE/zypper-private ]]
}
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# CLI parsing and exit codes.
load test_helper
setup() { common_setup; }
@test "--help prints the usage and exits 0" {
run skup --help
assert_success
assert_line 'usage: skeptical-updater [options] [command]'
assert_line --partial 'refresh recompute dependency closures for every '"'user'"' root'
assert_line --partial '--dry-run print native commands instead of running mutating ones'
assert_line --partial 'exit status: 0 ok, 1 usage/backend error, 2 aborted'
[[ -z $(cat "$FAKE_LOG") ]]
}
@test "-h is --help, even after a command" {
run skup list -h
assert_success
assert_line 'usage: skeptical-updater [options] [command]'
}
@test "--version and -V print the version" {
run skup --version
assert_success
assert_output 'skeptical-updater 0.1.0'
run skup -V
assert_output 'skeptical-updater 0.1.0'
}
@test "an unknown option is a usage error (exit 1)" {
run skup --frobnicate
assert_failure 1
assert_line 'skeptical-updater: error: unknown option: --frobnicate'
assert_line "Try 'skeptical-updater --help'."
}
@test "an unknown command is a usage error (exit 1)" {
run skup upgrade
assert_failure 1
assert_line 'skeptical-updater: error: unknown command: upgrade'
}
@test "list, trusted and refresh take no arguments" {
for c in list trusted refresh; do
run skup "$c" extra
assert_failure 1
assert_output --partial "'$c' takes no arguments"
done
}
@test "the interactive mode takes no arguments either" {
run skup '' foo
assert_failure 1
}
@test "trust and untrust need at least one package" {
run skup trust
assert_failure 1
assert_output --partial "'trust' needs at least one package name"
run skup untrust
assert_failure 1
assert_output --partial "'untrust' needs at least one package name"
}
@test "info and show need exactly one package" {
run skup info
assert_failure 1
assert_output --partial "'info' needs exactly one package name"
run skup show a b
assert_failure 1
assert_output --partial "'show' needs exactly one package name"
}
@test "invalid package names are rejected before anything runs" {
# shellcheck disable=SC2016 # literal $(id) on purpose
for bad in '-rf' 'a b' 'foo;rm' '../x' 'x/y' '$(id)'; do
run skup trust -- "$bad"
assert_failure 1
assert_output --partial "invalid package name: '$bad'"
done
[[ -z $(cat "$FAKE_LOG") ]]
[[ ! -e $STATE ]]
}
@test "valid package names with + . : ~ @ _ are accepted" {
run skup --backend=apt trust 'libstdc++6' 'libc6:i386' 'g++-12' 'a.b~c' 'x_y@z'
assert_success
run cut -f2 "$STATE_FILE"
assert_output "$(printf '%s\n' "$HEADER" a.b~c g++-12 libc6:i386 libstdc++6 x_y@z)"
}
@test "--backend must name a known backend" {
run skup --backend=portage list
assert_failure 1
assert_output --partial "unknown backend 'portage' (expected apt, dnf, pacman or zypper)"
}
@test "--backend and --state-dir without a value are usage errors" {
run skup list --backend
assert_failure 1
assert_output --partial '--backend needs a value'
run skup list --state-dir
assert_failure 1
assert_output --partial '--state-dir needs a value'
run skup list --state-dir=
assert_failure 1
assert_output --partial 'empty state directory'
}
@test "options may follow the command; --backend NAME as two words" {
run skup list --backend apt --no-refresh
assert_success
assert_line --index 0 'Backend: apt (Debian GNU/Linux 12 (bookworm))'
log_lacks '^apt-get update'
}
@test "--state-dir overrides SKEPTICAL_UPDATER_STATE_DIR" {
run skup --state-dir="$BATS_TEST_TMPDIR/other" trust su-dummy-c
assert_success
[[ -f $BATS_TEST_TMPDIR/other/trusted.tsv ]]
[[ ! -e $STATE ]]
}
@test "-- ends option parsing" {
run skup info -- --all
assert_failure 1
assert_output --partial "invalid package name: '--all'"
}
@test "no ANSI colours when stdout is not a terminal" {
run skup list --no-refresh
assert_success
refute_output --partial $'\e['
}
@test "--no-color and NO_COLOR are accepted" {
run skup --no-color list --no-refresh
assert_success
NO_COLOR=1 run skup list --no-refresh
assert_success
refute_output --partial $'\e['
}
+1
View File
@@ -0,0 +1 @@
su-dummy-orphan
+13
View File
@@ -0,0 +1,13 @@
ii su-dummy-a su-dummy-b
ii su-dummy-b
ii su-dummy-c
ii su-dummy-w su-virtual | su-dummy-alt
ii su-dummy-v su-virtual
rc su-dummy-f su-dummy-c
ii base-files awk
ii mawk libc6 (>= 2.29) awk
ii libc6 libgcc-s1
ii libgcc-s1 gcc-12-base (= 12.2.0-14), libc6 (>= 2.35)
ii gcc-12-base
ii su-dummy-orphan
ii su-dummy-c-data
1 ii su-dummy-a su-dummy-b
2 ii su-dummy-b
3 ii su-dummy-c
4 ii su-dummy-w su-virtual | su-dummy-alt
5 ii su-dummy-v su-virtual
6 rc su-dummy-f su-dummy-c
7 ii base-files awk
8 ii mawk libc6 (>= 2.29) awk
9 ii libc6 libgcc-s1
10 ii libgcc-s1 gcc-12-base (= 12.2.0-14), libc6 (>= 2.35)
11 ii gcc-12-base
12 ii su-dummy-orphan
13 ii su-dummy-c-data
+25
View File
@@ -0,0 +1,25 @@
perl-base:
Installed: 5.36.0-7+deb12u3
Candidate: 5.36.0-7+deb12u4
Version table:
5.36.0-7+deb12u4 500
500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages
*** 5.36.0-7+deb12u3 500
500 http://deb.debian.org/debian bookworm/main amd64 Packages
100 /var/lib/dpkg/status
su-dummy-c:
Installed: 1.0
Candidate: 2.0
Version table:
2.0 500
500 file:/srv/su-repo ./ Packages
*** 1.0 100
100 /var/lib/dpkg/status
su-dummy-e:
Installed: 1.0
Candidate: 2.0
Version table:
2.0 500
500 file:/srv/su-repo ./ Packages
*** 1.0 100
100 /var/lib/dpkg/status
+1
View File
@@ -0,0 +1 @@
su-dummy-b: su-dummy-a
+3
View File
@@ -0,0 +1,3 @@
gpgv
Reverse Depends:
|apt
+4
View File
@@ -0,0 +1,4 @@
su-dummy-b
Reverse Depends:
su-dummy-a
su-dummy-a
+5
View File
@@ -0,0 +1,5 @@
Package: gpgv
Version: 2.2.40-1.1
Architecture: amd64
Description: GNU privacy guard - signature verification tool
+15
View File
@@ -0,0 +1,15 @@
Package: su-dummy-a
Version: 2.0
Section: misc
Priority: optional
Architecture: all
Maintainer: Skeptical Updater Fixture <su-fixture@example.invalid>
Depends: su-dummy-b (>= 2.0)
Description: dummy package su-dummy-a for skeptical-updater tests
This package only exists to exercise the package manager.
.
It ships one data file and one conffile (version 2.0).
Description-md5: 36da7dcdaf4ec207be2937d1f5dfa408
Filename: ./su-dummy-a_2.0_all.deb
Size: 840
+14
View File
@@ -0,0 +1,14 @@
Package: su-dummy-b
Version: 2.0
Section: misc
Priority: optional
Architecture: all
Maintainer: Skeptical Updater Fixture <su-fixture@example.invalid>
Description: dummy package su-dummy-b for skeptical-updater tests
This package only exists to exercise the package manager.
.
It ships one data file and one conffile (version 2.0).
Description-md5: 36da7dcdaf4ec207be2937d1f5dfa408
Filename: ./su-dummy-b_2.0_all.deb
Size: 840
+14
View File
@@ -0,0 +1,14 @@
Package: su-dummy-c
Version: 2.0
Section: misc
Priority: optional
Architecture: all
Maintainer: Skeptical Updater Fixture <su-fixture@example.invalid>
Description: dummy package su-dummy-c for skeptical-updater tests
This package only exists to exercise the package manager.
.
It ships one data file and one conffile (version 2.0).
Description-md5: 36da7dcdaf4ec207be2937d1f5dfa408
Filename: ./su-dummy-c_2.0_all.deb
Size: 840
+13
View File
@@ -0,0 +1,13 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
su-dummy-a su-dummy-b su-dummy-c
3 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst su-dummy-b [1.0] (2.0 su-fixture:su-repo [all])
Inst su-dummy-a [1.0] (2.0 su-fixture:su-repo [all])
Inst su-dummy-c [1.0] (2.0 su-fixture:su-repo [all])
Conf su-dummy-b (2.0 su-fixture:su-repo [all])
Conf su-dummy-a (2.0 su-fixture:su-repo [all])
Conf su-dummy-c (2.0 su-fixture:su-repo [all])
+13
View File
@@ -0,0 +1,13 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages have been kept back:
su-dummy-c
The following packages will be upgraded:
su-dummy-a su-dummy-b
2 upgraded, 0 newly installed, 0 to remove and 1 not upgraded.
Inst su-dummy-b [1.0] (2.0 su-fixture:su-repo [all])
Inst su-dummy-a [1.0] (2.0 su-fixture:su-repo [all])
Conf su-dummy-b (2.0 su-fixture:su-repo [all])
Conf su-dummy-a (2.0 su-fixture:su-repo [all])
+5
View File
@@ -0,0 +1,5 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
+26
View File
@@ -0,0 +1,26 @@
NOTE: This is only a simulation!
apt-get needs root privileges for real execution.
Keep also in mind that locking is deactivated,
so don't depend on the relevance to the real current situation!
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following NEW packages will be installed:
su-dummy-newdep
The following packages have been kept back:
perl-base su-dummy-c su-dummy-e
The following packages will be upgraded:
libaudit-common libaudit1 libc6:i386 libssl3t64 su-dummy-m su-dummy-slow
6 upgraded, 1 newly installed, 0 to remove and 3 not upgraded.
Inst libaudit-common [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
Conf libaudit-common (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
Inst libaudit1 [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [amd64])
Inst libssl3t64 [3.0.13-0ubuntu3.15] (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst su-dummy-newdep (1.0 su-fixture:su-repo [all])
Inst su-dummy-slow [0.9] (1.0 [all])
Inst su-dummy-m [1.0] (2.0 su-fixture:su-repo [amd64]) [su-dummy-m:amd64 on su-dummy-m:i386] [su-dummy-m:i386 ]
Inst libc6:i386 [2.36-9] (2.36-9+deb12u10 Debian-Security:12/oldstable-security, Debian:12.12/oldstable [i386]) []
Remv su-dummy-f [1.0]
Conf libaudit1 (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [amd64])
Conf libssl3t64 (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
+10
View File
@@ -0,0 +1,10 @@
gnutls.x86_64 3.8.10-9.el9_8 baseos
kernel-core.x86_64 5.14.0-612.1.el9 baseos
su-dummy-a.noarch 2.0-1 su-dummy
su-dummy-b.noarch 2.0-1 su-dummy
su-dummy-c.x86_64 2.0-1 su-dummy
vim-minimal.x86_64 2:8.2.2637-26.el9_8.23 baseos
Obsoleting Packages
su-dummy-new.noarch 1.0-1 su-dummy
su-dummy-old.noarch 1.0-1 @System
@@ -0,0 +1,6 @@
Upgrades
ca-certificates.noarch 2026.2.90_v9.0.317-1.fc44 updates
curl.x86_64 8.18.0-12.fc44 updates
su-dummy-a.noarch 2.0-1 su-dummy
su-dummy-b.noarch 2.0-1 su-dummy
su-dummy-c.x86_64 2.0-1 su-dummy
@@ -0,0 +1,4 @@
Obsoleting Packages
su-dummy-new.noarch 1.0-1 su-dummy
su-dummy-old.noarch 1.0-1 @System
+28
View File
@@ -0,0 +1,28 @@
Installed Packages
Name : su-dummy-a
Version : 1.0
Release : 1
Architecture : noarch
Size : 15
Source : su-dummy-a-1.0-1.src.rpm
Repository : @System
Summary : skeptical-updater dummy su-dummy-a (v1.0)
License : MIT
Description : Dummy package su-dummy-a version 1.0 for skeptical-updater tests.
:
: Second paragraph of su-dummy-a 1.0 (blank line above on purpose).
Available Upgrades
Name : su-dummy-a
Version : 2.0
Release : 1
Architecture : noarch
Size : 6.3 k
Source : su-dummy-a-2.0-1.src.rpm
Repository : su-dummy
Summary : skeptical-updater dummy su-dummy-a (v2.0)
License : MIT
Description : Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
:
: Second paragraph of su-dummy-a 2.0 (blank line above on purpose).
+5
View File
@@ -0,0 +1,5 @@
NAME="AlmaLinux"
VERSION="9.8 (Olive Jaguar)"
ID="almalinux"
ID_LIKE="rhel centos fedora"
PRETTY_NAME="AlmaLinux 9.8 (Olive Jaguar)"
+4
View File
@@ -0,0 +1,4 @@
NAME="Arch Linux"
PRETTY_NAME="Arch Linux"
ID=arch
BUILD_ID=rolling
+4
View File
@@ -0,0 +1,4 @@
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
ID=debian
+6
View File
@@ -0,0 +1,6 @@
NAME="openSUSE Leap"
VERSION="16.0"
ID="opensuse-leap"
ID_LIKE="suse opensuse"
VERSION_ID="16.0"
PRETTY_NAME="openSUSE Leap 16.0"
+5
View File
@@ -0,0 +1,5 @@
NAME="openSUSE Tumbleweed"
ID="opensuse-tumbleweed"
ID_LIKE="opensuse suse"
VERSION_ID="20261007"
PRETTY_NAME="openSUSE Tumbleweed"
+266
View File
@@ -0,0 +1,266 @@
Name : su-dummy-a
Version : 1.0-1
Description : Skeptical-updater dummy package A
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : su-dummy-b
Optional Deps : None
Required By : None
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : su-dummy-b
Version : 1.0-1
Description : Skeptical-updater dummy package B (dependency of A)
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Required By : su-dummy-a
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Installed as a dependency for another package
Install Script : No
Validated By : None
Name : su-dummy-c
Version : 1.0-1
Description : Skeptical-updater dummy package C (independent)
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Required By : None
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : bash
Version : 5.3.20-1
Description : The GNU Bourne Again shell
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : sh
Depends On : readline libreadline.so=8-64 glibc ncurses libncursesw.so=6-64
Optional Deps : None
Required By : None
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : readline
Version : 8.3.001-1
Description : GNU readline library
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : libhistory.so=8-64 libreadline.so=8-64
Depends On : glibc ncurses libncursesw.so=6-64
Optional Deps : None
Required By : bash
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : ncurses
Version : 6.5-4
Description : System V Release 4.0 curses emulation library
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : libncurses++w.so=6-64 libncursesw.so=6-64
Depends On : glibc gcc-libs
Optional Deps : None
Required By : bash readline
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : gcc-libs
Version : 15.2.1-1
Description : Runtime libraries shipped by GCC
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : glibc>=2.27
Optional Deps : None
Required By : ncurses
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : glibc
Version : 2.42-2
Description : GNU C Library
Architecture : x86_64
URL : https://www.gnu.org/software/libc
Licenses : GPL-2.0-or-later LGPL-2.1-or-later
Groups : None
Provides : None
Depends On : linux-api-headers>=4.10 tzdata filesystem
Optional Deps : gd: for memusagestat
perl: for mtrace
Required By : bash gcc-libs ncurses
readline
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 48.71 MiB
Packager : Frederik Schwan <freswa@archlinux.org>
Build Date : Sun Sep 7 10:00:00 2026
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Installed as a dependency for another package
Install Script : Yes
Validated By : Signature
Name : linux-api-headers
Version : 6.16-1
Description : Kernel headers sanitized for use in userspace
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Required By : glibc
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : tzdata
Version : 2026a-1
Description : Sources for time zone and daylight saving time data
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Required By : glibc
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : filesystem
Version : 2025.10.12-1
Description : Base Arch Linux files
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : iana-etc
Optional Deps : None
Required By : glibc
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
Name : iana-etc
Version : 20260920-1
Description : /etc/protocols and /etc/services provided by IANA
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Required By : filesystem
Optional For : None
Conflicts With : None
Replaces : None
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Install Date : Fri Oct 9 08:00:00 2026
Install Reason : Explicitly installed
Install Script : No
Validated By : None
+3
View File
@@ -0,0 +1,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
+6
View File
@@ -0,0 +1,6 @@
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]
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
+95
View File
@@ -0,0 +1,95 @@
Repository : su-dummy
Name : su-dummy-a
Version : 2.0-1
Description : Skeptical-updater dummy package A
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : su-dummy-b
Optional Deps : None
Conflicts With : None
Replaces : None
Download Size : 380.00 B
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Validated By : None
Repository : su-dummy
Name : su-dummy-b
Version : 2.0-1
Description : Skeptical-updater dummy package B (dependency of A)
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Conflicts With : None
Replaces : None
Download Size : 380.00 B
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Validated By : None
Repository : su-dummy
Name : su-dummy-c
Version : 2.0-1
Description : Skeptical-updater dummy package C (independent)
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Conflicts With : None
Replaces : None
Download Size : 380.00 B
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Validated By : None
Repository : su-dummy
Name : su-dummy-new
Version : 1.0-1
Description : Skeptical-updater dummy package NEW
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Conflicts With : None
Replaces : None
Download Size : 380.00 B
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Validated By : None
Repository : su-dummy2
Name : su-dummy-b
Version : 3.0-1
Description : Skeptical-updater dummy package B (dependency of A)
Architecture : any
URL : https://example.invalid/su-dummy
Licenses : custom
Groups : None
Provides : None
Depends On : None
Optional Deps : None
Conflicts With : None
Replaces : None
Download Size : 380.00 B
Installed Size : 4.00 B
Packager : su-fixture <su-fixture@example.invalid>
Build Date : Tue Nov 14 22:13:20 2023
Validated By : None
+9
View File
@@ -0,0 +1,9 @@
core bash 5.3.20-1 [installed]
core ca-certificates-mozilla 3.131-1 [installed: 3.130-1]
core expat 2.9.0-1 [installed: 2.8.5-1]
core glib2 2.90.1-1 [installed: 2.88.3-1]
extra pacman-contrib 1.13.1-1
su-dummy su-dummy-a 2.0-1 [installed: 1.0-1]
su-dummy su-dummy-b 2.0-1 [installed: 1.0-1]
su-dummy su-dummy-c 2.0-1 [installed: 1.0-1]
su-dummy2 su-dummy-b 3.0-1
+61
View File
@@ -0,0 +1,61 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/unit/data/rpm/db.bash - the fake rpm database (sourced by fakes/rpm/rpm).
# Shaped after discovery/rpm.md sections 4-5 (EL9): the fixture packages incl. the
# SU_FIXTURE_EXTRAS su-dummy-d (file dep, versioned dep, rich dep, rpmlib()), plus a small
# glibc/bash base (REQUIRENAME values carry no version, like rpm's; rich deps are whole) whose glibc carries the "(glibc-gconv-extra ... if redhat-rpm-config)" rich dep.
# shellcheck shell=bash disable=SC2034
declare -A ARCH=(
[su-dummy-a]=noarch [su-dummy-b]=noarch [su-dummy-c]=x86_64 [su-dummy-d]=noarch
[bash]=x86_64 [glibc]=x86_64 [glibc-gconv-extra]=x86_64 [glibc-common]=x86_64
[basesystem]=noarch [setup]=noarch [filesystem]=x86_64 [ncurses-libs]=x86_64
[redhat-rpm-config]=noarch [rpm]=x86_64 [gnutls]=x86_64 [vim-minimal]=x86_64
[kernel-core]=x86_64 [ca-certificates]=noarch [curl]=x86_64
)
# installed versions ("epoch:version-release" or "version-release"); kernel-core is installonly (two versions)
declare -A EVR=(
[su-dummy-a]=1.0-1 [su-dummy-b]=1.0-1 [su-dummy-c]=1.0-1 [su-dummy-d]=1.0-1
[bash]=5.1.8-9.el9 [glibc]=2.34-275.el9_8 [glibc-gconv-extra]=2.34-275.el9_8 [glibc-common]=2.34-275.el9_8
[basesystem]=11-13.el9 [setup]=2.13.7-10.el9 [filesystem]=3.16-5.el9 [ncurses-libs]=6.2-12.20210508.el9
[redhat-rpm-config]=210-1.el9 [rpm]=4.16.1.3-40.el9 [gnutls]=3.8.10-4.el9_8 [vim-minimal]=2:8.2.2637-26.el9_8.21
[kernel-core]='5.14.0-570.1.el9 5.14.0-611.5.el9' [ca-certificates]=2025.2.80_v9.0.305-91.el9 [curl]=8.18.0-1.fc44
)
declare -A SUMMARY=(
[su-dummy-a]='skeptical-updater dummy su-dummy-a (v1.0)' [su-dummy-b]='skeptical-updater dummy su-dummy-b (v1.0)'
[su-dummy-c]='skeptical-updater dummy su-dummy-c (v1.0)' [su-dummy-d]='skeptical-updater dummy su-dummy-d (v1.0)'
[bash]='The GNU Bourne Again shell' [glibc]='The GNU libc libraries'
)
declare -A REQUIRES=(
[su-dummy-a]=$'su-dummy-b\nrpmlib(CompressedFileNames)\nrpmlib(FileDigests)'
[su-dummy-b]=$'rpmlib(CompressedFileNames)\nrpmlib(FileDigests)'
[su-dummy-c]=$'rpmlib(CompressedFileNames)'
[su-dummy-d]=$'su-dummy-a\n/bin/sh\nsu-dummy-b\n(su-dummy-c if su-dummy-b)\nrpmlib(CompressedFileNames)'
[bash]=$'/usr/bin/sh\nfilesystem\nlibc.so.6()(64bit)\nlibc.so.6(GLIBC_2.34)(64bit)\nlibtinfo.so.6()(64bit)\nrtld(GNU_HASH)'
[glibc]=$'(glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config)\nbasesystem\nglibc-common'
[glibc-gconv-extra]=$'glibc(x86-64)'
[glibc-common]=$'/usr/bin/sh\nglibc'
[basesystem]=$'filesystem\nsetup'
[setup]=$'system-release'
[filesystem]=$'/bin/sh\nsetup'
[ncurses-libs]=$'libc.so.6()(64bit)\nrtld(GNU_HASH)'
[redhat-rpm-config]=$'rpm\n/usr/bin/sh'
[rpm]=$'/usr/bin/sh\nlibc.so.6()(64bit)'
)
declare -A PROVIDES=(
[su-dummy-a]=su-dummy-a [su-dummy-b]=su-dummy-b [su-dummy-c]=$'su-dummy-c\nsu-dummy-c(x86-64)' [su-dummy-d]=su-dummy-d
[bash]=$'bash\nbash(x86-64)\nconfig(bash)'
[glibc]=$'glibc\nglibc(x86-64)\nlibc.so.6()(64bit)\nlibc.so.6(GLIBC_2.34)(64bit)\nrtld(GNU_HASH)'
[glibc-gconv-extra]=$'glibc-gconv-extra\nglibc-gconv-extra(x86-64)'
[glibc-common]=glibc-common [basesystem]=basesystem [setup]=setup [filesystem]=filesystem
[ncurses-libs]=$'ncurses-libs\nlibtinfo.so.6()(64bit)'
[redhat-rpm-config]=redhat-rpm-config [rpm]=rpm
)
# 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
[su-dummy-c]=/usr/share/su-dummy/su-dummy-c [su-dummy-d]=/usr/share/su-dummy/su-dummy-d
[bash]=$'/bin/sh\n/usr/bin/bash\n/usr/bin/sh'
[glibc]=/usr/lib64/libc.so.6
)
if [[ -n ${FAKE_RPM_NO_D:-} ]]; then unset 'EVR[su-dummy-d]'; fi
+8
View File
@@ -0,0 +1,8 @@
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<message type="info">Computing distribution upgrade...</message>
<install-summary download-size="19047" space-usage-diff="0" packages-to-change="4" need-restart="0" need-reboot="0">
</install-summary>
<prompt id="1" type="0">Continue?</prompt>
</stream>
+18
View File
@@ -0,0 +1,18 @@
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<message type="info">Computing distribution upgrade...</message>
<install-summary download-size="19047" space-usage-diff="0" packages-to-change="4" need-restart="0" need-reboot="0">
<to-upgrade>
<solvable type="package" name="su-dummy-a" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="s su-dummy-a"><description>d
more</description></solvable><solvable type="package" name="su-dummy-b" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="s su-dummy-b"><description>d
more</description></solvable><solvable type="package" name="su-dummy-c" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="s su-dummy-c"><description>d
more</description></solvable><solvable type="product" name="openSUSE-release" edition="20261008-1" arch="noarch" repository="su-dummy" edition-old="20261007-1" arch-old="noarch" summary="s openSUSE-release"><description>d
more</description></solvable></to-upgrade>
</install-summary>
<prompt id="1" type="0">Continue?</prompt>
</stream>
+14
View File
@@ -0,0 +1,14 @@
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<message type="info">Computing distribution upgrade...</message>
<install-summary download-size="19047" space-usage-diff="0" packages-to-change="4" need-restart="0" need-reboot="0">
<to-remove>
<solvable type="package" name="libcurl-mini4" edition="8.16.0-1" arch="noarch" repository="@System" summary="s libcurl-mini4"><description>d
more</description></solvable></to-remove>
<to-install>
</to-install>
</install-summary>
<prompt id="1" type="0">Continue?</prompt>
</stream>
+20
View File
@@ -0,0 +1,20 @@
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<message type="info">Computing distribution upgrade...</message>
<install-summary download-size="19047" space-usage-diff="0" packages-to-change="4" need-restart="0" need-reboot="0">
<to-upgrade>
<solvable type="package" name="su-dummy-a" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="s su-dummy-a"><description>d
more</description></solvable></to-upgrade>
<to-install>
<solvable type="package" name="su-dummy-newdep" edition="1.0-1" arch="noarch" repository="su-dummy" summary="s su-dummy-newdep"><description>d
more</description></solvable></to-install>
<to-remove>
<solvable type="package" name="libcurl-mini4" edition="8.16.0-1" arch="noarch" repository="@System" summary="s libcurl-mini4"><description>d
more</description></solvable></to-remove>
</install-summary>
<prompt id="1" type="0">Continue?</prompt>
</stream>
@@ -0,0 +1,18 @@
Information for package su-dummy-a:
-----------------------------------
Repository : su-dummy
Name : su-dummy-a
Version : 2.0-1
Arch : noarch
Vendor :
Installed Size : 15 B
Installed : Yes
Status : out-of-date (version 1.0-1 installed)
Source package : su-dummy-a-2.0-1.src
Summary : skeptical-updater dummy su-dummy-a (v2.0)
Description :
Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
Second paragraph of su-dummy-a 2.0 (blank line above on purpose).
+15
View File
@@ -0,0 +1,15 @@
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<message type="info">Reading installed packages...</message>
<update-status version="0.6">
<update-list>
<update kind="package" name="su-dummy-a" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-a (v2.0)</summary><description>Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
Second paragraph of su-dummy-a 2.0 (blank line above on purpose).</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" name="su-dummy-b" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-b (v2.0)</summary><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).</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" name="su-dummy-c" edition="2.0-1" arch="x86_64" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-c (v2.0)</summary><description>Dummy package su-dummy-c version 2.0 for skeptical-updater tests.
Second paragraph of su-dummy-c 2.0 (blank line above on purpose).</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update></update-list>
</update-status>
</stream>
+9
View File
@@ -0,0 +1,9 @@
<?xml version='1.0'?>
<stream>
<message type="info">Loading repository data...</message>
<message type="info">Reading installed packages...</message>
<update-status version="0.6">
<update-list>
</update-list>
</update-status>
</stream>
+17
View File
@@ -0,0 +1,17 @@
<?xml version='1.0'?>
<stream>
<message type="info">Refreshing service &apos;openSUSE&apos;.</message>
<message type="info">Loading repository data...</message>
<message type="info">Reading installed packages...</message>
<update-status version="0.6">
<update-list>
<update kind="package" name="openSUSE-build-key" edition="1.0-69.1" arch="x86_64" edition-old="1.0-68.1"><summary>The public gpg keys for rpm package signature verification</summary><description>This package contains the gpg keys that are used to sign the
openSUSE distribution.</description><license/><source url="http://download.opensuse.org/update/tumbleweed/" alias="repo-update"/></update><update kind="package" name="su-dummy-a" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-a (v2.0)</summary><description>Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
Second paragraph of su-dummy-a 2.0 (blank line above on purpose).</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" name="su-dummy-b" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-b (v2.0)</summary><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).</description><license/><source url="file:/var/lib/su-dummy-repo" alias="openSUSE:repo-oss &amp; co"/></update><update kind="package" name="su-dummy-c" edition="2.0-1" arch="x86_64" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-c (v2.0)</summary><description>Dummy package su-dummy-c version 2.0 for skeptical-updater tests.
Second paragraph of su-dummy-c 2.0 (blank line above on purpose).</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update></update-list>
</update-status>
</stream>
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Backend detection (pacman > apt > dnf (else yum) > zypper), --backend override, labels, banner.
# each @test runs in its own subshell: per-test exports are intended
# shellcheck disable=SC2030,SC2031
load test_helper
setup() { common_setup; }
@test "pacman wins over apt, dnf and zypper" {
use_fakes pacman apt dnf zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-arch
run skup list --no-refresh
assert_success
assert_line --index 0 'Backend: pacman (Arch Linux)'
log_has 'pacman -Qu'
log_lacks '^(apt-get|dnf|zypper) '
}
@test "apt wins over dnf and zypper" {
use_fakes zypper dnf apt rpm
run skup list --no-refresh
assert_success
assert_line --index 0 'Backend: apt (Debian GNU/Linux 12 (bookworm))'
log_has 'apt-get -s --with-new-pkgs upgrade'
}
@test "dnf wins over zypper" {
use_fakes zypper dnf rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-alma9
run skup list --no-refresh
assert_success
assert_line --index 0 'Backend: dnf (AlmaLinux 9.8 (Olive Jaguar))'
log_has 'dnf -q check-update --setopt=skip_if_unavailable=0'
}
@test "yum is used when dnf is missing" {
use_fakes zypper yum rpm
run skup list --no-refresh
assert_success
assert_line --index 0 'Backend: yum (Debian GNU/Linux 12 (bookworm))'
log_has 'yum -q check-update --setopt=skip_if_unavailable=0'
}
@test "zypper comes last" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed
run skup list --no-refresh
assert_success
assert_line --index 0 'Backend: zypper (openSUSE Tumbleweed)'
log_has 'zypper --non-interactive --no-refresh --xmlout dist-upgrade --dry-run'
}
@test "pman in PATH: label pman, the commands stay pacman" {
use_fakes pman pacman
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-arch
run skup list
assert_success
assert_line --index 0 'Backend: pman (Arch Linux)'
log_matches '^pacman -Sy --dbpath /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+ --logfile /dev/null$'
log_matches '^pacman --dbpath /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+ -Qu$'
}
@test "--backend overrides the detection" {
use_fakes pacman apt
run skup --backend=apt list --no-refresh
assert_success
assert_line --index 0 'Backend: apt (Debian GNU/Linux 12 (bookworm))'
log_lacks '^pacman '
}
@test "--backend=pman and --backend=yum are aliases of pacman and dnf" {
use_fakes pacman apt dnf rpm
run skup --backend=pman list --no-refresh
assert_success
assert_line --index 0 --partial 'Backend: pacman'
run skup --backend=yum list --no-refresh
assert_success
assert_line --index 0 --partial 'Backend: dnf'
}
@test "the statefile backend column is the backend, not the label" {
use_fakes pman pacman
run skup trust su-dummy-c
assert_success
run cat "$STATE_FILE"
assert_line --regexp "^pacman${T}su-dummy-c${T}user${T}${DATE_RE}\$"
}
@test "no supported package manager: exit 1" {
use_fakes
run skup list --no-refresh
assert_failure 1
assert_output --partial 'no supported package manager found in PATH (pacman, apt-get, dnf/yum, zypper)'
}
@test "a forced backend whose tool is missing: exit 1" {
use_fakes apt
run skup --backend=zypper list --no-refresh
assert_failure 1
assert_output --partial 'zypper backend: zypper not found in PATH'
run skup --backend=pacman list --no-refresh
assert_failure 1
assert_output --partial 'pacman backend: pacman not found in PATH'
run skup --backend=dnf list --no-refresh
assert_failure 1
assert_output --partial 'dnf backend: neither dnf nor yum found in PATH'
}
@test "the banner uses PRETTY_NAME, quoted or not" {
printf "NAME=x\nPRETTY_NAME='Single Quoted 1.0'\n" >"$BATS_TEST_TMPDIR/osr"
SKEPTICAL_UPDATER_OS_RELEASE=$BATS_TEST_TMPDIR/osr run skup list --no-refresh
assert_line --index 0 'Backend: apt (Single Quoted 1.0)'
printf 'PRETTY_NAME=Plain\n' >"$BATS_TEST_TMPDIR/osr"
SKEPTICAL_UPDATER_OS_RELEASE=$BATS_TEST_TMPDIR/osr run skup list --no-refresh
assert_line --index 0 'Backend: apt (Plain)'
}
@test "the banner without os-release shows just the backend" {
SKEPTICAL_UPDATER_OS_RELEASE=$BATS_TEST_TMPDIR/missing run skup list --no-refresh
assert_success
assert_line --index 0 'Backend: apt'
}
@test "trusted needs no package manager at all" {
use_fakes
write_state "$HEADER" "apt${T}x${T}user${T}2026-10-09T09:40:12Z"
run skup trusted
assert_success
assert_line "apt${T}x${T}user${T}2026-10-09T09:40:12Z"
}
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# --dry-run: mutating commands (upgrade, purge, hold/unhold, lock files, statefile writes)
# are printed as "+ <cmd>" instead of executed; read-only queries and (as root) the
# metadata refresh still run.
load test_helper
setup() { common_setup; }
APT_UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade'
@test "apt: refresh runs, hold/upgrade/unhold are printed, nothing mutating is executed" {
run skup --dry-run <<<$'2-\n'
assert_success
assert_line 'Refreshing package lists... done.'
log_has 'apt-get update -qq -o APT::Update::Error-Mode=any'
assert_line '+ apt-mark hold su-dummy-b'
assert_line "+ $APT_UPGRADE"
assert_line '+ apt-mark unhold su-dummy-b'
assert_line 'Dry run: nothing was changed.'
log_lacks '^apt-get -y'
log_lacks '^apt-mark (un)?hold'
run cat "$FAKE_DIR/holds"
assert_output ''
[[ ! -e $STATE ]]
}
@test "apt: a package the admin already held is not printed as held" {
echo su-dummy-c >"$FAKE_DIR/holds"
FAKE_APT_SIM=held run skup --dry-run --no-refresh <<<$'3-\n'
assert_success
refute_line --partial 'apt-mark hold'
refute_line --partial 'apt-mark unhold'
assert_line "+ $APT_UPGRADE"
}
@test "N+ prints the statefile changes and writes nothing; the rows still leave the list" {
run skup --dry-run --no-refresh <<<$'1+\nl\nq'
assert_success
assert_line "+ mkdir -p -m 0755 $STATE"
assert_line "+ $STATE_FILE add: apt su-dummy-a user"
assert_line "+ $STATE_FILE add: apt su-dummy-b dep:su-dummy-a"
assert_line '3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:'
[[ ! -e $STATE ]]
}
@test "Nx prints the purge commands and drops the row" {
run skup --dry-run --no-refresh <<<$'3x\ny\nl\nq'
assert_success
assert_line '+ apt-get -y purge su-dummy-c'
refute_output --partial 'autoremove'
assert_line '2 upgradable, 0 trusted (hidden, use --all to show), 2 to review:'
log_lacks '^apt-get -y'
}
@test "trust, untrust and refresh print their statefile changes and write nothing" {
write_state "$HEADER" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
before=$(cat "$STATE_FILE")
run skup --dry-run trust su-dummy-a
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
assert_line "+ $STATE_FILE add: apt su-dummy-a user"
assert_line "+ $STATE_FILE add: apt su-dummy-b dep:su-dummy-a"
run skup --dry-run untrust su-dummy-c
assert_success
assert_line "+ $STATE_FILE remove: apt su-dummy-c user"
write_state "$HEADER" "apt${T}su-dummy-w${T}user${T}2026-01-01T00:00:00Z"
before=$(cat "$STATE_FILE")
run skup --dry-run refresh
assert_success
assert_line "+ $STATE_FILE add: apt su-dummy-v dep:su-dummy-w"
[[ $(cat "$STATE_FILE") == "$before" ]]
}
@test "pacman: -Su --ignore and -Rns are printed" {
use_fakes pacman
run skup --dry-run --no-refresh <<<$'3x\ny\n2-\n'
assert_success
assert_line '+ pacman -Rns --noconfirm -- su-dummy-c'
assert_line '+ pacman -Su --noconfirm --ignore su-dummy-b'
log_lacks '^pacman -(Su|Syu|Rns) '
}
@test "dnf: upgrade --exclude and remove are printed" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
run skup --dry-run --no-refresh <<<$'5x\ny\n4-\n'
assert_success
assert_line '+ dnf -y remove su-dummy-c'
assert_line '+ dnf -y --setopt=skip_if_unavailable=0 --setopt=\*.metadata_expire=-1 upgrade --exclude=su-dummy-b'
log_lacks '^dnf -y'
}
@test "zypper: the private-lock commands are printed, no lock file is written" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-leap
mkdir -p "$BATS_TEST_TMPDIR/etc"
printf '[main]\nlocksfile.path = %s\n' "$BATS_TEST_TMPDIR/etc/locks" >"$BATS_TEST_TMPDIR/etc/zypp.conf"
export ZYPP_CONF=$BATS_TEST_TMPDIR/etc/zypp.conf
run skup --dry-run --no-refresh <<<$'2-\n'
assert_success
assert_line "+ # private $BATS_TEST_TMPDIR/skeptical-updater.XXXXXX/zypp.conf: $BATS_TEST_TMPDIR/etc/zypp.conf with locksfile.path = $BATS_TEST_TMPDIR/skeptical-updater.XXXXXX/locks (a copy of $BATS_TEST_TMPDIR/etc/locks)"
assert_line "+ env ZYPP_CONF=$BATS_TEST_TMPDIR/skeptical-updater.XXXXXX/zypp.conf zypper --non-interactive --quiet addlock su-dummy-b"
assert_line "+ env ZYPP_CONF=$BATS_TEST_TMPDIR/skeptical-updater.XXXXXX/zypp.conf zypper --non-interactive --no-refresh update"
log_lacks 'addlock| update$'
[[ ! -e $BATS_TEST_TMPDIR/etc/locks ]]
run ls "$BATS_TEST_TMPDIR"
refute_output --partial 'skeptical-updater.'
}
@test "as root the refresh really runs; as non-root it is printed" {
run skup --dry-run list
log_has 'apt-get update -qq -o APT::Update::Error-Mode=any'
: >"$FAKE_LOG"
SKEPTICAL_UPDATER_EUID=1000 run skup --dry-run list
assert_success
assert_line '+ apt-get update -qq -o APT::Update::Error-Mode=any'
log_lacks '^apt-get update'
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake apt-cache (unit tests): show / rdepends / policy from data/apt/
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
fake_log "$@"
pkgs=()
for a in "$@"; do case $a in -*) ;; *) pkgs+=("$a") ;; esac; done
verb=${pkgs[0]}
pkgs=("${pkgs[@]:1}")
case $verb in
show)
f=$FAKE_DATA/apt/show/${pkgs[0]}
if [[ ! -f $f ]] || fake_purged "${pkgs[0]}"; then
echo "E: No packages found" >&2
exit 100
fi
cat "$f"
;;
rdepends)
f=$FAKE_DATA/apt/rdepends/${pkgs[0]}
if [[ -f $f ]]; then cat "$f"; else printf '%s\nReverse Depends:\n' "${pkgs[0]}"; fi
;;
policy)
# print the stanzas of the requested packages
want=" ${pkgs[*]} "
show=0
while IFS= read -r line; do
if [[ $line =~ ^([^ ].*):$ ]]; then
if [[ $want == *" ${BASH_REMATCH[1]} "* ]]; then show=1; else show=0; fi
fi
((show)) && printf '%s\n' "$line"
done <"$FAKE_DATA/apt/policy.txt"
exit 0
;;
*)
echo "fake apt-cache: unhandled: $*" >&2
exit 99
;;
esac
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake apt-get (unit tests). Scenario: FAKE_APT_SIM=fixture|held|real|none|purge (data/apt/sim-*.txt).
# Purge simulation: installed = the "ii" rows of data/apt/dpkg-status.tsv; reverse dependencies from
# data/apt/purge-rdeps.txt; pre-existing orphans from data/apt/autoremovable.txt (added by
# --auto-remove, which is system-wide like the real one); FAKE_APT_AUTO_ORPHANS="pkg:dep ..." are
# the auto-installed dependencies a purge of pkg orphans; FAKE_APT_SIM_EXTRA="pkg:other ..." makes a
# simulated purge of pkg also remove other (a dependent apt-cache rdepends did not report).
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
fake_log "$@"
args=" $* "
D=$FAKE_DATA/apt
installed() { grep -q "^.i *"$'\t'"$1"$'\t' "$D/dpkg-status.tsv" && ! fake_purged "$1"; }
orphans() { while IFS= read -r o; do installed "$o" && printf '%s\n' "$o"; done <"$D/autoremovable.txt"; }
# purge_set [--auto-remove] NAME...: the packages a purge removes, sorted
purge_set() {
local auto=0 n r line
if [[ $1 == --auto-remove ]]; then
auto=1
shift
fi
{
for n in "$@"; do
installed "$n" || continue
echo "$n"
while IFS= read -r line; do
[[ $line == "$n: "* ]] || continue
for r in ${line#*: }; do installed "$r" && echo "$r"; done
done <"$D/purge-rdeps.txt"
if ((auto)); then
for r in ${FAKE_APT_AUTO_ORPHANS:-}; do [[ ${r%%:*} == "$n" ]] && installed "${r#*:}" && echo "${r#*:}"; done
fi
for r in ${FAKE_APT_SIM_EXTRA:-}; do [[ ${r%%:*} == "$n" ]] && installed "${r#*:}" && echo "${r#*:}"; done
done
if ((auto)); then orphans; fi
} | sort -u
}
case $args in
*' update '*)
rc=$(fake_rc FAKE_APT_UPDATE_RC 0)
if ((rc)); then
echo "E: Failed to fetch http://deb.debian.org/debian/dists/bookworm/InRelease Temporary failure resolving 'deb.debian.org'" >&2
echo "E: Some index files failed to download. They have been ignored, or old ones used instead." >&2
fi
exit "$rc"
;;
*' -s '*autoremove*)
while IFS= read -r o; do [[ -n $o ]] && echo "Remv $o [1.0]"; done < <(orphans)
exit 0
;;
*' -s '*purge*)
names=()
auto=()
for a in "$@"; do
case $a in --auto-remove) auto=(--auto-remove) ;; -* | purge) ;; *) names+=("$a") ;; esac
done
rc=$(fake_rc FAKE_APT_SIM_PURGE_RC 0)
if ((rc)); then
echo "E: Unable to correct problems, you have held broken packages." >&2
exit "$rc"
fi
for n in "${names[@]}"; do
if ! grep -q $'\t'"$n"$'\t' "$D/dpkg-status.tsv" && [[ $n != su-dummy-newdep ]]; then
echo "E: Unable to locate package $n" >&2
exit 100
fi
done
while IFS= read -r p; do [[ -n $p ]] && echo "Purg $p [1.0]"; done < <(purge_set "${auto[@]}" "${names[@]}")
exit 0
;;
*' -s '*' upgrade '*)
f=$D/sim-${FAKE_APT_SIM:-fixture}.txt
while IFS= read -r line; do
name=
case $line in Inst\ * | Conf\ *) name=${line#* }; name=${name%% *} ;; esac
if [[ -n $name ]] && fake_purged "$name"; then continue; fi
if [[ $line == ' '* && -f $FAKE_DIR/purged ]]; then
while IFS= read -r p; do line=${line//$p/}; done <"$FAKE_DIR/purged"
fi
printf '%s\n' "$line"
done <"$f"
exit 0
;;
*' upgrade '*)
echo "holds during upgrade: $(sort "$FAKE_DIR/holds" 2>/dev/null | tr '\n' ' ')" >>"$FAKE_LOG"
# which of our lock files does the package manager have open (it must have none)?
if [[ -d /proc/$$/fd ]]; then
n=0
for fd in "/proc/$$/fd"/*; do
[[ $(readlink "$fd" 2>/dev/null) == *.lock ]] && n=$((n + 1))
done
echo "lock fds in the upgrade: $n" >>"$FAKE_LOG"
fi
if [[ -n ${FAKE_APT_UPGRADE_KILL:-} ]]; then
kill -s "$FAKE_APT_UPGRADE_KILL" "$PPID"
sleep 0.2
fi
if [[ -n ${FAKE_KILL_READER:-} ]]; then
# close the applet's stdout: kill the process reading it, then keep writing
pid=$(cat "$FAKE_DIR/reader.pid")
kill -9 "$pid"
while kill -0 "$pid" 2>/dev/null; do sleep 0.05; done
fi
echo "Reading package lists..." 2>/dev/null
echo "0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded." 2>/dev/null
exit "$(fake_rc FAKE_APT_UPGRADE_RC 0)"
;;
*' purge '*)
rc=$(fake_rc FAKE_APT_PURGE_RC 0)
if ((rc == 0)); then
for a in "$@"; do case $a in -* | purge) ;; *) fake_mark_purged "$a" ;; esac; done
# FAKE_APT_PURGE_ALSO: packages that disappear although the plan did not name them
read -r -a also <<<"${FAKE_APT_PURGE_ALSO:-}"
((${#also[@]} == 0)) || fake_mark_purged "${also[@]}"
else
echo "E: Held packages were changed and -y was used without --allow-change-held-packages." >&2
fi
exit "$rc"
;;
esac
echo "fake apt-get: unhandled: $*" >&2
exit 99
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake apt-mark (unit tests): hold state in $FAKE_DIR/holds (one name per line).
# FAKE_APT_MARK_LOCKED=N: the first N hold/unhold calls fail like dpkg's busy frontend lock.
# 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.
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
fake_log "$@"
h=$FAKE_DIR/holds
touch "$h"
verb=$1
shift
case $verb in hold | unhold)
if [[ -n ${FAKE_APT_MARK_LOCKED:-} ]]; then
n=$(cat "$FAKE_DIR/locked-calls" 2>/dev/null || echo 0)
if ((n < FAKE_APT_MARK_LOCKED)); then
echo $((n + 1)) >"$FAKE_DIR/locked-calls"
echo "${FAKE_APT_MARK_LOCK_MSG:-dpkg: error: dpkg frontend lock was locked by another process with pid 4242}"
echo "E: Sub-process dpkg --set-selections returned an error code (2)"
echo "E: Executing dpkg failed. Are you root?"
exit 100
fi
fi
;;
esac
case $verb in
showhold) sort "$h" ;;
hold)
for p in "$@"; do
if grep -qxF -- "$p" "$h"; then echo "$p was already set on hold."; else
echo "$p" >>"$h"
echo "$p set on hold."
fi
done
[[ -z ${FAKE_APT_MARK_FAIL_MSG:-} ]] || echo "$FAKE_APT_MARK_FAIL_MSG" >&2
exit "$(fake_rc FAKE_APT_MARK_HOLD_RC 0)"
;;
unhold)
if [[ -n ${SKEPTICAL_UPDATER_STATE_DIR:-} && -e $SKEPTICAL_UPDATER_STATE_DIR/holds.lock ]] && command -v flock >/dev/null; then
if flock -n "$SKEPTICAL_UPDATER_STATE_DIR/holds.lock" true; then l=free; else l=held; fi
echo "holds.lock during unhold: $l" >>"$FAKE_LOG"
fi
if [[ -n ${FAKE_APT_MARK_UNHOLD_RC:-} ]]; then
echo "E: Unable to correct problems" >&2
exit "$FAKE_APT_MARK_UNHOLD_RC"
fi
if [[ -n ${FAKE_APT_MARK_UNHOLD_SIGNAL:-} ]]; then
kill -s "$FAKE_APT_MARK_UNHOLD_SIGNAL" "$(cat "$FAKE_DIR/applet.pid" 2>/dev/null || echo "$PPID")" 2>/dev/null
sleep 0.2
fi
for p in "$@"; do
if grep -qxF -- "$p" "$h"; then
grep -vxF -- "$p" "$h" >"$h.new" || true
mv "$h.new" "$h"
echo "Canceled hold on $p."
else echo "$p was already not on hold."; fi
done
;;
*)
echo "fake apt-mark: unhandled: $verb $*" >&2
exit 99
;;
esac
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# 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
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
fake_log "$@"
# FAKE_STATE_APPEND / FAKE_STATE_SED: simulate another process writing the statefile meanwhile
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
*' -W '*) cat "$FAKE_DATA/apt/dpkg-status.tsv" ;;
*)
echo "fake dpkg-query: unhandled: $*" >&2
exit 99
;;
esac
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# 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.
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
# shellcheck source=../../data/rpm/db.bash
. "$FAKE_DATA/rpm/db.bash"
fake_log "$@"
D=$FAKE_DATA/dnf
major=${FAKE_DNF_MAJOR:-4}
pos=()
for a in "$@"; do case $a in -*) ;; *) pos+=("$a") ;; esac; done
installed() { [[ -n ${EVR[$1]:-} ]] && ! fake_purged "$1"; }
case " $* " in
*' --version '*) if ((major == 5)); then echo "dnf5 version 5.4.3.0"; else echo "4.14.0"; fi ;;
*' makecache '*)
rc=$(fake_rc FAKE_DNF_MAKECACHE_RC 0)
((rc == 0)) || echo "Error: Failed to download metadata for repo 'su-dummy': Cannot download repomd.xml" >&2
exit "$rc"
;;
*' check-update '*)
rc=$(fake_rc FAKE_DNF_CHECK_RC 100)
f=$D/check-update-dnf$major.txt
[[ ${FAKE_DNF_CHECK:-} != obsoletes ]] || f=$D/check-update-obsoletes.txt
case $rc in
100)
while IFS= read -r line; do
read -r na _ <<<"$line"
[[ -n $na ]] && fake_purged "${na%.*}" && continue
printf '%s\n' "$line"
done <"$f"
;;
1) echo "Error: Failed to download metadata for repo 'baseos'" >&2 ;;
esac
exit "$rc"
;;
*' repoquery '*)
p=${pos[-1]}
case " $* " in
*' --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
exit 0
;;
*' info '*)
p=${pos[-1]}
if [[ -f $D/info-$p.txt ]]; then cat "$D/info-$p.txt"; else
echo "Error: No matching Packages to list" >&2
exit 1
fi
;;
*' --assumeno '*remove*)
# the removal table: the target, its dependents (su-dummy-a for su-dummy-b; FAKE_DNF_DEPENDENT=
# "pkg:other"), its unused dependencies (FAKE_DNF_UNUSED="pkg:dep"); FAKE_DNF_WRAP wraps those rows
p=${pos[-1]}
if [[ -n ${FAKE_DNF_REMOVE_REFUSE:-} ]]; then
echo "Error: Problem: The operation would result in removing the following protected packages: $p"
exit 1
fi
if ! installed "$p"; then
echo "No match for argument: $p"
echo "No packages marked for removal."
echo "Dependencies resolved."
echo "Nothing to do."
echo "Complete!"
exit 0
fi
dependent=() unused=()
[[ $p != su-dummy-b ]] || ! installed su-dummy-a || dependent+=(su-dummy-a)
for r in ${FAKE_DNF_DEPENDENT:-}; do [[ ${r%%:*} == "$p" ]] && dependent+=("${r#*:}"); done
for r in ${FAKE_DNF_UNUSED:-}; do [[ ${r%%:*} == "$p" ]] && unused+=("${r#*:}"); done
row() { # row NAME
if ((major == 5)); then
if [[ -n ${FAKE_DNF_WRAP:-} && $1 != "$p" ]]; then
echo " $1"
echo " noarch 0:1.0-1 <unknown> 15.0 B"
else
echo " $1 noarch 0:1.0-1 <unknown> 15.0 B"
fi
elif [[ -n ${FAKE_DNF_WRAP:-} && $1 != "$p" ]]; then
printf ' %s\n' "$1"
printf ' noarch 1.0-1 @System 15\n'
else
printf ' %-20s noarch 1.0-1 @System 15\n' "$1"
fi
}
if ((major == 5)); then
echo "Package Arch Version Repository Size"
else
echo "Dependencies resolved."
echo "================================================================================"
echo " Package Architecture Version Repository Size"
echo "================================================================================"
fi
echo "Removing:"
row "$p"
if ((${#dependent[@]})); then
echo "Removing dependent packages:"
for r in "${dependent[@]}"; do row "$r"; done
fi
if ((${#unused[@]})); then
echo "Removing unused dependencies:"
for r in "${unused[@]}"; do row "$r"; done
fi
echo
n=$((1 + ${#dependent[@]} + ${#unused[@]}))
if ((major == 5)); then
echo "Transaction Summary:"
echo " Removing: $n packages"
echo "Operation aborted by the user."
else
echo "Transaction Summary"
echo "================================================================================"
echo "Remove $n Packages"
echo
echo "Operation aborted."
fi
exit 1
;;
*' upgrade '*) exit "$(fake_rc FAKE_DNF_UPGRADE_RC 0)" ;;
*' remove '*)
fake_mark_purged "${pos[@]:1}"
exit 0
;;
*)
echo "fake dnf: unhandled: $*" >&2
exit 99
;;
esac
+23
View File
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/unit/fakes/fakelib.bash - shared by the fake package-manager commands.
# Every fake logs its invocation ("<name> <args...>") to $FAKE_LOG and keeps its mutable
# state (holds, purged packages, ...) under $FAKE_DIR. Replayed outputs live in
# tests/unit/data/<backend>/ and are copied from the samples recorded in discovery/*.md.
# shellcheck shell=bash
FAKE_NAME=${FAKE_AS:-${0##*/}}
: "${FAKE_LOG:?FAKE_LOG not set}" "${FAKE_DIR:?FAKE_DIR not set}" "${FAKE_DATA:?FAKE_DATA not set}"
fake_log() { printf '%s %s\n' "$FAKE_NAME" "$*" >>"$FAKE_LOG"; }
# fake_rc VAR DEFAULT: exit code override from the environment (e.g. FAKE_APT_UPGRADE_RC=100)
fake_rc() {
local v=${!1:-}
printf '%s' "${v:-$2}"
}
# fake_purged NAME: was NAME removed by an earlier fake purge in this test?
fake_purged() { [[ -f $FAKE_DIR/purged ]] && grep -qxF -- "$1" "$FAKE_DIR/purged"; }
fake_mark_purged() { printf '%s\n' "$@" >>"$FAKE_DIR/purged"; }
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake pacman (unit tests). The "real" database is $FAKE_PACMAN_DBPATH (see pacman-conf);
# -Sy --dbpath DIR logs what DIR/sync was seeded with and writes DIR/sync/su-dummy.db
# ("reviewed <n>"; plus a .sig with FAKE_PACMAN_SIG=1); -Su logs the real su-dummy.db it applies.
# FAKE_PACMAN_SUP_RC=1 makes the -Sup pre-check fail like the ignored-dependency trap;
# FAKE_PACMAN_SU_KILL=SIG: -Su signals the applet (Ctrl-C during the upgrade);
# 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_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
# call its --dbpath (or "real") in $FAKE_DIR/pacman-dbpath.
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
fake_log "$@"
D=$FAKE_DATA/pacman
# blocks FILE NAME...: print the blank-line separated blocks whose Name is one of NAME
# (all blocks when no NAME); exit 1 (after printing the others) if a NAME is missing
blocks() {
local f=$1 line block='' name='' rc=0 n
shift
local -A want=() found=()
for n in "$@"; do want[$n]=1; done
while IFS= read -r line || [[ -n $block ]]; do
if [[ -z $line ]]; then
if [[ -n $block ]] && { (($# == 0)) || [[ -n ${want[$name]:-} ]]; } && ! fake_purged "$name"; then
printf '%s\n' "$block"
found[$name]=1
fi
block='' name=''
continue
fi
[[ $line =~ ^Name\ +:\ (.*)$ ]] && name=${BASH_REMATCH[1]}
block+=$line$'\n'
done <"$f"
for n in "$@"; do
if [[ -z ${found[$n]:-} ]]; then
echo "error: package '$n' was not found" >&2
rc=1
fi
done
return "$rc"
}
op='' dbpath=real print=0
targets=()
while (($#)); do
case $1 in
--dbpath)
dbpath=$2
shift
;;
--logfile | --print-format | --ignore) shift ;;
--print) print=1 ;;
--*) ;;
-[A-Z]*) [[ -n $op ]] || op=$1 ;;
-*) ;;
*) targets+=("$1") ;;
esac
shift
done
echo "$op $dbpath" >>"$FAKE_DIR/pacman-dbpath"
case $op in -Q* | -Si | -Sl) echo "$op ${COLUMNS-unset}" >>"$FAKE_DIR/pacman-columns" ;; esac
qu() {
local f=$D/Qu-${FAKE_PACMAN_QU:-fixture}.txt line
[[ -f $f ]] || return 0
while IFS= read -r line; do
fake_purged "${line%% *}" || printf '%s\n' "$line"
done <"$f"
}
case $op in
-Sy)
if [[ $dbpath != real ]]; then
echo "seeded: $(cd "$dbpath/sync" && echo *)" >>"$FAKE_LOG"
rc=$(fake_rc FAKE_PACMAN_SY_RC 0)
((rc)) && exit "$rc"
n=$(($(cat "$FAKE_DIR/sy-count" 2>/dev/null || echo 0) + 1))
echo "$n" >"$FAKE_DIR/sy-count"
echo "reviewed $n" >"$dbpath/sync/su-dummy.db"
[[ -z ${FAKE_PACMAN_SIG:-} ]] || echo "signature $n" >"$dbpath/sync/su-dummy.db.sig"
fi
exit "$(fake_rc FAKE_PACMAN_SY_RC 0)"
;;
-Qu)
[[ -z ${FAKE_PACMAN_STDERR:-} ]] || echo "$FAKE_PACMAN_STDERR" >&2
out=$(qu)
[[ -n $out ]] || exit 1
printf '%s\n' "$out"
;;
-Sup)
if [[ -n ${FAKE_PACMAN_SUP_RC:-} ]]; then
echo ":: unable to satisfy dependency 'su-dummy-b>=2.0' required by su-dummy-a"
echo "error: failed to prepare transaction (could not satisfy dependencies)" >&2
exit "$FAKE_PACMAN_SUP_RC"
fi
qu | while read -r n _ _ _ flag; do [[ $flag == '[ignored]' ]] || echo "$n"; done
for n in ${FAKE_PACMAN_SUP_EXTRA:-}; do echo "$n"; done
;;
-Sl)
[[ -z ${FAKE_PACMAN_STDERR:-} ]] || echo "$FAKE_PACMAN_STDERR" >&2
[[ ${FAKE_PACMAN_SL:-} == empty ]] || cat "$D/Sl.txt"
;;
-Qi) blocks "$D/Qi.txt" "${targets[@]}" ;;
-Si)
t=()
for a in "${targets[@]}"; do t+=("${a#*/}"); done
blocks "$D/Si.txt" "${t[@]}"
;;
-Su | -Syu)
echo "real sync at $op: $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db" 2>/dev/null || echo none) / $(cat "$FAKE_PACMAN_DBPATH/sync/su-dummy.db.sig" 2>/dev/null || echo no-sig)" >>"$FAKE_LOG"
if [[ -e $FAKE_PACMAN_DBPATH/db.lck ]]; then echo "db.lck at $op: yes" >>"$FAKE_LOG"; else echo "db.lck at $op: no" >>"$FAKE_LOG"; fi
if [[ -n ${FAKE_PACMAN_SU_KILL:-} ]]; then
kill -s "$FAKE_PACMAN_SU_KILL" "$PPID"
sleep 0.2
fi
exit "$(fake_rc FAKE_PACMAN_SU_RC 0)"
;;
-Rs)
((print)) || exit 98
tg=${targets[0]}
if [[ -n ${FAKE_PACMAN_RS_REFUSE:-} || $tg == su-dummy-b ]]; then
echo "checking dependencies..."
echo ":: removing $tg breaks dependency '$tg' required by su-dummy-a"
echo "error: failed to prepare transaction (could not satisfy dependencies)" >&2
exit 1
fi
case $tg in
su-dummy-a) printf '%s\n' su-dummy-a su-dummy-b ;; # b was installed as a dependency of a
su-dummy-c | glib2 | expat | ca-certificates-mozilla) echo "$tg" ;;
*)
echo "error: target not found: $tg" >&2
exit 1
;;
esac
for r in ${FAKE_PACMAN_RS_EXTRA:-}; do [[ ${r%%:*} == "$tg" ]] && echo "${r#*:}"; done
;;
-Rns)
rc=$(fake_rc FAKE_PACMAN_RNS_RC 0)
if ((rc)); then echo "error: failed to prepare transaction (could not satisfy dependencies)" >&2; else fake_mark_purged "${targets[@]}"; fi
exit "$rc"
;;
*)
echo "fake pacman: unhandled: $op $*" >&2
exit 99
;;
esac
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake pacman-conf (unit tests): the "real" pacman database lives in $FAKE_PACMAN_DBPATH
case ${1:-} in
DBPath) printf '%s/\n' "${FAKE_PACMAN_DBPATH:?}" ;;
*) exit 1 ;;
esac
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake pman (unit tests): only its presence matters (label "pman")
exit 0
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake rpm (unit tests): answers the queries skeptical-updater makes from data/rpm/db.bash
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
# shellcheck source=../../data/rpm/db.bash
. "$FAKE_DATA/rpm/db.bash"
fake_log "$@"
installed() { [[ -n ${EVR[$1]:-} ]] && ! fake_purged "$1"; }
all=0 whatprov=0 list=0 qf=''
names=()
while (($#)); do
case $1 in
-q) ;;
-qa) all=1 ;;
-ql) list=1 ;;
--whatprovides) whatprov=1 ;;
--qf | --queryformat)
qf=$2
shift
;;
*) names+=("$1") ;;
esac
shift
done
rc=0
if ((all)); then
for p in "${!EVR[@]}"; do
installed "$p" || continue
case $qf in
*'%{NAME}.%{ARCH}'*)
for v in ${EVR[$p]}; do
printf '%s.%s\t%s\n' "$p" "${ARCH[$p]}" "$v"
done
;;
*'%{REQUIRENAME}'*'%{=NAME}'*)
while IFS= read -r r; do [[ -n $r ]] && printf '%s\t%s\n' "$r" "$p"; done <<<"${REQUIRES[$p]:-}"
;;
*) printf '%s\n' "$p" ;;
esac
done
exit 0
fi
if ((whatprov)); then
for cap in "${names[@]}"; do
hit=0
for p in "${!EVR[@]}"; do
installed "$p" || continue
if grep -qxF -- "$cap" <<<"${PROVIDES[$p]:-}"$'\n'"${FILES[$p]:-}"; then
printf '%s\n' "$p"
hit=1
fi
done
if ((!hit)); then
echo "no package provides $cap"
rc=1
fi
done
exit "$rc"
fi
for p in "${names[@]}"; do
if ! installed "$p"; then
echo "package $p is not installed"
rc=1
continue
fi
if ((list)); then
if [[ -n ${FILES[$p]:-} ]]; then printf '%s\n' "${FILES[$p]}"; else echo "(contains no files)"; fi
continue
fi
case $qf in
*'%{REQUIRENAME}'*) [[ -z ${REQUIRES[$p]:-} ]] || printf '%s\n' "${REQUIRES[$p]}" ;;
*'%{PROVIDENAME}'*) [[ -z ${PROVIDES[$p]:-} ]] || printf '%s\n' "${PROVIDES[$p]}" ;;
*'%{SUMMARY}'*) printf '%s\n' "${SUMMARY[$p]:-dummy summary of $p}" ;;
*) printf '%s-%s.%s\n' "$p" "${EVR[$p]##* }" "${ARCH[$p]}" ;;
esac
done
exit "$rc"
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake sudo (unit tests): logs the command it was asked to run, runs nothing
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
fake_log "$@"
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake yum (unit tests): yum is the dnf binary on EL9+
FAKE_AS=yum exec "${BASH_SOURCE[0]%/*}/../dnf/dnf" "$@"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# fake zypper (unit tests). FAKE_ZYPPER_LU=fixture|real|none picks the list-updates XML,
# 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.
# shellcheck source=../fakelib.bash
. "${BASH_SOURCE[0]%/*}/../fakelib.bash"
fake_log "${ZYPP_CONF:+ZYPP_CONF=$ZYPP_CONF }$*"
D=$FAKE_DATA/zypper
locksfile() {
local f=${ZYPP_CONF:-} line l=
[[ -n $f && -r $f ]] || return 1
while IFS= read -r line; do
[[ $line =~ ^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*(.*)$ ]] && l=${BASH_REMATCH[1]}
done <"$f"
[[ -n $l ]] || return 1
printf '%s' "$l"
}
pos=()
for a in "$@"; do case $a in -*) ;; *) pos+=("$a") ;; esac; done
verb=${pos[0]:-}
args=("${pos[@]:1}")
xml_remove() { # the install-summary of `remove --dry-run` for the given names
local n
printf "<?xml version='1.0'?>\n<stream>\n<install-summary download-size=\"0\" space-usage-diff=\"-30\" packages-to-change=\"%s\">\n<to-remove>\n" "$#"
for n in "$@"; do printf '<solvable type="package" name="%s" edition="1.0-1" arch="noarch" repository="@System" summary="dummy %s"><description>d</description></solvable>\n' "$n" "$n"; done
printf '</to-remove>\n</install-summary>\n</stream>\n'
}
dry=0
case " $* " in *' --dry-run '*) dry=1 ;; esac
case $verb in
refresh)
rc=$(fake_rc FAKE_ZYPPER_REFRESH_RC 0)
((rc == 0)) || echo "Could not refresh the repositories because of errors." >&2
exit "$rc"
;;
list-updates) cat "$D/lu-${FAKE_ZYPPER_LU:-fixture}.xml" ;;
info)
p=${args[-1]}
if [[ -f $D/info-$p.txt ]]; then cat "$D/info-$p.txt"; else
echo "package '$p' not found."
exit 104
fi
;;
addlock)
l=$(locksfile) || {
echo "fake zypper: addlock without a private ZYPP_CONF/locksfile.path" >&2
exit 98
}
for p in "${args[@]}"; do printf '\ntype: package\nmatch_type: glob\ncase_sensitive: on\nsolvable_name: %s\n\n' "$p" >>"$l"; done
;;
dist-upgrade | dup | update)
if ((dry)); then
cat "$D/dup-${FAKE_ZYPPER_DUP:-fixture}.xml"
exit 0
fi
if l=$(locksfile); then { echo "--- locks seen by $verb:"; cat "$l"; echo "--- end"; } >>"$FAKE_LOG"; fi
if [[ -n ${ZYPP_CONF:-} && -n ${SKEPTICAL_UPDATER_STATE_DIR:-} && -e $SKEPTICAL_UPDATER_STATE_DIR/zypper.lock ]]; then
if flock -n "$SKEPTICAL_UPDATER_STATE_DIR/zypper.lock" true; then l=free; else l=held; fi
echo "zypper.lock during $verb: $l" >>"$FAKE_LOG"
fi
if [[ -n ${FAKE_ZYPPER_UPGRADE_KILL:-} ]]; then
kill -s "$FAKE_ZYPPER_UPGRADE_KILL" "$PPID"
sleep 0.2
fi
exit "$(fake_rc FAKE_ZYPPER_UPGRADE_RC 0)"
;;
remove)
if ((dry)); then
set -- "${args[@]}"
# FAKE_ZYPPER_CLEAN="pkg:dep" (unneeded dependency) / FAKE_ZYPPER_EXTRA="pkg:other" join the plan
more=()
for r in ${FAKE_ZYPPER_CLEAN:-} ${FAKE_ZYPPER_EXTRA:-}; do [[ ${r%%:*} == "${1:-}" ]] && more+=("${r#*:}"); done
case ${1:-} in
su-dummy-b) xml_remove su-dummy-a su-dummy-b "${more[@]}" ;;
su-dummy-*) xml_remove "$1" "${more[@]}" ;;
*)
echo "'$1' not found in package names. Trying capabilities."
echo "No provider of '$1' found."
exit 104
;;
esac
exit 0
fi
for p in "${args[@]}"; do
case $p in su-dummy-*) ;; *)
echo "'$p' not found in package names. Trying capabilities."
echo "No provider of '$p' found."
exit 104
;;
esac
done
fake_mark_purged "${args[@]}"
;;
*)
echo "fake zypper: unhandled: $*" >&2
exit 99
;;
esac
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# The interactive grammar (stdin is the command channel; no TTY): apt backend, fixture rows
# [1] su-dummy-a [2] su-dummy-b [3] su-dummy-c (su-dummy-a depends on su-dummy-b).
load test_helper
setup() { common_setup; }
UPGRADE='apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade'
@test "the session shows the list and the prompt help; q quits with exit 0, nothing upgraded" {
run skup --no-refresh <<<'q'
assert_success
assert_line --index 0 'Backend: apt (Debian GNU/Linux 12 (bookworm))'
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
assert_line ' [1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo'
assert_line '<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever'
assert_line '> q'
assert_line 'Quit: nothing was upgraded.'
log_lacks '^apt-get -y'
log_lacks '^apt-mark (un)?hold'
}
@test "an empty line proceeds: the native upgrade runs, exit 0" {
run skup <<<''
assert_success
assert_line 'Excluded from this run: none'
assert_line 'Upgrade finished.'
log_has 'apt-get update -qq -o APT::Update::Error-Mode=any'
log_has "$UPGRADE"
}
@test "a whitespace-only line also proceeds" {
run skup --no-refresh <<<' '
assert_success
log_has "$UPGRADE"
}
@test "the exit status of the native upgrade is passed through" {
FAKE_APT_UPGRADE_RC=100 run skup --no-refresh <<<''
assert_failure 100
assert_output --partial 'the upgrade failed: apt exited with status 100'
}
@test "EOF aborts with exit 2 without upgrading" {
run skup --no-refresh <<<'1?'
assert_failure 2
assert_output --partial 'end of input: aborted, nothing was upgraded'
log_lacks '^apt-get -y'
}
@test "EOF straight away (empty stdin) also aborts with exit 2" {
run skup --no-refresh </dev/null
assert_failure 2
log_lacks '^apt-get -y'
}
@test "a last line without a newline is still executed before EOF" {
run skup --no-refresh < <(printf '2-')
assert_failure 2
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'
assert_success
assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests'
assert_line ' required by (installed): su-dummy-a'
}
@test "N?? prints the native full info" {
run skup --no-refresh <<<$'1??\nq'
assert_success
assert_line 'Package: su-dummy-a'
assert_line 'Depends: su-dummy-b (>= 2.0)'
log_has 'apt-cache show --no-all-versions su-dummy-a'
}
@test "N- toggles the exclusion and the '-' marker" {
run skup --no-refresh <<<$'2-\nl\n2-\nl\n2-\n'
assert_success
assert_line 'excluded from this run: su-dummy-b'
assert_line 're-included: su-dummy-b'
assert_line '-[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
assert_line 'Excluded from this run: su-dummy-b'
log_has 'apt-mark hold su-dummy-b'
log_has 'apt-mark unhold su-dummy-b'
}
@test "Nx asks y/N on the same stdin; y purges, the row leaves, numbers stay stable" {
run skup --no-refresh <<<$'3x\ny\nl\n3?\nq'
assert_success
assert_line 'Purge su-dummy-c now? [y/N] y'
assert_line 'purged: su-dummy-c'
log_has 'apt-get -y purge su-dummy-c'
log_lacks 'autoremove --purge'
assert_line '2 upgradable, 0 trusted (hidden, use --all to show), 2 to review:'
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
[[ $(grep -c '^ \[3\] su-dummy-c' <<<"$output") -eq 1 ]]
assert_output --partial 'no package [3] in the list (l to redraw)'
}
@test "Nx answered with N, n, empty or anything else does not purge" {
for answer in N n '' maybe; do
: >"$FAKE_LOG"
run skup --no-refresh <<<"3x"$'\n'"$answer"$'\nq'
assert_success
assert_line 'not purged: su-dummy-c'
log_lacks '^apt-get -y'
done
}
@test "Nx: a failing purge keeps the row" {
FAKE_APT_PURGE_RC=100 run skup --no-refresh <<<$'3x\nyes\nl\nq'
assert_success
assert_output --partial 'purge of su-dummy-c failed (apt exited 100); the row stays in the list'
assert_line ' [3] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
}
@test "Nx: EOF at the confirmation aborts with exit 2 and purges nothing" {
run skup --no-refresh <<<'3x'
assert_failure 2
log_lacks '^apt-get -y'
}
@test "Nx on a package other installed packages depend on is refused, like pacman -Rns; the row stays" {
run skup --no-refresh <<<$'2x\nl\nq'
assert_success
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
refute_output --partial '[y/N]'
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
[[ $(grep -c '^ \[2\] su-dummy-b' <<<"$output") -eq 2 ]]
log_lacks '^apt-get -y'
}
@test "Nx removes the target with the dependencies only it needed, listed in the prompt" {
FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x\ny\nl\nq'
assert_success
assert_line 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N] y'
assert_line 'purged: su-dummy-a su-dummy-b'
assert_line '1 upgradable, 0 trusted (hidden, use --all to show), 1 to review:'
assert_line ' [3] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
}
@test "Nx: rows the purge took with it unannounced leave the list too (re-listed afterwards)" {
FAKE_APT_PURGE_ALSO=su-dummy-a run skup --no-refresh <<<$'3x\ny\nl\nq'
assert_success
assert_line ' (su-dummy-a is no longer upgradable, removed from the list)'
assert_line '1 upgradable, 0 trusted (hidden, use --all to show), 1 to review:'
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
}
@test "Nx on a new dependency (old version -, not installed) is refused" {
FAKE_APT_SIM=real run skup --no-refresh <<<$'9x\nq'
assert_success
assert_line 'su-dummy-newdep is not installed (the upgrade would install it as a new dependency): nothing to purge; use 9- to exclude it from this run.'
refute_output --partial '[y/N]'
log_lacks 'purge'
}
@test "Nx refused by the package manager asks nothing" {
FAKE_APT_SIM_PURGE_RC=100 run skup --no-refresh <<<$'3x\nq'
assert_success
assert_output --partial 'E: Unable to correct problems, you have held broken packages.'
assert_output --partial 'cannot purge su-dummy-c: apt refuses (see above); nothing was removed'
refute_output --partial '[y/N]'
log_lacks '^apt-get -y'
}
@test "purging the last packages ends the session cleanly (exit 0)" {
export FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b
run skup --no-refresh <<<$'3x\ny\n1x\ny'
assert_success
assert_line 'purged: su-dummy-c'
assert_line 'purged: su-dummy-a su-dummy-b'
assert_line 'Nothing left to upgrade.'
log_lacks "^apt-get -y -o"
}
@test "purging every listed row keeps the session while trusted (hidden) rows remain" {
write_state "$HEADER" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
FAKE_APT_AUTO_ORPHANS=su-dummy-a:su-dummy-b run skup --no-refresh <<<$'1x\ny\nl\nq'
assert_success
assert_line '1 upgradable, 1 trusted (hidden, use --all to show), 0 to review:'
assert_line 'Quit: nothing was upgraded.'
}
@test "N+ trusts with the closure: rows leave, numbers stay, the hidden count grows" {
run skup --no-refresh <<<$'1+\nl\nq'
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
assert_line '3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:'
assert_line ' [3] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
# [1] and [2] only appear in the first listing
[[ $(grep -c '^ \[1\] su-dummy-a' <<<"$output") -eq 1 ]]
[[ $(grep -c '^ \[2\] su-dummy-b' <<<"$output") -eq 1 ]]
run cut -f1-3 "$STATE_FILE"
assert_line "apt${T}su-dummy-a${T}user"
assert_line "apt${T}su-dummy-b${T}dep:su-dummy-a"
}
@test "trusting a package re-includes the excluded rows that this trust hides" {
run skup --no-refresh <<<$'2-\n1+\n\n'
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
assert_line ' (su-dummy-b is trusted now: no longer excluded from this run)'
assert_line 'Excluded from this run: none'
log_lacks '^apt-mark hold'
}
@test "with --all a trusted row stays visible, so its exclusion stays too" {
run skup --no-refresh --all <<<$'2-\n1+\n\n'
assert_success
refute_output --partial 'no longer excluded'
assert_line 'Excluded from this run: su-dummy-b'
}
@test "trusted packages are hidden with a count; numbering covers the listed rows only" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
run skup --no-refresh <<<$'1?\nq'
assert_success
assert_line '3 upgradable, 1 trusted (hidden, use --all to show), 2 to review:'
assert_line ' [1] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
assert_line ' [2] su-dummy-c 1.0 -> 2.0 su-fixture:su-repo'
assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests'
}
@test "--all lists trusted packages too, marked with '='" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
run skup --no-refresh --all <<<$'2+\nl\nq'
assert_success
assert_line '3 upgradable, 1 trusted (marked with =), 2 to review:'
assert_line '=[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo'
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
assert_line 'trusted su-dummy-b'
assert_line '3 upgradable, 2 trusted (marked with =), 1 to review:'
assert_line '=[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
}
@test "trusted packages are upgraded silently (never excluded)" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
run skup --no-refresh <<<''
assert_success
assert_line 'Excluded from this run: none'
log_lacks '^apt-mark hold'
log_has "$UPGRADE"
}
@test "everything trusted: 0 to review, the prompt is still offered" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
"apt${T}su-dummy-b${T}user${T}2026-01-01T00:00:00Z" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
run skup --no-refresh <<<''
assert_success
assert_line '3 upgradable, 3 trusted (hidden, use --all to show), 0 to review:'
log_has "$UPGRADE"
}
@test "*+ trusts every listed package" {
run skup --no-refresh <<<$'*+\nl\nq'
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
assert_line 'trusted su-dummy-b'
assert_line 'trusted su-dummy-c'
assert_line '3 upgradable, 3 trusted (hidden, use --all to show), 0 to review:'
run cut -f2,3 "$STATE_FILE"
assert_line "su-dummy-b${T}user"
}
@test "*- excludes every listed package; *- again re-includes them" {
run skup --no-refresh <<<$'*-\nl\n*-\nl\n*-\n'
assert_success
assert_line 'excluded all 3 listed packages from this run'
assert_line 're-included all 3 listed packages'
assert_line '-[1] su-dummy-a 1.0 -> 2.0 su-fixture:su-repo'
assert_line 'Excluded from this run: su-dummy-a su-dummy-b su-dummy-c'
log_has 'apt-mark hold su-dummy-a su-dummy-b su-dummy-c'
}
@test "several tokens on one line run in order" {
run skup --no-refresh <<<$'1? 2- 3?\nl\nq'
assert_success
assert_line 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
assert_line 'excluded from this run: su-dummy-b'
assert_line 'su-dummy-c: dummy package su-dummy-c for skeptical-updater tests'
assert_line '-[2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
}
@test "an unknown token prints an error, runs nothing from that line and re-prompts" {
run skup --no-refresh <<<$'2- bogus\nl\n1+ 3y\nq'
assert_success
assert_output --partial "unknown command 'bogus' (h for help)"
assert_output --partial "unknown command '3y' (h for help)"
assert_line ' [2] su-dummy-b 1.0 -> 2.0 su-fixture:su-repo'
refute_output --partial 'excluded from this run'
[[ ! -e $STATE_FILE ]]
}
@test "numbers that are not in the list are rejected" {
run skup --no-refresh <<<$'9?\n0-\n4x\nq'
assert_success
assert_output --partial 'no package [9] in the list (l to redraw)'
assert_output --partial 'no package [0] in the list (l to redraw)'
assert_output --partial 'no package [4] in the list (l to redraw)'
log_lacks '^apt-get -y'
}
@test "h and ? print the help" {
run skup --no-refresh <<<$'h\n?\nq'
assert_success
[[ $(grep -c 'N?? full native info' <<<"$output") -eq 2 ]]
assert_line 'Several tokens can be given on one line, e.g. "1? 3- 4+".'
}
@test "l redraws the list" {
run skup --no-refresh <<<$'l\nq'
assert_success
[[ $(grep -c '^3 upgradable' <<<"$output") -eq 2 ]]
}
@test "q in the middle of a line stops there (later tokens never run)" {
run skup --no-refresh <<<$'1? q 3x'
assert_success
assert_line 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
refute_output --partial 'Purge'
}
@test "nothing to upgrade: no prompt, exit 0" {
FAKE_APT_SIM=none run skup --no-refresh </dev/null
assert_success
assert_line 'Nothing to upgrade.'
refute_output --partial '<enter> upgrade'
}
@test "held (kept back) rows can be reviewed like the others" {
echo su-dummy-c >"$FAKE_DIR/holds"
FAKE_APT_SIM=held run skup --no-refresh <<<$'3?\nq'
assert_success
assert_line ' [3] su-dummy-c 1.0 -> 2.0 file:/srv/su-repo ./ [held]'
assert_line 'su-dummy-c: dummy package su-dummy-c for skeptical-updater tests'
}
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Every backend parser, fed with the real outputs recorded in discovery/*.md
# (replayed by the fakes in tests/unit/fakes/, data in tests/unit/data/).
# each @test runs in its own subshell: per-test exports are intended
# shellcheck disable=SC2030,SC2031
load test_helper
setup() { common_setup; }
# ---- apt ----------------------------------------------------------------------------------------
@test "apt: Inst lines (all the edge cases) and the kept-back block (held, versions from policy)" {
FAKE_APT_SIM=real run skup list --no-refresh
assert_success
assert_line '10 upgradable, 0 trusted (hidden, use --all to show), 10 to review:'
assert_line --regexp '^ \[1\] libaudit-common +1:3\.1\.2-2\.1build1\.1 +-> +1:3\.1\.2-2\.1ubuntu0\.1 +Ubuntu:24\.04/noble-updates$'
assert_line --regexp '^ \[2\] libaudit1 +1:3\.1\.2-2\.1build1\.1 +-> +1:3\.1\.2-2\.1ubuntu0\.1 +Ubuntu:24\.04/noble-updates$'
assert_line --regexp '^ \[3\] libc6:i386 +2\.36-9 +-> +2\.36-9\+deb12u10 +Debian-Security:12/oldstable-security, Debian:12\.12/oldstable$'
assert_line --regexp '^ \[4\] libssl3t64 +3\.0\.13-0ubuntu3\.15 +-> +3\.0\.13-0ubuntu3\.16 +Ubuntu:24\.04/noble-updates, Ubuntu:24\.04/noble-security$'
assert_line --regexp '^ \[5\] perl-base +5\.36\.0-7\+deb12u3 +-> +5\.36\.0-7\+deb12u4 +http://deb\.debian\.org/debian-security bookworm-security/main amd64 \[held\]$'
assert_line --regexp '^ \[6\] su-dummy-c +1\.0 +-> +2\.0 +file:/srv/su-repo \./ \[held\]$'
assert_line --regexp '^ \[7\] su-dummy-e +1\.0 +-> +2\.0 +file:/srv/su-repo \./ \[held\]$'
assert_line --regexp '^ \[8\] su-dummy-m +1\.0 +-> +2\.0 +su-fixture:su-repo$'
assert_line --regexp '^ \[9\] su-dummy-newdep +- +-> +1\.0 +su-fixture:su-repo$'
assert_line --regexp '^ \[10\] su-dummy-slow +0\.9 +-> +1\.0$'
refute_output --partial 'su-dummy-f'
log_has 'apt-get -s --with-new-pkgs upgrade'
log_has 'apt-cache policy perl-base su-dummy-c su-dummy-e'
}
@test "apt: a held package from the fixture shows up with the held flag" {
FAKE_APT_SIM=held run skup list --no-refresh
assert_success
assert_line --regexp '^ \[1\] su-dummy-a +1\.0 +-> +2\.0 +su-fixture:su-repo$'
assert_line --regexp '^ \[3\] su-dummy-c +1\.0 +-> +2\.0 +file:/srv/su-repo \./ \[held\]$'
}
@test "apt: nothing to upgrade" {
FAKE_APT_SIM=none run skup list --no-refresh
assert_success
assert_line 'Nothing to upgrade.'
log_lacks '^apt-cache policy'
}
@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')"
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'
}
@test "apt: the description also comes from Description-<lang> fields (Translation files)" {
mkdir -p "$BATS_TEST_TMPDIR/data/apt/show"
cp -r "$FAKE_DATA"/. "$BATS_TEST_TMPDIR/data/"
sed -i 's/^Description: /Description-en: /' "$BATS_TEST_TMPDIR/data/apt/show/su-dummy-a"
sed -i 's/^Description: /Description-pt_BR: /' "$BATS_TEST_TMPDIR/data/apt/show/su-dummy-b"
FAKE_DATA=$BATS_TEST_TMPDIR/data run skup info su-dummy-a
assert_success
assert_line 'su-dummy-a: dummy package su-dummy-a for skeptical-updater tests'
FAKE_DATA=$BATS_TEST_TMPDIR/data run skup info su-dummy-b
assert_line 'su-dummy-b: dummy package su-dummy-b for skeptical-updater tests'
# Description-md5 is not a description
sed -i '/^Description-en: /d' "$BATS_TEST_TMPDIR/data/apt/show/su-dummy-a"
FAKE_DATA=$BATS_TEST_TMPDIR/data run skup info su-dummy-a
assert_line 'su-dummy-a: (no description available)'
}
@test "apt: info of an unknown package exits 1" {
run skup info no-such-pkg-xyz
assert_failure 1
assert_line 'no-such-pkg-xyz: (no description available: unknown package?)'
}
@test "apt: show is apt-cache show --no-all-versions (the candidate)" {
run skup show su-dummy-a
assert_success
assert_line 'Package: su-dummy-a'
assert_line 'Version: 2.0'
assert_line 'Depends: su-dummy-b (>= 2.0)'
run skup show no-such-pkg-xyz
assert_failure 1
assert_output --partial "no package information for 'no-such-pkg-xyz' (apt exited 100)"
}
# ---- pacman -------------------------------------------------------------------------------------
@test "pacman: -Qu joined with the FIRST -Sl repo; [ignored] => held" {
use_fakes pacman
FAKE_PACMAN_QU=real run skup list --no-refresh
assert_success
assert_line '6 upgradable, 0 trusted (hidden, use --all to show), 6 to review:'
assert_line ' [1] ca-certificates-mozilla 3.130-1 -> 3.131-1 core'
assert_line --regexp '^ \[2\] expat +2\.8\.5-1 +-> +2\.9\.0-1 +core \[held\]$'
assert_line --regexp '^ \[3\] glib2 +2\.88\.3-1 +-> +2\.90\.1-1 +core \[held\]$'
assert_line --regexp '^ \[5\] su-dummy-b +1\.0-1 +-> +2\.0-1 +su-dummy$'
refute_output --partial 'su-dummy2'
}
@test "pacman: rows survive an empty 'pacman -Sl' (no repo column instead of a broken join)" {
use_fakes pacman
FAKE_PACMAN_SL=empty run skup list --no-refresh
assert_success
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
assert_line ' [1] su-dummy-a 1.0-1 -> 2.0-1'
}
@test "pacman: packages only the transaction (-Sup) shows become a note" {
use_fakes pacman
FAKE_PACMAN_SUP_EXTRA='su-dummy-new' run skup list --no-refresh
assert_success
assert_line 'Note: the upgrade also installs (new dependencies or replacements): su-dummy-new'
}
@test "pacman: nothing in -Qu but pending work in -Sup: the upgrade is still offered" {
use_fakes pacman
FAKE_PACMAN_QU=none FAKE_PACMAN_SUP_EXTRA='su-dummy-new' run skup --no-refresh <<<''
assert_success
assert_line '0 to review, but pacman reports pending changes (see the notes): <enter> runs the upgrade.'
log_has 'pacman -Su --noconfirm'
}
@test "pacman: nothing upgradable (-Qu exits 1 with no output)" {
use_fakes pacman
FAKE_PACMAN_QU=none run skup list --no-refresh
assert_success
assert_line 'Nothing to upgrade.'
}
@test "pacman: description from -Qi, falling back to -Si for packages that are not installed" {
use_fakes pacman
run skup info su-dummy-a
assert_success
assert_line 'su-dummy-a: Skeptical-updater dummy package A'
assert_line ' required by (installed): nothing'
run skup info su-dummy-new
assert_success
assert_line 'su-dummy-new: Skeptical-updater dummy package NEW'
}
@test "pacman: reverse deps from 'Required By', wrapped continuation lines joined" {
use_fakes pacman
run skup info glibc
assert_success
assert_line 'glibc: GNU C Library'
assert_line ' required by (installed): bash, gcc-libs, ncurses, readline'
run skup info su-dummy-b
assert_line ' required by (installed): su-dummy-a'
}
@test "pacman: the closure resolves provides (sonames, sh) and version constraints" {
use_fakes pacman
run skup trust bash
assert_success
assert_output 'trusted bash (+8 dependencies: filesystem gcc-libs glibc iana-etc linux-api-headers ncurses readline tzdata)'
run skup trust su-dummy-a
assert_output 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
}
@test "pacman: show prints the installed (-Qi) and the candidate (-Si) records" {
use_fakes pacman
run skup show su-dummy-a
assert_success
assert_line 'Install Reason : Explicitly installed'
assert_line 'Repository : su-dummy'
assert_line 'Version : 2.0-1'
log_has 'pacman -Qi -- su-dummy-a'
log_has 'pacman -Si -- su-dummy-a'
run skup show nope
assert_failure 1
}
@test "pacman: every query runs with COLUMNS=0 (no wrapping)" {
use_fakes pacman
export COLUMNS=20
run skup info glibc
run skup list --no-refresh
run skup trust bash
run grep -v ' 0$' "$FAKE_DIR/pacman-columns"
assert_output ''
}
# ---- dnf ----------------------------------------------------------------------------------------
@test "dnf4: check-update (blank line, padding, Obsoleting section), old version from rpm -qa" {
use_fakes dnf rpm
run skup list --no-refresh
assert_success
assert_line '6 upgradable, 0 trusted (hidden, use --all to show), 6 to review:'
assert_line ' [1] gnutls 3.8.10-4.el9_8 -> 3.8.10-9.el9_8 baseos'
# installonly: the highest installed version is the old one
assert_line --regexp '^ \[2\] kernel-core +5\.14\.0-611\.5\.el9 +-> +5\.14\.0-612\.1\.el9 +baseos$'
assert_line --regexp '^ \[3\] su-dummy-a +1\.0-1 +-> +2\.0-1 +su-dummy$'
assert_line --regexp '^ \[5\] su-dummy-c +1\.0-1 +-> +2\.0-1 +su-dummy$'
# epoch kept on both sides
assert_line --regexp '^ \[6\] vim-minimal +2:8\.2\.2637-26\.el9_8\.21 +-> +2:8\.2\.2637-26\.el9_8\.23 +baseos$'
refute_line --regexp '\] su-dummy-new'
refute_output --partial 'su-dummy-old'
refute_output --partial 'Obsoleting'
assert_line 'Note: the upgrade also installs (obsoleting installed packages): su-dummy-new'
}
@test "dnf: check-update exit 100 with only obsoleting packages: 0 rows, but the upgrade is offered" {
use_fakes dnf rpm
FAKE_DNF_CHECK=obsoletes run skup --no-refresh <<<''
assert_success
assert_line '0 upgradable, 0 trusted (hidden, use --all to show), 0 to review:'
assert_line 'Note: the upgrade also installs (obsoleting installed packages): su-dummy-new'
assert_line '0 to review, but dnf reports pending changes (see the notes): <enter> runs the upgrade.'
refute_output --partial 'Nothing to upgrade.'
log_has 'dnf -y --setopt=skip_if_unavailable=0 --setopt=*.metadata_expire=-1 upgrade'
}
@test "dnf5: the 'Upgrades' header is skipped" {
use_fakes dnf rpm
FAKE_DNF_MAJOR=5 run skup list --no-refresh
assert_success
assert_line '5 upgradable, 0 trusted (hidden, use --all to show), 5 to review:'
assert_line --regexp '^ \[1\] ca-certificates +2025\.2\.80_v9\.0\.305-91\.el9 +-> +2026\.2\.90_v9\.0\.317-1\.fc44 +updates$'
assert_line --regexp '^ \[2\] curl +8\.18\.0-1\.fc44 +-> +8\.18\.0-12\.fc44 +updates$'
refute_output --partial 'Upgrades'
}
@test "dnf: check-update exit 0 means nothing to upgrade, exit 1 is an error (exit 1)" {
use_fakes dnf rpm
FAKE_DNF_CHECK_RC=0 run skup list --no-refresh
assert_success
assert_line 'Nothing to upgrade.'
FAKE_DNF_CHECK_RC=1 run skup list --no-refresh
assert_failure 1
assert_output --partial 'dnf check-update failed (exit 1)'
assert_output --partial 'could not list the upgradable packages'
}
@test "dnf: description from repoquery (candidate), rpm fallback when not upgradable" {
use_fakes dnf rpm
run skup info su-dummy-a
assert_success
assert_line 'su-dummy-a: skeptical-updater dummy su-dummy-a (v2.0)'
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'
FAKE_DNF_MAJOR=5 run skup info su-dummy-a
assert_line 'su-dummy-a: skeptical-updater dummy su-dummy-a (v2.0)'
}
@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'
run skup info su-dummy-c
assert_line ' required by (installed): su-dummy-d'
run skup info bash
assert_line ' required by (installed): filesystem, glibc-common, redhat-rpm-config, rpm, su-dummy-d'
}
@test "rpm closure: file deps, rich deps without their condition, rpmlib() dropped" {
use_fakes dnf rpm
run skup trust su-dummy-d
assert_success
assert_output 'trusted su-dummy-d (+11 dependencies: basesystem bash filesystem glibc glibc-common glibc-gconv-extra ncurses-libs setup su-dummy-a su-dummy-b su-dummy-c)'
refute_output --partial 'redhat-rpm-config'
refute_output --partial 'rpmlib'
run skup trust su-dummy-a
assert_output 'trusted su-dummy-a (its installed dependency was already trusted)'
}
@test "dnf4: a removal-table row wrapped onto two lines (long name) is one package" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
FAKE_DNF_UNUSED=su-dummy-a:su-dummy-b FAKE_DNF_WRAP=1 run skup --no-refresh <<<$'3x\ny\nq'
assert_success
assert_output --partial 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N]'
refute_output --partial 'noarch'
log_has 'dnf -y remove su-dummy-a su-dummy-b'
}
@test "dnf5: a wrapped removal-table row is one package as well" {
use_fakes dnf rpm
export FAKE_RPM_NO_D=1
FAKE_DNF_MAJOR=5 FAKE_DNF_UNUSED=su-dummy-a:su-dummy-b FAKE_DNF_WRAP=1 run skup --no-refresh <<<$'3x\ny\nq'
assert_success
assert_output --partial 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N]'
log_has 'dnf -y remove su-dummy-a su-dummy-b'
}
@test "dnf: show is dnf -q info" {
use_fakes dnf rpm
run skup show su-dummy-a
assert_success
assert_line 'Available Upgrades'
assert_line 'Version : 2.0'
log_has 'dnf -q info su-dummy-a'
}
# ---- zypper -------------------------------------------------------------------------------------
@test "zypper: --xmlout list-updates (one-line XML, multi-line descriptions, last record, entities)" {
use_fakes zypper rpm
FAKE_ZYPPER_LU=real run skup list --no-refresh
assert_success
assert_line '4 upgradable, 0 trusted (hidden, use --all to show), 4 to review:'
assert_line ' [1] openSUSE-build-key 1.0-68.1 -> 1.0-69.1 repo-update'
assert_line --regexp '^ \[2\] su-dummy-a +1\.0-1 +-> +2\.0-1 +su-dummy$'
assert_line --regexp '^ \[3\] su-dummy-b +1\.0-1 +-> +2\.0-1 +openSUSE:repo-oss & co$'
assert_line --regexp '^ \[4\] su-dummy-c +1\.0-1 +-> +2\.0-1 +su-dummy$'
log_has 'zypper --non-interactive --xmlout list-updates'
}
@test "zypper on Tumbleweed (root): the review list is the dist-upgrade plan" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed
run skup list --no-refresh
assert_success
log_has 'zypper --non-interactive --no-refresh --xmlout dist-upgrade --dry-run'
log_lacks 'list-updates'
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
assert_line ' [1] su-dummy-a 1.0-1 -> 2.0-1 su-dummy'
refute_output --partial 'openSUSE-release'
}
@test "zypper on Tumbleweed: new packages are rows (old -), removals are a note" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed
FAKE_ZYPPER_DUP=removes run skup list --no-refresh
assert_success
assert_line ' [2] su-dummy-newdep - -> 1.0-1 su-dummy'
assert_line 'Note: the dist-upgrade also removes: libcurl-mini4'
}
@test "zypper on Tumbleweed: a plan with only removals is not 'Nothing to upgrade'" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed
FAKE_ZYPPER_DUP=onlyremove run skup --no-refresh <<<''
assert_success
assert_line '0 to review, but zypper reports pending changes (see the notes): <enter> runs the upgrade.'
log_has 'zypper --non-interactive --no-refresh dist-upgrade'
FAKE_ZYPPER_DUP=empty run skup --no-refresh </dev/null
assert_success
assert_line 'Nothing to upgrade.'
}
@test "zypper on Tumbleweed as non-root: list-updates with a note (the plan needs root)" {
use_fakes zypper rpm
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-tumbleweed
SKEPTICAL_UPDATER_EUID=1000 run skup list --no-refresh
assert_success
log_has 'zypper --non-interactive --xmlout list-updates'
log_lacks 'dist-upgrade'
assert_line 'Note: not root: this is "zypper list-updates"; the dist-upgrade plan (which may differ) needs root'
}
@test "zypper: an empty update list" {
use_fakes zypper rpm
FAKE_ZYPPER_LU=none run skup list --no-refresh
assert_success
assert_line 'Nothing to upgrade.'
}
@test "zypper: description from 'zypper info' Summary, rpm fallback when zypper exits 104" {
use_fakes zypper rpm
run skup info su-dummy-a
assert_success
assert_line 'su-dummy-a: skeptical-updater dummy su-dummy-a (v2.0)'
run skup info su-dummy-d
assert_success
assert_line 'su-dummy-d: skeptical-updater dummy su-dummy-d (v1.0)'
run skup info nothing-like-this
assert_failure 1
}
@test "zypper: show is zypper info --requires; unknown package => exit 1" {
use_fakes zypper rpm
run skup show su-dummy-a
assert_success
assert_line 'Information for package su-dummy-a:'
log_has 'zypper --non-interactive --quiet info --requires su-dummy-a'
run skup show nothing-like-this
assert_failure 1
assert_output --partial "(zypper exited 104)"
}
@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'
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
}
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Robustness: the apt hold restore survives a closed stdout and repeated signals, locks and
# merge-on-save of the statefile, an inaccessible state directory.
# each @test runs in its own subshell: per-test exports are intended
# shellcheck disable=SC2030,SC2031
load test_helper
setup() { common_setup; }
# nobody_env: a world-reachable copy of the test environment under /tmp for running the applet as
# nobody (setpriv): $W/bin (tools), $W/fake (log), $W/tmp; sets W
nobody_env() {
((EUID == 0)) || skip "needs root to switch to an unprivileged user"
command -v setpriv >/dev/null 2>&1 || skip "setpriv not available"
W=$(mktemp -d /tmp/su-nobody.XXXXXX)
chmod 0755 "$W"
mkdir -p "$W/bin" "$W/fake" "$W/tmp" "$W/state"
chmod 0777 "$W/fake" "$W/tmp"
for t in "$SYSBIN"/*; do ln -s "$(readlink "$t")" "$W/bin/${t##*/}"; done
: >"$W/fake/log"
chmod 0666 "$W/fake/log"
}
# nobody CMD-ARGS...: run the applet as nobody (no flock in PATH: the mkdir lock fallback)
# shellcheck disable=SC2329 # invoked through `run`
nobody() {
rm -f "$W/bin/flock"
setpriv --reuid=65534 --regid=65534 --clear-groups -- env -u SKEPTICAL_UPDATER_EUID \
PATH="$FAKES/apt:$W/bin" FAKE_DIR="$W/fake" FAKE_LOG="$W/fake/log" TMPDIR="$W/tmp" \
SKEPTICAL_UPDATER_STATE_DIR="$W/state" "$SU_BIN" "$@"
}
@test "a closed stdout (the tee reading it was killed) does not stop the hold restore" {
FAKE_KILL_READER=1 run bash -c '
printf "2-\n\n" | env PATH="$1" "$2" --no-refresh |
{ echo "$BASHPID" >"$3/reader.pid"; while IFS= read -r _; do :; done; }
echo "applet status ${PIPESTATUS[1]}"' _ "$TEST_PATH" "$SU_BIN" "$FAKE_DIR"
assert_line 'applet status 141'
log_has 'apt-mark hold su-dummy-b'
log_has 'apt-mark unhold su-dummy-b'
run cat "$FAKE_DIR/holds"
assert_output ''
[[ ! -e $STATE/holds.added ]]
}
@test "a second signal while the exit handler restores the holds does not abort the restore" {
# 1st TERM during the upgrade starts the exit handler, a 2nd TERM arrives during its apt-mark unhold
FAKE_APT_UPGRADE_KILL=TERM FAKE_APT_MARK_UNHOLD_SIGNAL=TERM \
env PATH="$TEST_PATH" "$SU_BIN" --no-refresh <<<$'2-\n' >"$BATS_TEST_TMPDIR/out" 2>&1 &
pid=$!
echo "$pid" >"$FAKE_DIR/applet.pid"
rc=0
wait "$pid" || rc=$?
[[ $rc -eq 143 ]] || fail "exit status $rc: $(cat "$BATS_TEST_TMPDIR/out")"
log_has 'apt-mark unhold su-dummy-b'
run cat "$FAKE_DIR/holds"
assert_output ''
[[ ! -e $STATE/holds.added ]] || fail "the journal was left behind: $(cat "$STATE/holds.added")"
}
@test "the package manager does not inherit our lock descriptors" {
[[ -d /proc/self/fd ]] || skip "no /proc"
run skup --no-refresh <<<$'2-\n'
assert_success
log_has 'lock fds in the upgrade: 0'
}
@test "statefile writes merge: a row another process added meanwhile is kept" {
write_state "$HEADER" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
# while this session computes the closure of su-dummy-a, another process trusts zsh
FAKE_STATE_APPEND="apt${T}zsh${T}user${T}2026-02-02T00:00:00Z" run skup --no-refresh <<<$'1+\nq'
assert_success
run cut -f2,3 "$STATE_FILE"
assert_line "su-dummy-a${T}user"
assert_line "su-dummy-b${T}dep:su-dummy-a"
assert_line "su-dummy-c${T}user"
assert_line "zsh${T}user"
}
@test "statefile writes merge: a row another process removed meanwhile stays removed" {
write_state "$HEADER" "apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z"
FAKE_STATE_SED='/su-dummy-c/d' run skup trust su-dummy-a
assert_success
run cut -f2 "$STATE_FILE"
refute_line su-dummy-c
assert_line su-dummy-a
}
@test "statefile writes merge: a removal applies only to the row as this session saw it" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
"apt${T}su-dummy-b${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z" \
"apt${T}stale${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z"
# refresh drops "stale" (no longer in the closure); meanwhile another process made it a user row
FAKE_STATE_SED='s/^apt\tstale\tdep:su-dummy-a/apt\tstale\tuser/' run skup refresh
assert_success
assert_output 'refreshed the dependency closures of 1 trusted root: 0 added, 1 removed'
run cut -f2,3 "$STATE_FILE"
assert_line "stale${T}user"
assert_line "su-dummy-b${T}dep:su-dummy-a"
}
@test "the statefile lock is flock(1) on trusted.lock, released after the write" {
run skup trust su-dummy-c
assert_success
[[ -f $STATE/trusted.lock ]]
flock -n "$STATE/trusted.lock" true
}
@test "without flock(1) a mkdir lock is used and removed again" {
rm -f "$SYSBIN/flock"
run skup trust su-dummy-c
assert_success
[[ ! -e $STATE/trusted.lock ]]
[[ ! -e $STATE/trusted.lock.d ]]
run cut -f2 "$STATE_FILE"
assert_line su-dummy-c
}
@test "mkdir lock: a stale lock (dead owner) is taken over, a live one is respected" {
rm -f "$SYSBIN/flock"
mkdir -p "$STATE/holds.lock.d"
echo su-dummy-b >"$STATE/holds.added"
echo su-dummy-b >"$FAKE_DIR/holds"
# live owner: this test's own shell
echo "$$" >"$STATE/holds.lock.d/pid"
run skup list --no-refresh
assert_success
assert_output --partial 'another skeptical-updater run is using'
[[ -e $STATE/holds.added ]]
# dead owner
sleep 0 &
dead=$!
wait "$dead"
echo "$dead" >"$STATE/holds.lock.d/pid"
run skup list --no-refresh
assert_success
assert_line 'Released temporary holds left over by an interrupted run: su-dummy-b'
[[ ! -e $STATE/holds.added ]]
[[ ! -e $STATE/holds.lock.d ]]
}
@test "mkdir lock: an owner of another user (kill -0 fails with EPERM) counts as alive" {
nobody_env
chown 65534 "$W/state"
# the lock is held by this test's shell (root): nobody cannot signal it
mkdir "$W/state/trusted.lock.d"
echo "$$" >"$W/state/trusted.lock.d/pid"
chown -R 65534 "$W/state/trusted.lock.d"
SKEPTICAL_UPDATER_LOCK_WAIT=1 run nobody trust su-dummy-c
assert_failure 1
assert_output --partial "another skeptical-updater is writing $W/state/trusted.tsv"
[[ $(cat "$W/state/trusted.lock.d/pid") == "$$" ]]
[[ ! -e $W/state/trusted.tsv ]]
rm -rf "$W"
}
@test "mkdir lock: a stale lock nobody can delete is moved aside (taken over), without a busy loop" {
nobody_env
chown 65534 "$W/state"
sleep 0 &
dead=$!
wait "$dead"
# a dead owner, and the lock directory belongs to root: nobody cannot delete it, only rename it
mkdir "$W/state/trusted.lock.d"
echo "$dead" >"$W/state/trusted.lock.d/pid"
SECONDS=0
SKEPTICAL_UPDATER_LOCK_WAIT=2 run timeout 30 bash -c "$(declare -f nobody); W=$W FAKES=$FAKES SU_BIN=$SU_BIN; nobody trust su-dummy-c"
assert_success
assert_output 'trusted su-dummy-c'
((SECONDS < 20))
[[ ! -e $W/state/trusted.lock.d ]]
rm -rf "$W"
}
@test "mkdir lock: a live lock that never goes away ends in the timeout" {
rm -f "$SYSBIN/flock"
mkdir -p "$STATE/trusted.lock.d"
echo "$$" >"$STATE/trusted.lock.d/pid"
SECONDS=0
SKEPTICAL_UPDATER_LOCK_WAIT=2 run timeout 30 env PATH="$TEST_PATH" "$SU_BIN" trust su-dummy-c
assert_failure 1
assert_output --partial 'another skeptical-updater is writing'
((SECONDS < 20))
[[ $(cat "$STATE/trusted.lock.d/pid") == "$$" ]]
}
@test "mkdir lock: the lock directory appears with its pid, no temporary directory is left behind" {
rm -f "$SYSBIN/flock"
FAKE_STATE_APPEND='' run skup trust su-dummy-c
assert_success
run ls -A "$STATE"
assert_output 'trusted.tsv'
}
@test "mkdir lock: two takeovers of one stale lock - the late one finds the new live lock and leaves it" {
rm -f "$SYSBIN/flock"
mkdir -p "$STATE/holds.lock.d"
sleep 0 &
dead=$!
wait "$dead"
echo "$dead" >"$STATE/holds.lock.d/pid"
echo su-dummy-b >"$STATE/holds.added"
echo su-dummy-b >"$FAKE_DIR/holds"
# "C" sees the dead owner, then pauses before its takeover ...
SKEPTICAL_UPDATER_LOCK_WAIT=2 SKEPTICAL_UPDATER_LOCK_TAKEOVER_PAUSE=2 env PATH="$TEST_PATH" "$SU_BIN" --no-refresh <<<$'2-\n' >"$BATS_TEST_TMPDIR/c.out" 2>&1 &
c=$!
sleep 1
# ... meanwhile "B" took the stale lock over: the lock now belongs to a live process (this shell)
rm -rf "$STATE/holds.lock.d"
mkdir "$STATE/holds.lock.d"
echo "$$" >"$STATE/holds.lock.d/pid"
rc=0
wait "$c" || rc=$?
# C must not have removed B's live lock (it does not get it, and neither do its holds)
[[ $(cat "$STATE/holds.lock.d/pid") == "$$" ]] || fail "the live lock was taken: $(cat "$BATS_TEST_TMPDIR/c.out")"
run ls -A "$STATE"
refute_output --partial 'stale'
rm -rf "$STATE/holds.lock.d"
}
@test "mkdir lock: temporary and renamed lock directories of crashed acquirers are removed" {
rm -f "$SYSBIN/flock"
mkdir -p "$STATE/.trusted.lock.Crash1" "$STATE/.trusted.lock.Crash2" "$STATE/trusted.lock.d.stale.999999" "$STATE/.trusted.lock.Live01"
sleep 0 &
dead=$!
wait "$dead"
echo "$dead" >"$STATE/.trusted.lock.Crash1/pid"
touch -d '2 hours ago' "$STATE/.trusted.lock.Crash2"
echo "$dead" >"$STATE/trusted.lock.d.stale.999999/pid"
echo "$$" >"$STATE/.trusted.lock.Live01/pid"
run skup trust su-dummy-c
assert_success
run ls -A "$STATE"
assert_output "$(printf '%s\n' .trusted.lock.Live01 trusted.tsv)"
}
@test "a state directory that cannot be entered is an error, not 'no trusted packages'" {
((EUID == 0)) || skip "needs root to switch to an unprivileged user"
command -v setpriv >/dev/null 2>&1 || skip "setpriv not available"
w=$(mktemp -d /tmp/su-nobody.XXXXXX)
chmod 0755 "$w"
mkdir -p "$w/bin" "$w/fake" "$w/tmp" "$w/state"
chmod 0777 "$w/fake" "$w/tmp"
for t in "$SYSBIN"/*; do ln -s "$(readlink "$t")" "$w/bin/${t##*/}"; done
: >"$w/fake/log"
chmod 0666 "$w/fake/log"
printf '%s\n' "$HEADER" >"$w/state/trusted.tsv"
chmod 0000 "$w/state"
nobody() {
setpriv --reuid=65534 --regid=65534 --clear-groups -- env -u SKEPTICAL_UPDATER_EUID \
PATH="$FAKES/apt:$w/bin" FAKE_DIR="$w/fake" FAKE_LOG="$w/fake/log" TMPDIR="$w/tmp" \
SKEPTICAL_UPDATER_STATE_DIR="$w/state" "$SU_BIN" "$@"
}
run nobody trusted
assert_failure 1
assert_output --partial "cannot access the state directory $w/state (permission denied)"
run nobody list --no-refresh
assert_failure 1
assert_output --partial "cannot access the state directory $w/state (permission denied)"
# a readable directory with an unreadable statefile
chmod 0755 "$w/state"
chmod 0600 "$w/state/trusted.tsv"
run nobody trusted
assert_failure 1
assert_output --partial "cannot read the statefile $w/state/trusted.tsv (permission denied)"
rm -rf "$w"
}
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Root handling: what needs root, sudo re-exec with identical arguments, clear failures,
# and the commands that must work unprivileged.
load test_helper
setup() {
common_setup
export SKEPTICAL_UPDATER_EUID=1000
}
@test "the interactive upgrade as non-root without sudo fails clearly (exit 1)" {
run skup <<<''
assert_failure 1
assert_output --partial 'upgrading needs root and sudo is not available; run skeptical-updater as root (or use --dry-run)'
[[ -z $(cat "$FAKE_LOG") ]]
}
@test "list with a refresh needs root; without sudo it fails clearly" {
run skup list
assert_failure 1
assert_output --partial 'refreshing the package lists needs root (use --no-refresh)'
log_lacks 'update'
}
@test "list --no-refresh works unprivileged" {
run skup list --no-refresh
assert_success
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
}
@test "trusted, info and show work unprivileged" {
write_state "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-10-09T09:40:12Z"
run skup trusted
assert_success
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'
run skup show su-dummy-a
assert_success
assert_line 'Package: su-dummy-a'
}
@test "--dry-run works unprivileged: the refresh is printed, not run" {
run skup --dry-run <<<''
assert_success
assert_line 'Refreshing package lists... skipped (not root), the command would be:'
assert_line '+ apt-get update -qq -o APT::Update::Error-Mode=any'
assert_line '+ apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade'
log_lacks '^apt-get update'
log_lacks '^apt-get -y'
}
@test "with sudo available, non-root re-execs through sudo: this file itself, same arguments, hidden marker" {
use_fakes apt sudo
run skup --all list
assert_success
log_has "sudo -- $SU_BIN --sudo-reexec --state-dir=$STATE --all list"
log_lacks '^apt-get'
}
@test "the sudo re-exec carries --no-color when NO_COLOR is set" {
use_fakes apt sudo
unset SKEPTICAL_UPDATER_STATE_DIR
NO_COLOR=1 run skup --backend=apt
assert_success
log_has "sudo -- $SU_BIN --sudo-reexec --no-color --backend=apt"
}
@test "an explicit --state-dir is passed through unchanged" {
use_fakes apt sudo
run skup --state-dir=/srv/x list
assert_success
log_has "sudo -- $SU_BIN --sudo-reexec --state-dir=/srv/x list"
}
@test "a copy that is not executable and not in PATH is re-run through bash with its absolute path" {
use_fakes apt sudo
mkdir -p "$BATS_TEST_TMPDIR/copy"
cp "$SU_BIN" "$BATS_TEST_TMPDIR/copy/su-copy"
chmod 0644 "$BATS_TEST_TMPDIR/copy/su-copy"
cd "$BATS_TEST_TMPDIR/copy"
run env PATH="$TEST_PATH" bash su-copy list
assert_success
log_matches "^sudo -- /[^ ]*/bash $BATS_TEST_TMPDIR/copy/su-copy --sudo-reexec --state-dir=$STATE list\$"
}
@test "an executable copy invoked through bash is still re-run directly (sudoers rules match the file)" {
use_fakes apt sudo
mkdir -p "$BATS_TEST_TMPDIR/copy"
cp "$SU_BIN" "$BATS_TEST_TMPDIR/copy/su-copy"
chmod 0755 "$BATS_TEST_TMPDIR/copy/su-copy"
run env PATH="$TEST_PATH" bash "$BATS_TEST_TMPDIR/copy/su-copy" list
assert_success
log_has "sudo -- $BATS_TEST_TMPDIR/copy/su-copy --sudo-reexec --state-dir=$STATE list"
}
@test "after a sudo re-exec that did not make us root, there is no second re-exec" {
use_fakes apt sudo
run skup --sudo-reexec list
assert_failure 1
assert_output --partial 're-executed through sudo but still not running as root (euid 1000); not trying again'
log_lacks '^sudo'
}
@test "--sudo-reexec is only recognised as the first argument and is never forwarded" {
run skup list --sudo-reexec
assert_failure 1
assert_output --partial 'unknown option: --sudo-reexec'
SKEPTICAL_UPDATER_EUID=0 run skup --sudo-reexec list --no-refresh
assert_success
log_has 'apt-get -s --with-new-pkgs upgrade'
log_lacks 'sudo-reexec'
}
@test "trust into a writable state dir does not need root" {
use_fakes apt sudo
run skup trust su-dummy-c
assert_success
assert_output 'trusted su-dummy-c'
log_lacks '^sudo'
}
@test "trust into a non-writable state dir re-execs through sudo" {
ro=$BATS_TEST_DIRNAME/no-such-state-dir
[[ -w $BATS_TEST_DIRNAME ]] && skip "the repository is writable here (the suite mounts it read-only in Docker)"
use_fakes apt sudo
run skup --state-dir="$ro" trust su-dummy-c
assert_success
log_has "sudo -- $SU_BIN --sudo-reexec --state-dir=$ro trust su-dummy-c"
}
@test "--dry-run never re-execs through sudo" {
use_fakes apt sudo
run skup --dry-run list
assert_success
log_lacks '^sudo'
}
@test "as root nothing is re-executed" {
use_fakes apt sudo
SKEPTICAL_UPDATER_EUID=0 run skup list
assert_success
log_lacks '^sudo'
log_has 'apt-get update -qq -o APT::Update::Error-Mode=any'
}
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# The statefile: creation, format, sorting, dedupe, comments, atomic writes, trust/untrust/refresh.
load test_helper
setup() { common_setup; }
@test "trust creates the directory (0755) and the file (0644) with the header" {
run skup trust su-dummy-c
assert_success
assert_output 'trusted su-dummy-c'
[[ $(stat -c %a "$STATE") == 755 ]]
[[ $(stat -c %a "$STATE_FILE") == 644 ]]
run cat "$STATE_FILE"
assert_line --index 0 "$HEADER"
assert_line --index 1 --regexp "^apt${T}su-dummy-c${T}user${T}${DATE_RE}\$"
[[ ${#lines[@]} -eq 2 ]]
}
@test "intermediate directories are created too" {
run skup --state-dir="$BATS_TEST_TMPDIR/a/b/c" trust su-dummy-c
assert_success
[[ -f $BATS_TEST_TMPDIR/a/b/c/trusted.tsv ]]
[[ $(stat -c %a "$BATS_TEST_TMPDIR/a/b/c") == 755 ]]
}
@test "trust writes the user row and dep:<root> rows for the installed closure" {
run skup trust su-dummy-a
assert_success
assert_output 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
run cat "$STATE_FILE"
assert_line --index 1 --regexp "^apt${T}su-dummy-a${T}user${T}${DATE_RE}\$"
assert_line --index 2 --regexp "^apt${T}su-dummy-b${T}dep:su-dummy-a${T}${DATE_RE}\$"
log_has "dpkg-query -W -f \${db:Status-Abbrev}\\t\${Package}\\t\${Pre-Depends}\\t\${Depends}\\t\${Provides}\\n"
}
@test "the apt closure follows Provides (virtual packages) and alternatives" {
run skup trust su-dummy-w
assert_success
assert_output 'trusted su-dummy-w (+1 dependency: su-dummy-v)'
}
@test "the apt closure walks real chains through virtual packages (base-files -> awk -> mawk)" {
run skup trust base-files
assert_success
assert_output 'trusted base-files (+4 dependencies: gcc-12-base libc6 libgcc-s1 mawk)'
run cut -f2,3 "$STATE_FILE"
assert_line "mawk${T}dep:base-files"
assert_line "gcc-12-base${T}dep:base-files"
}
@test "a not-installed (rc) package is not part of a closure" {
run skup trust su-dummy-f
assert_success
assert_output 'trusted su-dummy-f'
}
@test "records are sorted on (backend, package); comments are kept in order" {
write_state '# my own note' \
"pacman${T}zsh${T}user${T}2026-01-01T00:00:00Z" \
"apt${T}zsh${T}user${T}2026-01-01T00:00:00Z" \
'# second note' \
"apt${T}foo-bar${T}user${T}2026-01-01T00:00:00Z" \
"apt${T}foo${T}dep:zsh${T}2026-01-01T00:00:00Z" \
"dnf${T}abc${T}user${T}2026-01-01T00:00:00Z"
run skup trust su-dummy-c
assert_success
run cat "$STATE_FILE"
assert_line --index 0 '# my own note'
assert_line --index 1 '# second note'
assert_line --index 2 "apt${T}foo${T}dep:zsh${T}2026-01-01T00:00:00Z"
assert_line --index 3 "apt${T}foo-bar${T}user${T}2026-01-01T00:00:00Z"
assert_line --index 4 --regexp "^apt${T}su-dummy-c${T}user${T}"
assert_line --index 5 "apt${T}zsh${T}user${T}2026-01-01T00:00:00Z"
assert_line --index 6 "dnf${T}abc${T}user${T}2026-01-01T00:00:00Z"
assert_line --index 7 "pacman${T}zsh${T}user${T}2026-01-01T00:00:00Z"
[[ ${#lines[@]} -eq 8 ]]
}
@test "duplicates are collapsed: a user row wins over a dep row, else the first one" {
write_state "$HEADER" \
"apt${T}b${T}dep:x${T}2026-01-01T00:00:00Z" \
"apt${T}b${T}user${T}2026-02-02T00:00:00Z" \
"apt${T}c${T}dep:x${T}2026-03-03T00:00:00Z" \
"apt${T}c${T}dep:y${T}2026-04-04T00:00:00Z"
run skup trust su-dummy-c
assert_success
run cat "$STATE_FILE"
assert_line "apt${T}b${T}user${T}2026-02-02T00:00:00Z"
assert_line "apt${T}c${T}dep:x${T}2026-03-03T00:00:00Z"
refute_line --partial 'dep:y'
refute_line --partial "b${T}dep:x"
}
@test "a user row is never downgraded to dep:" {
run skup trust su-dummy-b
run skup trust su-dummy-a
assert_success
assert_output 'trusted su-dummy-a (its installed dependency was already trusted)'
run cut -f2,3 "$STATE_FILE"
assert_line "su-dummy-b${T}user"
refute_line --partial 'dep:'
}
@test "an existing dep: row is upgraded to user, keeping one row" {
run skup trust su-dummy-a
run skup trust su-dummy-b
assert_success
assert_output 'trusted su-dummy-b'
run cut -f2,3 "$STATE_FILE"
assert_line "su-dummy-b${T}user"
[[ $(grep -c su-dummy-b "$STATE_FILE") -eq 1 ]]
}
@test "trusting an already trusted package reports it and changes nothing" {
run skup trust su-dummy-c
before=$(cat "$STATE_FILE")
run skup trust su-dummy-c
assert_success
assert_output 'su-dummy-c was already trusted'
[[ $(cat "$STATE_FILE") == "$before" ]]
}
@test "malformed lines are kept verbatim, with a warning" {
write_state "$HEADER" "apt${T}only-two" "apt${T}x${T}weird${T}2026-01-01T00:00:00Z"
run skup trust su-dummy-c
assert_success
assert_output --partial "trusted.tsv:2: malformed line kept verbatim: apt${T}only-two"
assert_output --partial "trusted.tsv:3: malformed line kept verbatim"
run cat "$STATE_FILE"
assert_line "apt${T}only-two"
assert_line "apt${T}x${T}weird${T}2026-01-01T00:00:00Z"
}
@test "blank lines and CRLF line ends are tolerated" {
printf '%s\r\n\r\n%s\r\n\n' "$HEADER" "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" >"$BATS_TEST_TMPDIR/st"
mkdir -p "$STATE"
mv "$BATS_TEST_TMPDIR/st" "$STATE_FILE"
run skup list --no-refresh
assert_line '3 upgradable, 1 trusted (hidden, use --all to show), 2 to review:'
}
@test "writes are atomic (new file renamed over the old one) and leave no temp file" {
run skup trust su-dummy-c
ino1=$(stat -c %i "$STATE_FILE")
run skup trust su-dummy-a
assert_success
ino2=$(stat -c %i "$STATE_FILE")
[[ $ino1 != "$ino2" ]]
run ls -A "$STATE"
assert_output "$(printf '%s\n' trusted.lock trusted.tsv)"
}
@test "a failing write leaves the old file intact" {
run skup trust su-dummy-c
before=$(cat "$STATE_FILE")
# make the state dir read-only (root ignores directory modes: skip then)
chmod 0555 "$STATE"
if [[ -w $STATE ]]; then
chmod 0755 "$STATE"
skip "running as root: directory modes are not enforced"
fi
run skup trust su-dummy-a
chmod 0755 "$STATE"
assert_failure 1
[[ $(cat "$STATE_FILE") == "$before" ]]
}
@test "untrust removes the row for PKG and every dep:PKG row, nothing else" {
write_state "$HEADER" \
"apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
"apt${T}su-dummy-b${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z" \
"apt${T}x${T}dep:y${T}2026-01-01T00:00:00Z" \
"apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z" \
"pacman${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
"pacman${T}su-dummy-b${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z"
run skup untrust su-dummy-a
assert_success
assert_output 'untrusted su-dummy-a (2 rows removed)'
run cat "$STATE_FILE"
assert_output "$(printf '%s\n' "$HEADER" \
"apt${T}su-dummy-c${T}user${T}2026-01-01T00:00:00Z" \
"apt${T}x${T}dep:y${T}2026-01-01T00:00:00Z" \
"pacman${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
"pacman${T}su-dummy-b${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z")"
}
@test "untrust of a dependency-only package removes its dep row" {
run skup trust su-dummy-a
run skup untrust su-dummy-b
assert_success
assert_output 'untrusted su-dummy-b (1 row removed)'
run cut -f2 "$STATE_FILE"
assert_line su-dummy-a
refute_line su-dummy-b
}
@test "untrust of a package that is not trusted warns, exit 0" {
run skup untrust nope
assert_success
assert_output --partial 'warning: nope is not trusted (no row for backend apt)'
[[ ! -e $STATE_FILE ]]
}
@test "refresh recomputes closures: adds missing rows, drops stale ones, keeps the rest" {
write_state "$HEADER" \
"apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z" \
"apt${T}su-dummy-b${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z" \
"apt${T}stale${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z" \
"apt${T}orphan${T}dep:gone${T}2026-01-01T00:00:00Z" \
"apt${T}su-dummy-w${T}user${T}2026-01-01T00:00:00Z" \
"pacman${T}stale${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z"
run skup refresh
assert_success
assert_output 'refreshed the dependency closures of 2 trusted roots: 1 added, 2 removed'
run cat "$STATE_FILE"
assert_line "apt${T}su-dummy-b${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z"
assert_line --regexp "^apt${T}su-dummy-v${T}dep:su-dummy-w${T}${DATE_RE}\$"
refute_line --partial "apt${T}stale"
refute_line --partial 'orphan'
assert_line "pacman${T}stale${T}dep:su-dummy-a${T}2026-01-01T00:00:00Z"
assert_line "apt${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
}
@test "refresh with no user roots for this backend does nothing" {
write_state "$HEADER" "pacman${T}x${T}user${T}2026-01-01T00:00:00Z"
run skup refresh
assert_success
assert_output 'No trusted root packages for backend apt: nothing to refresh.'
}
@test "trusted prints the statefile verbatim" {
write_state "$HEADER" '# note' "apt${T}x${T}user${T}2026-01-01T00:00:00Z"
run skup trusted
assert_success
assert_output "$(printf '%s\n' "$HEADER" '# note' "apt${T}x${T}user${T}2026-01-01T00:00:00Z")"
}
@test "trusted without a statefile says so, exit 0" {
run skup trusted
assert_success
assert_output "No trusted packages yet ($STATE_FILE does not exist)."
}
@test "only rows of the current backend hide packages" {
write_state "$HEADER" "pacman${T}su-dummy-a${T}user${T}2026-01-01T00:00:00Z"
run skup list --no-refresh
assert_line '3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:'
assert_line --regexp '^ \[1\] su-dummy-a '
}
@test "the reason does not matter for hiding: dep rows hide too" {
write_state "$HEADER" "apt${T}su-dummy-b${T}dep:whatever${T}2026-01-01T00:00:00Z"
run skup list --no-refresh
assert_line '3 upgradable, 1 trusted (hidden, use --all to show), 2 to review:'
refute_output --partial 'su-dummy-b'
}
+86
View File
@@ -0,0 +1,86 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# tests/unit/test_helper.bash - common setup for the unit tests (sourced via `load test_helper`).
# The applet runs with a hermetic PATH: fake package managers (tests/unit/fakes/<dir>) in front
# of a directory with symlinks to the few system tools it needs. No root, no network, no real
# package manager; everything is written under $BATS_TEST_TMPDIR.
# shellcheck shell=bash
load '../vendor/bats-support/load'
load '../vendor/bats-assert/load'
SU_BIN=$(cd "$BATS_TEST_DIRNAME/../.." && pwd)/skeptical-updater
FAKES=$BATS_TEST_DIRNAME/fakes
export FAKE_DATA=$BATS_TEST_DIRNAME/data
SYS_TOOLS=(bash env cat sort sed grep tr cut join awk mktemp mv mkdir chmod rm cp ln date ls head tail wc touch sleep kill flock readlink timeout stat sha256sum)
common_setup() {
SYSBIN=$BATS_TEST_TMPDIR/sysbin
mkdir -p "$SYSBIN"
local t p
for t in "${SYS_TOOLS[@]}"; do
p=$(type -P "$t") || continue
ln -sf "$p" "$SYSBIN/$t"
done
export FAKE_DIR=$BATS_TEST_TMPDIR/fake
mkdir -p "$FAKE_DIR"
: >"$FAKE_DIR/holds"
export FAKE_LOG=$FAKE_DIR/log
: >"$FAKE_LOG"
export STATE=$BATS_TEST_TMPDIR/state
export STATE_FILE=$STATE/trusted.tsv
export SKEPTICAL_UPDATER_STATE_DIR=$STATE
export SKEPTICAL_UPDATER_EUID=0
export SKEPTICAL_UPDATER_OS_RELEASE=$FAKE_DATA/os-release-debian12
export TMPDIR=$BATS_TEST_TMPDIR
export FAKE_PACMAN_DBPATH=$FAKE_DIR/pacman-db
mkdir -p "$FAKE_PACMAN_DBPATH/sync" "$FAKE_PACMAN_DBPATH/local"
unset NO_COLOR ZYPP_CONF COLUMNS SKEPTICAL_UPDATER_LOCK_WAIT
use_fakes apt
}
# use_fakes DIR...: the applet sees only these fake dirs + the system tools
use_fakes() {
local d p=
for d in "$@"; do p+=$FAKES/$d:; done
TEST_PATH=$p$SYSBIN
}
# skup ARGS...: run the applet (use as `run skup ...`, stdin is passed through)
skup() { env PATH="$TEST_PATH" "$SU_BIN" "$@"; }
# shellcheck disable=SC2034 # used by the .bats files
HEADER='# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)'
# shellcheck disable=SC2034
DATE_RE='[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z'
# shellcheck disable=SC2034
T=$'\t'
# write_state LINE...: create the statefile with exactly these lines
write_state() {
mkdir -p "$STATE"
printf '%s\n' "$@" >"$STATE_FILE"
}
# log_has LINE: the fake log contains exactly this line
log_has() {
grep -qxF -- "$1" "$FAKE_LOG" || {
printf 'expected log line: %s\n--- fake log:\n%s\n' "$1" "$(cat "$FAKE_LOG")" >&2
return 1
}
}
# log_matches REGEX: some fake log line matches
log_matches() {
grep -qE -- "$1" "$FAKE_LOG" || {
printf 'expected a log line matching: %s\n--- fake log:\n%s\n' "$1" "$(cat "$FAKE_LOG")" >&2
return 1
}
}
# log_lacks REGEX: no fake log line matches
log_lacks() {
if grep -qE -- "$1" "$FAKE_LOG"; then
printf 'unexpected log line matching: %s\n--- fake log:\n%s\n' "$1" "$(cat "$FAKE_LOG")" >&2
return 1
fi
}