first commit
This commit is contained in:
commit
1354db6c39
90 files changed
+13123
No files matched your search
+10
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+217
@@ -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"))"
|
||||
@@ -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
|
||||
}
|
||||
Executable
+110
@@ -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; }
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 -- "$@"; }
|
||||
@@ -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 'openSUSE'.`) | `-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//</<}; v=${v//>/>}; v=${v//"/\"}; v=${v//'/\'}; v=${v//&/&}; 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`.
|
||||
Executable
+158
@@ -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)"
|
||||
Executable
+396
@@ -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//</<}; v=${v//>/>}; v=${v//"/\"}; v=${v//'/\'}; v=${v//&/&}
|
||||
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
|
||||
}
|
||||
Executable
+175
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
Executable
+2371
File diff suppressed because it is too large.
Load diff
Executable
+381
@@ -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"
|
||||
@@ -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
|
||||
Executable
+45
@@ -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"
|
||||
Executable
+94
@@ -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"/}"
|
||||
Executable
+57
@@ -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"
|
||||
Executable
+251
@@ -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"))"
|
||||
Executable
+222
@@ -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
|
||||
Executable
+217
@@ -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)"
|
||||
@@ -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=$?
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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 ]]
|
||||
}
|
||||
@@ -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['
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
su-dummy-orphan
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
su-dummy-b: su-dummy-a
|
||||
@@ -0,0 +1,3 @@
|
||||
gpgv
|
||||
Reverse Depends:
|
||||
|apt
|
||||
@@ -0,0 +1,4 @@
|
||||
su-dummy-b
|
||||
Reverse Depends:
|
||||
su-dummy-a
|
||||
su-dummy-a
|
||||
@@ -0,0 +1,5 @@
|
||||
Package: gpgv
|
||||
Version: 2.2.40-1.1
|
||||
Architecture: amd64
|
||||
Description: GNU privacy guard - signature verification tool
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
@@ -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])
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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)"
|
||||
@@ -0,0 +1,4 @@
|
||||
NAME="Arch Linux"
|
||||
PRETTY_NAME="Arch Linux"
|
||||
ID=arch
|
||||
BUILD_ID=rolling
|
||||
@@ -0,0 +1,4 @@
|
||||
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||
NAME="Debian GNU/Linux"
|
||||
VERSION_ID="12"
|
||||
ID=debian
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
NAME="openSUSE Tumbleweed"
|
||||
ID="opensuse-tumbleweed"
|
||||
ID_LIKE="opensuse suse"
|
||||
VERSION_ID="20261007"
|
||||
PRETTY_NAME="openSUSE Tumbleweed"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version='1.0'?>
|
||||
<stream>
|
||||
<message type="info">Refreshing service 'openSUSE'.</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 & 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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Executable
+42
@@ -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
|
||||
Executable
+128
@@ -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
|
||||
Executable
+67
@@ -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
|
||||
Executable
+19
@@ -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
|
||||
Executable
+134
@@ -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
|
||||
@@ -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"; }
|
||||
Executable
+153
@@ -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
|
||||
Executable
+9
@@ -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
|
||||
Executable
+6
@@ -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
|
||||
Executable
+81
@@ -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"
|
||||
Executable
+9
@@ -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
|
||||
Executable
+6
@@ -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" "$@"
|
||||
Executable
+105
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user