Files
webmin/bin/patch
Ilia Ross aa3db4ddbf Fix patch command to apply with git inside another repository
ⓘ When GNU patch is not installed, git apply was run inside the module directory, and if the Webmin root itself was a git repository, git resolved paths relative to that root and silently skipped the patched files.

Limit repository discovery to the current directory and allow reduced context, similar to the default fuzz used by patch, so slightly drifted context lines no longer cause a rejection.
2026-08-17 19:06:25 +02:00

244 lines
5.5 KiB
Perl
Executable File

#!/usr/bin/env perl
# patch - Apply a patch to Webmin core or its modules from GitHub or a local file
use strict;
use warnings;
use 5.010;
use Getopt::Long qw(:config permute pass_through);
use Pod::Usage;
use File::Basename;
use Cwd qw(cwd);
my %opt;
GetOptions(
'help|h' => \$opt{'help'},
'config|c=s' => \$opt{'config'},
);
pod2usage(0) if ($opt{'help'});
# Get Webmin path
my $path = cwd;
my $lib = "web-lib-funcs.pl";
if (!-r "$path/$lib") {
$path = dirname(dirname($0));
if (!-r "$path/$lib") {
$path = $path = Cwd::realpath('..');
}
}
# Init core
my $config_dir = $opt{'config'} || '/etc/webmin';
$ENV{'WEBMIN_CONFIG'} = $config_dir;
push(@INC, $path);
eval 'use WebminCore';
init_config();
# Check if curl is installed
if (!has_command('curl')) {
print "\"curl\" command is not installed\n";
exit 1;
}
# Check if git is installed
if (!has_command('patch')) {
if (!has_command('git')) {
print "Neither \"patch\" nor \"git\" commands are installed\n";
exit 1;
}
}
# Get patch URL or file
my $patch = $ARGV[0];
# Params check
if (!$patch) {
pod2usage(0);
exit 1;
}
# Patch check
if ($patch !~ /^https?:\/\//) {
if (!-r $patch) {
print "Patch file $patch doesn't exist\n";
exit 1;
}
}
elsif ($patch =~ /^https?:\/\/(github|gitlab)\.com/ &&
$patch !~ /\.patch$/ && $patch !~ /\.diff$/) {
$patch .= '.patch';
}
# Parse module name from URL
my $module = "";
if ($patch =~ m{
(?|
# GitHub/GitLab commit URL
https://(?:github|gitlab)\.com/([^/]+)/([^/]+)/commit/([^/]+)
|
# GitHub pull request commit URL
https://github\.com/([^/]+)/([^/]+)/pull/\d+/commits/([^/]+)
|
# GitLab merge request URL with commit ID
https://gitlab\.com/([^/]+)/([^/]+)/-/merge_requests/\d+/diffs\?commit_id=([^&]+)
|
# GitHub raw URL
https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)
|
# GitLab raw URL
https://gitlab\.com/([^/]+)/([^/]+)/-/raw/([^/]+)/(.+)
)
}x) {
$module = $2;
$module = "" if ($2 eq 'webmin');
# Special handling for some modules
$module = $module =~ /^virtualmin-pro$/ ?
'virtual-server/pro' :
'virtual-server'
if $module =~ /^virtualmin-(gpl|pro)$/;
}
# Check if module exists
if (!-d "$path/$module") {
print "Module '$module' doesn't exist\n";
exit 1;
}
# Prepare patch command
my $cmd;
my $direct;
my $filename;
my $dir;
my $output;
# If raw URL given, try to download and just replace the whole file
if ($patch =~ m{^https?://raw\.githubusercontent\.com/} ||
$patch =~ m{^https?://gitlab\.com/.*?/-/raw/}) {
if ($patch =~ m{
.*? # Non-greedy match of everything up to the branch name
(?:master|main)/ # Branches name
(.*/)? # Capture all directories after the branch (group 1)
([^/]+)$ # Filename (group 2)
}x)
{
$direct = 1;
$dir = $1 // "";
$filename = $2;
}
else {
print "Patch failed: Can't parse file name from URL\n";
exit 1;
}
my $cd = "$path/$module/$dir";
$cd =~ s|/+|/|g;
chdir "$cd";
$cmd = "curl -w \"%{http_code}\\n\" -s -o $filename @{[quotemeta($patch)]}";
}
# Download command
elsif ($patch =~ /^https?:\/\//) {
$cmd = "curl -L -s @{[quotemeta($patch)]}";
chdir "$path/$module";
}
# Local file
else {
$cmd = "cat @{[quotemeta($patch)]}";
}
# Download file directly
if ($direct) {
$output = `$cmd 2>&1`;
if ($output != 200) {
print "Patch failed: Cannot download '$filename'. HTTP status code: $output\n";
exit 1;
}
}
# Apply patch using patch command
elsif (has_command('patch')) {
$output = `$cmd 2>&1 | patch -p1 --verbose 2>&1`;
if ($output !~ /succeeded/i) {
print "Patch failed: $output\n";
exit 1;
}
}
# Apply patch using git command
else {
# If the current directory is inside some git repository (e.g. Webmin
# root tracked with git), git would treat patch paths as relative to
# that repository root and silently skip them, so stop repository
# discovery at the parent directory to always apply patch paths
# relative to the current directory
local $ENV{'GIT_CEILING_DIRECTORIES'} = dirname(cwd());
# Allow reduced context, similar to the default fuzz used by patch
$output = `$cmd 2>&1 | git apply --reject --verbose -C1 --whitespace=fix 2>&1`;
if ($output !~ /applied patch.*?cleanly/i) {
print "Patch failed: $output\n";
exit 1;
}
}
# Print results
if ($direct) {
print "File replaced successfully:\n";
if ($dir) {
$dir = $dir ? "/$dir" : "";
$dir =~ s|^/||;
}
print " $dir$filename\n";
system("sed -i '1s|^#!/usr/local/bin/perl|#!/usr/bin/perl|' \"$filename\"");
}
else {
print "Patch applied successfully to:\n";
print " $1\n" while $output =~ /^(?|Applied patch\s+(\S+)|patching file\s+(\S+))/mg;
}
# Reload Webmin
reload_miniserv();
=pod
=head1 NAME
patch
=head1 DESCRIPTION
Apply a patch to Webmin core or its modules from GitHub/GitLab, a local
file, or by downloading and replacing the entire file from a raw URL.
=head1 SYNOPSIS
webmin patch patch-url/file
=head1 OPTIONS
=over
=item --help, -h
Give this help list.
=item --config, -c
Specify the full path to the Webmin configuration directory. Defaults to
C</etc/webmin>
Examples of usage:
Apply a patch from a URL.
- webmin patch https://github.com/webmin/webmin/commit/e6a2bb15b0.patch
- webmin patch https://github.com/virtualmin/virtualmin-gpl/commit/f4433153d
Apply a patch from local file.
- cd /usr/libexec/webmin/virtual-server/pro &&
webmin patch /root/virtualmin-pro/patches/patch-1.patch
=back
=head1 LICENSE AND COPYRIGHT
Copyright 2024 Ilia Ross <ilia@virtualmin.com>