diff --git a/CHANGELOG.md b/CHANGELOG.md
index 331f74258..8e0ab5600 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,8 @@
* Add options to send Webmin and Usermin errors to the systemd journal [forum.virtualmin.com/t/136562](https://forum.virtualmin.com/t/miniserv-webserver-log-growing-too-big-should-be-rotated/136562)
* Add webserver logging controls to Usermin Configuration module
* Add option to rotate Webmin and Usermin webserver logs using `logrotate` instead of periodically clearing them [#2821](https://github.com/webmin/webmin/pull/2821)
+* Add DNF 4 and 5 package hold management to the Software Package Updates module
+* Fix DNF update confirmations by previewing packages and dependencies that will be installed or updated
* Fix PostgreSQL initialization on EL systems to use SCRAM-SHA-256 authentication by default
#### 2.660 (August 20, 2026)
diff --git a/package-updates/CHANGELOG b/package-updates/CHANGELOG
index d67c3c1c7..9f6845d98 100644
--- a/package-updates/CHANGELOG
+++ b/package-updates/CHANGELOG
@@ -1,4 +1,5 @@
---- Changes since 2.660 ----
+Added support for managing DNF 4 and 5 version locks when the versionlock command is installed.
Added a "New updates are found" choice to the "Send email when" option that sends a complete report only when new updates become available, so notify-only schedules no longer repeat the same message every run.
---- Changes since 2.641 ----
Added a Held updates view with controls to hold, unhold or explicitly update APT-held packages.
diff --git a/package-updates/lang/en b/package-updates/lang/en
index a8009cd81..54a83fe30 100644
--- a/package-updates/lang/en
+++ b/package-updates/lang/en
@@ -107,7 +107,7 @@ update_newver=New version
update_confirm=Install Now
update_confirmheld=Update Held Packages
update_heldnote=These packages are held. This action explicitly updates them once, and leaves them held for future updates.
-update_enotheld=Package $1 is not currently held by APT
+update_enotheld=Package $1 is not currently held
update_enoheldops=No update operation was found for the selected held packages. Refresh the package list and try again.
update_none=None
update_ops=Building complete list of packages ..
diff --git a/package-updates/package-updates-lib.pl b/package-updates/package-updates-lib.pl
index 4953bf16c..cb6356d8a 100644
--- a/package-updates/package-updates-lib.pl
+++ b/package-updates/package-updates-lib.pl
@@ -314,8 +314,12 @@ return defined(&software::update_system_updates);
# Returns true if the current update system can list and change package holds.
sub supports_package_holds
{
-return defined(&software::list_update_system_holds) &&
- defined(&software::update_system_hold);
+return 0 if (!defined(&software::list_update_system_holds) ||
+ !defined(&software::update_system_hold) ||
+ !defined(&software::update_system_hold_flags));
+return &software::supports_update_system_holds()
+ if (defined(&software::supports_update_system_holds));
+return 1;
}
# list_package_holds()
@@ -389,8 +393,9 @@ my ($name, $system, $install, $flags) = @_;
$system ||= $software::update_system;
my @rv;
my $pkg;
-my $include_held = $system eq 'apt' && defined($flags) &&
- $flags eq '--allow-change-held-packages';
+my $include_held = $system eq $software::update_system &&
+ &supports_package_holds() && defined($flags) &&
+ $flags eq &software::update_system_hold_flags();
# First get from list of updates
($pkg) = grep { $_->{'update'} eq $name &&
@@ -490,14 +495,13 @@ unlink($current_cache_file);
return @rv;
}
-# list_package_operations(package|packages, system)
-# Given a package (or space-separate package list), returns a list of all
-# dependencies that will be installed
+# list_package_operations(package|packages, system, [flags])
+# Returns packages and dependencies that would be installed or updated.
sub list_package_operations
{
-my ($name, $system) = @_;
+my ($name, $system, $flags) = @_;
if (defined(&software::update_system_operations)) {
- my @rv = &software::update_system_operations($name);
+ my @rv = &software::update_system_operations($name, $flags);
foreach my $p (@rv) {
$p->{'system'} = $system;
}
diff --git a/package-updates/update.cgi b/package-updates/update.cgi
index 4a2b88602..9d871700f 100755
--- a/package-updates/update.cgi
+++ b/package-updates/update.cgi
@@ -73,18 +73,18 @@ else {
@pkgs || &error($text{'update_enone'});
$allow_held = 0;
if ($in{'mode'} eq 'held') {
- # The held-updates page is the only UI that can explicitly
- # override an APT hold for a single update transaction.
+ # Only Held updates can override a hold for one transaction.
&supports_package_holds() || &error($text{'hold_enotsupported'});
@held = &list_package_holds();
foreach $ps (@pkgs) {
($p, $s) = split(/\//, $ps, 2);
- $s eq 'apt' && &package_is_held($p, \@held) ||
+ $s eq $software::update_system &&
+ &package_is_held($p, \@held) ||
&error(&text('update_enotheld', $p));
}
$allow_held = 1;
}
- $install_flags = $allow_held ? '--allow-change-held-packages' :
+ $install_flags = $allow_held ? &software::update_system_hold_flags() :
$in{'flags'};
&ui_print_unbuffered_header(undef,
$in{'mode'} eq 'new' ? $text{'update_title2'} : $text{'update_title'}, "");
@@ -102,7 +102,8 @@ else {
($p, $s) = split(/\//, $ps);
push(@pkgnames, $p);
}
- @ops = &list_package_operations(join(" ", @pkgnames), $s);
+ @ops = &list_package_operations(join(" ", @pkgnames), $s,
+ $install_flags);
&error($text{'update_enoheldops'}) if (!@ops && $allow_held);
}
diff --git a/software/CHANGELOG b/software/CHANGELOG
index 82ef37064..23095748d 100644
--- a/software/CHANGELOG
+++ b/software/CHANGELOG
@@ -1,6 +1,7 @@
---- Changes since 2.641 ----
Fix Alpine Linux mysql/mariadb package installs names due missing server utils (means at least Alpine Linux package installation is supported since Alpine linux v 3.16 up to edge)
Added APT functions for listing, holding, unholding and explicitly updating held packages.
+Added DNF 4 and 5 functions for listing, locking, unlocking and explicitly updating version-locked packages.
---- Changes since 1.130 ----
Packages can now be installed directly from yum, if installed.
The entire system can also be upgraded from yum.
diff --git a/software/apt-lib.pl b/software/apt-lib.pl
index ccf1ac286..37b4bba8a 100755
--- a/software/apt-lib.pl
+++ b/software/apt-lib.pl
@@ -20,6 +20,13 @@ $name =~ s/:[A-Za-z0-9][A-Za-z0-9._-]*$//;
return $name;
}
+# update_system_hold_flags()
+# Returns the APT option for explicitly updating held packages.
+sub update_system_hold_flags
+{
+return '--allow-change-held-packages';
+}
+
# update_system_install([package], [&in], [no-force], [flags])
# Install some package with apt
sub update_system_install
@@ -105,15 +112,17 @@ $? = $status;
return @rv;
}
-# update_system_operations(packages)
-# Given a list of packages, returns a list containing packages that will
-# actually get installed, each of which is a hash ref with name and version.
+# update_system_operations(packages, [flags])
+# Returns packages APT would install or update. The optional hold flag includes
+# held packages in the simulation.
sub update_system_operations
{
-my ($packages) = @_;
+my ($packages, $flags) = @_;
$ENV{'UCF_FORCE_CONFFOLD'} = 'YES';
$ENV{'DEBIAN_FRONTEND'} = 'noninteractive';
-my $cmd = "apt-get -s install ".
+my $holdflag = defined($flags) &&
+ $flags eq &update_system_hold_flags() ? " $flags" : "";
+my $cmd = "apt-get -s$holdflag install ".
join(" ", map { quotemeta($_) } split(/\s+/, $packages)).
" &1";
&clean_language();
@@ -261,8 +270,8 @@ close(DUMP);
return @rv;
}
-# update_system_updates()
-# Returns a list of available package updates
+# update_system_updates([include-holds])
+# Returns available package updates, optionally including held packages.
sub update_system_updates
{
my ($include_holds) = @_;
diff --git a/software/lang/en b/software/lang/en
index aa84dbace..d69df21ce 100644
--- a/software/lang/en
+++ b/software/lang/en
@@ -266,6 +266,11 @@ yum_input=Package from YUM
yum_install=Installing package(s) with command $1 ..
yum_ok=.. install complete
yum_failed=.. install failed!
+yum_unholdfailed=.. failed to temporarily remove the version lock on $1 : $2
+yum_reholdfailed=.. failed to restore the version lock on $1 : $2
+yum_versionlock_missing=The DNF versionlock command is not installed
+yum_versionlock_none=No packages were specified
+yum_versionlock_failed=dnf versionlock $1 failed
yum_find=Browse YUM ..
yum_package=Package
yum_version=Version
diff --git a/software/yum-lib.pl b/software/yum-lib.pl
index 42722eab3..68328cec7 100755
--- a/software/yum-lib.pl
+++ b/software/yum-lib.pl
@@ -19,17 +19,61 @@ sub list_update_system_commands
return ($yum_command);
}
+# get_dnf_version()
+# Returns the DNF major version, or zero when using YUM.
+sub get_dnf_version
+{
+return 0 if ($yum_command !~ /(?:^|\/)dnf(?:-\d+)?$/);
+if (!defined($dnf_version)) {
+ &clean_language();
+ my $out = &backquote_command(
+ "$yum_command --version 2>&1 &1 = 5 ? '--setopt=disable_excludes=*' :
+ '--disableplugin=versionlock';
+}
+
# update_system_install([packages], [&in], [no-force], [flags])
-# Install some package with yum
+# Installs or updates packages with YUM or DNF.
sub update_system_install
{
local $update = $_[0] || $in{'update'};
local $in = $_[1];
local $force = !$_[2];
local $flags = $_[3];
+local $versionlock_update = defined($flags) &&
+ $flags eq &update_system_hold_flags();
+local $runflags = $versionlock_update ? undef : $flags;
local $qflags;
-$qflags = &trim(join(" ", map { quotemeta($_) } split(/ /, $flags)))
- if ($flags);
+$qflags = &trim(join(" ", map { quotemeta($_) } split(/ /, $runflags)))
+ if ($runflags);
$update =~ s/\.\*/\*/g;
local $enable;
if ($in->{'enablerepo'}) {
@@ -45,7 +89,28 @@ if (@names == 1) {
}
$update = join(" ", map { quotemeta($_) } @names);
-# Work out command to use - for DNF, upgrades need to use the update command
+# Temporarily unlock selected packages, then restore their holds after the
+# transaction.
+local @relock;
+if ($versionlock_update) {
+ my @packages = &unique(@updates);
+ my $unlock_error = &delete_update_system_holds(
+ \@packages, \@relock);
+ if ($unlock_error) {
+ my $relock_error = &restore_update_system_holds(\@relock);
+ if ($relock_error) {
+ print &text('yum_reholdfailed',
+ "".&html_escape(join(" ", @packages))."",
+ &html_escape($relock_error)),"
\n";
+ }
+ print &text('yum_unholdfailed',
+ "".&html_escape(join(" ", @packages))."",
+ &html_escape($unlock_error)),"
\n";
+ return ( );
+ }
+ }
+
+# Use update for installed DNF packages and install for everything else.
local $cmd;
if ($yum_command =~ /dnf$/) {
local @pinfo = &package_info($updates[0]);
@@ -62,9 +127,9 @@ else {
# Work out the command to run, which may enable some repos
my $uicmd = "$yum_command $enable -y $cmd ".join(" ", @names);
-$uicmd .= " $flags" if ($flags);
+$uicmd .= " $runflags" if ($runflags);
my $fullcmd = "$yum_command $enable -y $cmd $update";
-$fullcmd .= " $qflags" if ($flags);
+$fullcmd .= " $qflags" if ($qflags);
foreach my $u (@updates) {
my $repo = &update_system_repo($u);
if ($repo) {
@@ -110,7 +175,7 @@ while() {
}
}
elsif (/^\s+(Updating|Installing|Upgrading)\s+:\s+(\S+)/) {
- # Line like :
+ # Older DNF and YUM progress lines, for example:
# Updating : wbt-virtual-server-theme 1/2
# or
# Installing : 2:nmap-5.51-2.el6.i686 1/1
@@ -119,12 +184,11 @@ while() {
$pkg =~ s/\-\d.*$//; # Strip version number from end
push(@rv, $pkg);
}
- elsif (/\]\s+(Upgrading|Installing)\s+(\S+)/) {
- # Line like :
+ elsif (/\]\s+(Upgrading|Installing|Downgrading|Reinstalling)\s+(\S+)/) {
+ # DNF 5 progress line, for example:
# [3/8] Upgrading libcurl-0:8.11.1-5.fc42 100% ...
- local $pkg = $2;
- $pkg =~ s/:\d.*$//; # Strip version number from end
- push(@rv, $pkg);
+ local $pkg = &update_system_nevra_name($2);
+ push(@rv, $pkg) if ($pkg);
}
if (!/ETA/ && !/\%\s+done\s+\d+\/\d+\s*$/) {
print &html_escape($_."\n");
@@ -134,8 +198,19 @@ while() {
}
}
close(CMD);
+local $status = $?;
+
+# Restore holds at the versions now installed.
+if (@relock) {
+ local $relock_error = &restore_update_system_holds(\@relock);
+ if ($relock_error) {
+ print &text('yum_reholdfailed',
+ "".&html_escape(join(" ", &unique(@updates)))."",
+ &html_escape($relock_error)),"\n";
+ }
+ }
print "\n";
-if ($? || $nopackage) {
+if ($status || $nopackage) {
print "$text{'yum_failed'}
\n";
return ( );
}
@@ -167,12 +242,26 @@ for(my $i=0; $i<$n; $i++) {
return @rv;
}
-# update_system_operations(packages)
-# Given a list of packages, returns a list containing packages that will
-# actually get installed, each of which is a hash ref with name and version.
+# update_system_nevra_name(nevra)
+# Extracts the package name from a NEVRA string printed by DNF.
+sub update_system_nevra_name
+{
+my ($nevra) = @_;
+$nevra =~ s/^\d+://; # Older DNF may put the epoch before the name
+return $1 if ($nevra =~ /^(.+)-\d+:/);
+return $1 if ($nevra =~ /^(.+)-\d[^-]*-[^-]+(?:\.[^.]+)?$/);
+return undef;
+}
+
+# update_system_operations(packages, [flags])
+# Returns packages YUM or DNF would install or update. DNF previews the
+# transaction directly; YUM uses shell mode.
sub update_system_operations
{
-my ($packages) = @_;
+my ($packages, $flags) = @_;
+if ($yum_command =~ /(?:^|\/)dnf(?:-\d+)?$/) {
+ return &update_system_dnf_operations($packages, $flags);
+ }
my $temp = &transname();
&open_tempfile(SHELL, ">$temp", 0, 1);
&print_tempfile(SHELL, "install $packages\n");
@@ -198,6 +287,87 @@ close(SHELL);
return @rv;
}
+# update_system_dnf_operations(packages, [flags])
+# Returns packages DNF would install or update in a simulated transaction.
+sub update_system_dnf_operations
+{
+my ($packages, $flags) = @_;
+my @rv;
+my $hold_override = defined($flags) &&
+ $flags eq &update_system_hold_flags();
+my $runflags = defined($flags) && !$hold_override ?
+ &trim(join(" ", map { quotemeta($_) } split(/\s+/, $flags))) : "";
+my @relock;
+if ($hold_override) {
+ my @selected = &unique(split(/\s+/, $packages));
+ my $error = &delete_update_system_holds(\@selected, \@relock);
+ if ($error) {
+ &restore_update_system_holds(\@relock) if (@relock);
+ return ( );
+ }
+ }
+# DNF 5 does not upgrade installed packages with install, so use upgrade for
+# its previews.
+my $action = &get_dnf_version() >= 5 ? 'upgrade' : 'install';
+my $command = "$yum_command --assumeno $action ".
+ join(" ", map { quotemeta($_) } split(/\s+/, $packages));
+$command .= " $runflags" if ($runflags);
+$command .= " 2>/dev/null";
+&clean_language();
+&open_execute_command(DNF, $command, 1, 1);
+my ($intable, $skip, $wrapped);
+while() {
+ s/\r|\n//g;
+ if (/^\s*Package\s+Arch(itecture)?\s+Version\s+Repo/i) {
+ # Start reading the transaction table.
+ $intable = 1;
+ }
+ elsif (/^\s*Transaction\s+Summary/i) {
+ last;
+ }
+ elsif (!$intable || /^=+$/) {
+ next;
+ }
+ elsif (/^\S/) {
+ # Ignore sections that do not add package versions.
+ $skip = !/^(Installing|Upgrading|Reinstalling|Downgrading)/i;
+ $wrapped = undef;
+ }
+ elsif ($skip) {
+ next;
+ }
+ elsif (/^\s+(\S+)\s*$/) {
+ # Save a long package name wrapped onto its own line.
+ $wrapped = $1;
+ }
+ elsif (/^\s+replacing\s/i) {
+ # Ignore an old package shown below its replacement.
+ $wrapped = undef;
+ }
+ elsif (/^\s+\S/) {
+ # Parse a complete row, or the remainder of a wrapped row.
+ my @cols = split(/\s+/, &trim($_));
+ unshift(@cols, $wrapped) if ($wrapped);
+ $wrapped = undef;
+ next if (@cols < 4);
+ my $pkg = { 'name' => $cols[0],
+ 'arch' => $cols[1],
+ 'version' => $cols[2] };
+ if ($pkg->{'version'} =~ s/^(\S+)://) {
+ $pkg->{'epoch'} = $1;
+ }
+ push(@rv, $pkg);
+ }
+ }
+close(DNF);
+&reset_environment();
+if (@relock) {
+ my $error = &restore_update_system_holds(\@relock);
+ return ( ) if ($error);
+ }
+return @rv;
+}
+
# show_update_system_opts()
# Returns HTML for enabling a repository, if any are disabled
sub show_update_system_opts
@@ -333,14 +503,19 @@ while() {
close(PKG);
}
-# update_system_updates()
-# Returns a list of package updates available from yum
+# update_system_updates([include-holds])
+# Returns available package updates, optionally including version-locked ones.
sub update_system_updates
{
+my ($include_holds) = @_;
local @rv;
local %done;
if ($yum_command =~ /dnf/) {
- &open_execute_command(PKG, "$yum_command check-update 2>/dev/null", 1, 1);
+ my $holdflag = $include_holds && &supports_update_system_holds() ?
+ " ".&update_system_hold_flags() : "";
+ $holdflag =~ s/\*/\\*/g;
+ &open_execute_command(PKG,
+ "$yum_command$holdflag check-update 2>/dev/null", 1, 1);
}
else {
&open_execute_command(PKG, "$yum_command check-update 2>/dev/null | tr '\n' '#' | sed -e 's/# / /g' | tr '#' '\n'", 1, 1);
@@ -361,10 +536,157 @@ while() {
last if (/Obsoleting\s+Packages/i);
}
close(PKG);
+if (&supports_update_system_holds()) {
+ my %holds = map { $_, 1 } &list_update_system_holds();
+ foreach my $pkg (@rv) {
+ $pkg->{'held'} = 1 if ($holds{$pkg->{'name'}});
+ }
+ @rv = grep { !$_->{'held'} } @rv if (!$include_holds);
+ }
&set_yum_security_field(\%done);
return @rv;
}
+# update_system_hold_spec_name(spec)
+# Extracts the package name from an exact DNF 4 versionlock entry.
+sub update_system_hold_spec_name
+{
+my ($spec) = @_;
+return undef if ($spec =~ /^!/);
+return $1 if ($spec =~ /^(.+)-\d+:[^-]+-[^-]+\.[^.]+$/);
+return $1 if ($spec =~ /^\d+:(.+)-[^-]+-[^-]+\.[^.]+$/);
+return undef;
+}
+
+# list_update_system_hold_specs()
+# Returns exact locks that Webmin can safely manage by package name.
+sub list_update_system_hold_specs
+{
+return ( ) if (!&supports_update_system_holds());
+my @locks;
+&clean_language();
+&open_execute_command(VLOCK,
+ "$yum_command -q versionlock list 2>/dev/null", 1, 1);
+if (&get_dnf_version() >= 5) {
+ # DNF 5 formats each lock as a multi-line package block.
+ my ($lock, @blocks);
+ while() {
+ s/\r|\n//g;
+ if (/^Package name:\s*(\S+)/) {
+ push(@blocks, $lock) if ($lock);
+ $lock = { 'name' => $1, 'spec' => $1 };
+ }
+ elsif ($lock && /^evr\s*=\s*(\S+)/) {
+ $lock->{'exact'} = 1;
+ }
+ elsif ($lock && /\S/ && !/^\s*#/) {
+ $lock->{'custom'} = 1;
+ }
+ elsif (!/\S/) {
+ push(@blocks, $lock) if ($lock);
+ $lock = undef;
+ }
+ }
+ push(@blocks, $lock) if ($lock);
+
+ # Ignore globs, duplicate entries and custom conditions because deleting
+ # them by name could remove rules that Webmin cannot restore.
+ my (%blocks, %custom);
+ foreach my $block (@blocks) {
+ $blocks{$block->{'name'}}++;
+ $custom{$block->{'name'}} = 1
+ if (!$block->{'exact'} || $block->{'custom'});
+ }
+ @locks = grep { !$custom{$_->{'name'}} &&
+ $blocks{$_->{'name'}} == 1 &&
+ $_->{'name'} !~ /[\*\?\[\]]/ } @blocks;
+ }
+else {
+ # DNF 4 prints standard entries as name-epoch:version-release.arch.
+ while() {
+ s/\r|\n//g;
+ my $name = &update_system_hold_spec_name($_);
+ push(@locks, { 'name' => $name, 'spec' => $_ }) if ($name);
+ }
+ }
+close(VLOCK);
+&reset_environment();
+return @locks;
+}
+
+# list_update_system_holds()
+# Returns package names held by exact DNF version locks.
+sub list_update_system_holds
+{
+my %holds = map { $_->{'name'}, 1 } &list_update_system_hold_specs();
+return sort keys %holds;
+}
+
+# run_update_system_hold(action, package)
+# Runs one versionlock operation. Returns undef on success, or error text.
+sub run_update_system_hold
+{
+my ($action, $package) = @_;
+my $cmd = "$yum_command -q versionlock $action ".quotemeta($package);
+my $out;
+&clean_language();
+my $status = &execute_command_logged($cmd, undef, \$out, \$out);
+&reset_environment();
+if ($status) {
+ $out = &trim($out);
+ return $out || &text('yum_versionlock_failed', $action);
+ }
+return undef;
+}
+
+# delete_update_system_holds(&packages, &removed)
+# Unlocks selected packages and records each removed hold for restoration.
+sub delete_update_system_holds
+{
+my ($packages, $removed) = @_;
+my %held = map { $_, 1 } &list_update_system_holds();
+my $error;
+foreach my $package (&unique(@$packages)) {
+ next if (!$held{$package});
+ $error = &run_update_system_hold('delete', $package);
+ last if ($error);
+ push(@$removed, $package);
+ }
+return $error;
+}
+
+# restore_update_system_holds(&packages)
+# Re-locks packages at their currently installed versions.
+sub restore_update_system_holds
+{
+my ($packages) = @_;
+foreach my $package (&unique(@$packages)) {
+ my $error = &run_update_system_hold('add', $package);
+ return $error if ($error);
+ }
+return undef;
+}
+
+# update_system_hold(&packages, hold)
+# Holds or unholds DNF packages by name. Returns undef on success, or error text.
+sub update_system_hold
+{
+my ($packages, $hold) = @_;
+return $text{'yum_versionlock_missing'}
+ if (!&supports_update_system_holds());
+my @packages = &unique(@$packages);
+return $text{'yum_versionlock_none'} if (!@packages);
+if ($hold) {
+ foreach my $package (@packages) {
+ my $error = &run_update_system_hold('add', $package);
+ return $error if ($error);
+ }
+ return undef;
+ }
+my @removed;
+return &delete_update_system_holds(\@packages, \@removed);
+}
+
# get_yum_config()
# Returns entries from the YUM config file, as a list of hash references
sub get_yum_config
@@ -543,4 +865,3 @@ else {
}
1;
-
diff --git a/t/software-apt.t b/t/software-apt.t
index 3e05ec256..5e2c1e317 100644
--- a/t/software-apt.t
+++ b/t/software-apt.t
@@ -24,7 +24,9 @@ is(strip_apt_package_arch('ncurses-base'), 'ncurses-base',
{
no warnings qw(once redefine);
+my $command;
local *backquote_command = sub {
+ $command = $_[0];
return "Inst libtinfo6:amd64 [6.3-2ubuntu0.1] ".
"(6.3-2ubuntu0.2 Ubuntu:22.04/jammy-updates [amd64])\n";
};
@@ -34,6 +36,9 @@ local *reset_environment = sub { };
my @ops = update_system_operations('libtinfo6');
is($ops[0]->{'name'}, 'libtinfo6',
'normalizes package names from simulated APT operations');
+update_system_operations('libtinfo6', update_system_hold_flags());
+like($command, qr/^apt-get -s --allow-change-held-packages install /,
+ 'overrides holds while resolving an explicit held-package update');
}
{
diff --git a/t/software-yum.t b/t/software-yum.t
new file mode 100644
index 000000000..f64bbb2fa
--- /dev/null
+++ b/t/software-yum.t
@@ -0,0 +1,347 @@
+#!/usr/local/bin/perl
+
+use strict;
+use warnings;
+use Test::More;
+use File::Basename qw(dirname);
+use File::Spec;
+use Cwd qw(abs_path);
+
+our (%config, %packages, %text);
+our ($yum_command, $supports_dnf_versionlock, $dnf_version);
+
+sub has_command
+{
+return $_[0] eq 'dnf' ? '/usr/bin/dnf' : undef;
+}
+
+my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..'));
+chdir($root) or die "chdir($root): $!";
+
+do './software/yum-lib.pl' or die $@ || $!;
+
+{
+no warnings qw(once redefine);
+local *clean_language = sub { };
+local *reset_environment = sub { };
+local *backquote_command = sub {
+ return $_[0] =~ /--version/ ? "4.14.0\n" :
+ " versionlock control package version locks\n";
+ };
+local $dnf_version;
+local $supports_dnf_versionlock;
+is(get_dnf_version(), 4, 'detects DNF 4');
+ok(supports_update_system_holds(),
+ 'detects the DNF 4 versionlock command');
+is(update_system_hold_flags(), '--disableplugin=versionlock',
+ 'uses the DNF 4 held-update discovery option');
+}
+
+{
+no warnings qw(once redefine);
+local *clean_language = sub { };
+local *reset_environment = sub { };
+local *backquote_command = sub {
+ return $_[0] =~ /--version/ ? "dnf5 version 5.4.2.1\n" :
+ " versionlock Manage versionlock configuration\n";
+ };
+local $dnf_version;
+local $supports_dnf_versionlock;
+is(get_dnf_version(), 5, 'detects DNF 5');
+ok(supports_update_system_holds(),
+ 'detects the DNF 5 versionlock command');
+is(update_system_hold_flags(), '--setopt=disable_excludes=*',
+ 'uses the DNF 5 held-update discovery option');
+}
+
+{
+no warnings qw(once redefine);
+local *clean_language = sub { };
+local *reset_environment = sub { };
+local *backquote_command = sub {
+ return $_[0] =~ /--version/ ? "4.14.0\n" :
+ "No such command: versionlock\n";
+ };
+local $dnf_version;
+local $supports_dnf_versionlock;
+ok(!supports_update_system_holds(),
+ 'hides holds when DNF does not expose versionlock');
+}
+
+{
+no warnings qw(once redefine);
+my $output =
+ "bash-0:5.1.8-8.el9.*\n".
+ "python3-*\n".
+ "0:python3-pip-21.2.3-8.el9.*\n".
+ "!blocked-0:2.0-1.el9.*\n";
+local *supports_update_system_holds = sub { return 1; };
+local *get_dnf_version = sub { return 4; };
+local *clean_language = sub { };
+local *reset_environment = sub { };
+local *open_execute_command = sub {
+ my ($fh) = @_;
+ no strict 'refs';
+ open(ref($fh) ? $fh : \*{$fh}, '<', \$output)
+ or die "open simulated DNF 4 locks: $!";
+ };
+
+is_deeply([ list_update_system_holds() ], [ qw(bash python3-pip) ],
+ 'lists DNF 4 exact locks without raw patterns or excludes');
+}
+
+{
+no warnings qw(once redefine);
+my $output =
+ "# Added by 'dnf versionlock add nano'\n".
+ "Package name: nano\n".
+ "evr = 8.7.1-2.fc44\n\n".
+ "Package name: coreutils\n".
+ "evr != 9.10-4.fc44\n\n".
+ "Package name: bash\n".
+ "evr = 5.3.0-2.fc44\n".
+ "arch = aarch64\n\n".
+ "Package name: python3-*\n".
+ "evr = 3.14.0-1.fc44\n\n".
+ "Package name: nano\n".
+ "evr > 9\n";
+local *supports_update_system_holds = sub { return 1; };
+local *get_dnf_version = sub { return 5; };
+local *clean_language = sub { };
+local *reset_environment = sub { };
+local *open_execute_command = sub {
+ my ($fh) = @_;
+ no strict 'refs';
+ open(ref($fh) ? $fh : \*{$fh}, '<', \$output)
+ or die "open simulated DNF 5 locks: $!";
+ };
+
+is_deeply([ list_update_system_holds() ], [ ],
+ 'skips DNF 5 names with duplicate, custom or glob lock rules');
+
+$output = "Package name: nano\nevr = 8.7.1-2.fc44\n";
+is_deeply([ list_update_system_holds() ], [ 'nano' ],
+ 'lists a simple DNF 5 exact lock by package name');
+}
+
+{
+no warnings qw(once redefine);
+is(update_system_hold_spec_name('bash-0:5.1.8-8.el9.*'), 'bash',
+ 'decodes DNF 4 name-epoch-version entries');
+is(update_system_hold_spec_name('0:python3-pip-21.2.3-8.el9.*'),
+ 'python3-pip', 'decodes legacy DNF 4 epoch-name entries');
+is(update_system_hold_spec_name('python3-*'), undef,
+ 'does not treat a raw pattern as an exact lock');
+is(update_system_hold_spec_name('!bash-0:5.1.8-8.el9.*'), undef,
+ 'does not treat an exclude as an exact lock');
+is(update_system_nevra_name('nano-0:8.7.1-2.fc44'), 'nano',
+ 'extracts a DNF 5 package name from transaction output');
+is(update_system_nevra_name(
+ 'webmin-virtualmin-support-2:4.3.202602061237-1.noarch'),
+ 'webmin-virtualmin-support', 'extracts a hyphenated NEVRA name');
+is(update_system_nevra_name('nano-8.7.1-2.fc44.aarch64'), 'nano',
+ 'extracts a package name when no epoch is printed');
+}
+
+{
+no warnings qw(once redefine);
+my @commands;
+local *supports_update_system_holds = sub { return 1; };
+local *unique = sub {
+ my %seen;
+ return grep { !$seen{$_}++ } @_;
+ };
+local *clean_language = sub { };
+local *reset_environment = sub { };
+local *trim = sub {
+ my ($value) = @_;
+ $value =~ s/^\s+|\s+$//g;
+ return $value;
+ };
+local *list_update_system_holds = sub { return ('bash'); };
+local *execute_command_logged = sub {
+ my ($command, undef, $stdout) = @_;
+ push(@commands, $command);
+ $$stdout = '';
+ return 0;
+ };
+
+is(update_system_hold([ 'bash', 'bash', 'coreutils' ], 1), undef,
+ 'adds DNF version locks successfully');
+is(update_system_hold([ 'bash', 'coreutils' ], 0), undef,
+ 'removes only package names reported as exactly locked');
+is_deeply(\@commands,
+ [ '/usr/bin/dnf -q versionlock add bash',
+ '/usr/bin/dnf -q versionlock add coreutils',
+ '/usr/bin/dnf -q versionlock delete bash' ],
+ 'uses name-based add and delete commands on both DNF generations');
+}
+
+{
+no warnings qw(once redefine);
+my $dnf_output =
+ "[3/6] Upgrading bash-0:5.3.0-2.fc44 100% | 1.0 MiB/s | 1.0 MiB | 00m01s\n";
+my $executed_command;
+my @lock_actions;
+local *update_system_hold_flags = sub { return 'held-update'; };
+local *append_architectures = sub { return @_; };
+local *package_info = sub { return ('bash'); };
+local *update_system_repo = sub { return undef; };
+local *additional_log = sub { };
+local *html_escape = sub { return $_[0]; };
+local *text = sub { return $_[0]; };
+local *unique = sub {
+ my %seen;
+ return grep { !$seen{$_}++ } @_;
+ };
+local *delete_update_system_holds = sub {
+ my ($packages, $removed) = @_;
+ push(@lock_actions, [ 'delete', [ @$packages ] ]);
+ push(@$removed, 'bash');
+ return undef;
+ };
+local *restore_update_system_holds = sub {
+ my ($packages) = @_;
+ push(@lock_actions, [ 'restore', [ @$packages ] ]);
+ return undef;
+ };
+local *open_execute_command = sub {
+ my ($fh, $command) = @_;
+ $executed_command = $command;
+ no strict 'refs';
+ open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
+ or die "open simulated DNF install: $!";
+ };
+
+my $printed = '';
+open(my $stdout, '>', \$printed) or die "open captured stdout: $!";
+local *STDOUT = $stdout;
+$? = 0;
+my @installed = update_system_install('bash', { }, 1, 'held-update');
+is_deeply(\@installed, [ 'bash' ],
+ 'returns a DNF 5 package updated while held');
+unlike($executed_command, qr/held-update|disableplugin|disable_excludes/,
+ 'runs the transaction without a global versionlock bypass');
+is_deeply(\@lock_actions,
+ [ [ 'delete', [ 'bash' ] ], [ 'restore', [ 'bash' ] ] ],
+ 'temporarily unlocks and then re-locks only the selected package');
+}
+
+{
+no warnings qw(once redefine);
+my $dnf_output =
+ "bash.aarch64 5.1.8-10.el9 baseos\n".
+ "coreutils.aarch64 8.32-40.el9 baseos\n";
+my @commands;
+local *supports_update_system_holds = sub { return 1; };
+local *list_update_system_holds = sub { return ('bash'); };
+local *set_yum_security_field = sub { };
+local *get_dnf_version = sub { return 4; };
+local *open_execute_command = sub {
+ my ($fh, $command) = @_;
+ push(@commands, $command);
+ no strict 'refs';
+ open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
+ or die "open simulated DNF updates: $!";
+ };
+
+my @normal = update_system_updates(0);
+is_deeply([ map { $_->{'name'} } @normal ], [ 'coreutils' ],
+ 'DNF 4 regular updates exclude held packages');
+my @with_holds = update_system_updates(1);
+is_deeply([ map { $_->{'name'} } @with_holds ],
+ [ 'bash', 'coreutils' ], 'DNF 4 held-update query includes locks');
+like($commands[1], qr/--disableplugin=versionlock check-update/,
+ 'DNF 4 uses its plugin bypass for held-update discovery');
+
+@commands = ( );
+local *get_dnf_version = sub { return 5; };
+@with_holds = update_system_updates(1);
+like($commands[0], qr/--setopt=disable_excludes=\\\* check-update/,
+ 'DNF 5 disables excludes for held-update discovery');
+ok($with_holds[0]->{'held'}, 'marks a DNF 5 locked update as held');
+}
+
+{
+no warnings qw(once redefine);
+my $dnf_output =
+ "Last metadata expiration check: 0:10:00 ago.\n".
+ "Dependencies resolved.\n".
+ "================================================================\n".
+ " Package Architecture Version Repository Size\n".
+ "================================================================\n".
+ "Upgrading:\n".
+ " tzdata noarch 2026c-1.fc44 updates 497 k\n".
+ " vim-minimal aarch64 2:9.1.2000-2.fc44 updates 647 k\n".
+ "Installing dependencies:\n".
+ " wbt-virtual-server-theme\n".
+ " noarch 21.20-1 virtualmin 2.5 M\n".
+ "Removing dependent packages:\n".
+ " oldpkg noarch 1.0-1 system 1 k\n".
+ "\nTransaction Summary:\n".
+ "Upgrade 2 Packages\n";
+my $command;
+my @lock_actions;
+my $dnf_major = 4;
+local *update_system_hold_flags = sub {
+ return '--setopt=disable_excludes=*';
+ };
+local *get_dnf_version = sub { return $dnf_major; };
+local *open_execute_command = sub {
+ my ($fh, $cmd) = @_;
+ $command = $cmd;
+ no strict 'refs';
+ open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
+ or die "open simulated DNF transaction: $!";
+ };
+local *clean_language = sub { };
+local *reset_environment = sub { };
+local *trim = sub {
+ my ($value) = @_;
+ $value =~ s/^\s+|\s+$//g;
+ return $value;
+ };
+local *unique = sub {
+ my %seen;
+ return grep { !$seen{$_}++ } @_;
+ };
+local *delete_update_system_holds = sub {
+ my ($packages, $removed) = @_;
+ push(@lock_actions, [ 'delete', [ @$packages ] ]);
+ push(@$removed, @$packages);
+ return undef;
+ };
+local *restore_update_system_holds = sub {
+ my ($packages) = @_;
+ push(@lock_actions, [ 'restore', [ @$packages ] ]);
+ return undef;
+ };
+
+my @ops = update_system_operations('tzdata vim-minimal');
+like($command, qr{^/usr/bin/dnf --assumeno install },
+ 'uses a simulated install for DNF 4 operations');
+unlike($command, qr/disableplugin|disable_excludes/,
+ 'keeps versionlock active for regular operations');
+is_deeply([ map { $_->{'name'} } @ops ],
+ [ qw(tzdata vim-minimal wbt-virtual-server-theme) ],
+ 'parses install and upgrade rows from a DNF transaction table');
+
+$dnf_major = 5;
+@ops = update_system_operations('tzdata vim-minimal',
+ '--setopt=disable_excludes=*');
+like($command, qr{^/usr/bin/dnf --assumeno upgrade },
+ 'uses a simulated upgrade for installed packages on DNF 5');
+unlike($command, qr/disableplugin|disable_excludes/,
+ 'does not pass the discovery-only bypass to a transaction preview');
+is_deeply(\@lock_actions,
+ [ [ 'delete', [ qw(tzdata vim-minimal) ] ],
+ [ 'restore', [ qw(tzdata vim-minimal) ] ] ],
+ 'temporarily unlocks selected packages for a held-update preview');
+is_deeply([ map { $_->{'name'} } @ops ],
+ [ qw(tzdata vim-minimal wbt-virtual-server-theme) ],
+ 'parses the held-package preview after restoring its locks');
+is($ops[1]->{'epoch'}, '2', 'splits epochs from preview versions');
+is($ops[2]->{'arch'}, 'noarch', 'handles wrapped package names');
+}
+
+done_testing();