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();