# grub2-lib.pl
# Helpers for the GRUB 2 Webmin module.

BEGIN { push(@INC, ".."); };    ## no critic
use strict;
use warnings;
use WebminCore;
use Cwd qw(abs_path);
use File::Basename qw(basename dirname);
use File::Find;
use File::Path qw(make_path remove_tree);
use Fcntl qw(O_CREAT O_EXCL O_WRONLY);
use Errno qw(EEXIST);

our (%config, %text);
our ($module_root_directory, $module_var_directory);
our ($grub2_config_change_flag, $grub2_generate_time_flag);

&init_config();
$grub2_config_change_flag = $module_var_directory."/config-flag";
$grub2_generate_time_flag = $module_var_directory."/generate-flag";
&load_grub2_defaults();

# grub2_mark_regenerate_needed()
# Updates the flag indicating that grub.cfg needs to be regenerated.
sub grub2_mark_regenerate_needed
{
&open_lock_tempfile(my $fh, ">$grub2_config_change_flag", 0, 1);
&close_tempfile($fh);
return;
}

# grub2_mark_generated()
# Updates the flag indicating that grub.cfg has been regenerated.
sub grub2_mark_generated
{
&open_lock_tempfile(my $fh, ">$grub2_generate_time_flag", 0, 1);
&close_tempfile($fh);
return;
}

# grub2_needs_regenerate()
# Returns true when a saved source change has not been regenerated yet.
sub grub2_needs_regenerate
{
my @cst = stat($grub2_config_change_flag);
my @gst = stat($grub2_generate_time_flag);
return 0 if (!@cst);
return 1 if (!@gst);
return $cst[9] > $gst[9] ? 1 : 0;
}

# grub2_action_links(&acl, [return-url])
# Returns header action links for applying pending GRUB source changes.
sub grub2_action_links
{
my ($acl, $return_url) = @_;
return '' if (!$acl->{'apply'} || !&grub2_command('mkconfig_cmd'));
$return_url ||= &grub2_this_url();
my $label = $text{'index_generate'};
$label = &ui_tag('b', $label) if (&grub2_needs_regenerate());
return &ui_link("generate.cgi?redir=".&urlize($return_url), $label);
}

# grub2_this_url()
# Returns the current module URL for apply-action redirects.
sub grub2_this_url
{
my $url = $ENV{'SCRIPT_NAME'} || '';
$url .= "?$ENV{'QUERY_STRING'}"
	if (defined($ENV{'QUERY_STRING'}) && $ENV{'QUERY_STRING'} ne '');
return $url;
}

# load_grub2_defaults()
# Fills missing runtime config values from the bundled module defaults.
sub load_grub2_defaults
{
my %defaults;
if ($module_root_directory && -r "$module_root_directory/config") {
	# Start with bundled defaults so unconfigured installs have sane paths.
	&read_file("$module_root_directory/config", \%defaults);
	}
foreach my $k (keys %defaults) {
	if (!defined($config{$k}) || $config{$k} eq '') {
		$config{$k} = $defaults{$k};
		}
	}
&discover_grub2_runtime_defaults();
}

# discover_grub2_runtime_defaults()
# Corrects missing generic defaults to the GRUB 2 layout installed locally.
sub discover_grub2_runtime_defaults
{
# Prefer common distro paths before falling back to the packaged config.
&prefer_existing_file('grub_cfg',
	'/boot/grub2/grub.cfg',
	'/boot/grub/grub.cfg',
	'/boot/efi/EFI/redhat/grub.cfg',
	'/boot/efi/EFI/rocky/grub.cfg',
	'/boot/efi/EFI/almalinux/grub.cfg',
	'/boot/efi/EFI/centos/grub.cfg',
	'/boot/efi/EFI/debian/grub.cfg',
);
&prefer_existing_file('grubenv_file',
	'/boot/grub2/grubenv',
	'/boot/grub/grubenv',
	'/boot/efi/EFI/redhat/grubenv',
	'/boot/efi/EFI/rocky/grubenv',
	'/boot/efi/EFI/almalinux/grubenv',
	'/boot/efi/EFI/centos/grubenv',
	'/boot/efi/EFI/debian/grubenv',
);
&prefer_existing_dir('bls_dir', '/boot/loader/entries');
&prefer_existing_dir('theme_dir',
	'/boot/grub2/themes',
	'/boot/grub/themes',
);
&prefer_existing_dir('background_dir',
	'/boot/grub2/backgrounds',
	'/boot/grub/backgrounds',
);
&prefer_existing_command('mkconfig_cmd',
	qw(grub2-mkconfig grub-mkconfig));
&prefer_existing_command('install_cmd',
	qw(grub2-install grub-install));
&prefer_existing_command('set_default_cmd',
	qw(grub2-set-default grub-set-default));
&prefer_existing_command('reboot_once_cmd',
	qw(grub2-reboot grub-reboot));
&prefer_existing_command('editenv_cmd',
	qw(grub2-editenv grub-editenv));
# Optional helpers unlock safer validation and BLS updates when installed.
&prefer_existing_command('script_check_cmd',
	qw(grub2-script-check grub-script-check));
&prefer_existing_command('mkpasswd_cmd',
	qw(grub2-mkpasswd-pbkdf2 grub-mkpasswd-pbkdf2));
&prefer_existing_command('grubby_cmd', qw(grubby));
}

# prefer_existing_file(key, paths...)
# Uses the first existing path when the configured file is missing.
sub prefer_existing_file
{
my ($key, @paths) = @_;
my $current = $config{$key};
return if (defined($current) && $current ne '' && -e $current);
foreach my $path (@paths) {
	if (-e $path) {
		$config{$key} = $path;
		return;
		}
	}
}

# prefer_existing_dir(key, paths...)
# Uses the first existing directory when the configured directory is missing.
sub prefer_existing_dir
{
my ($key, @paths) = @_;
my $current = $config{$key};
return if (defined($current) && $current ne '' && -d $current);
foreach my $path (@paths) {
	if (-d $path) {
		$config{$key} = $path;
		return;
		}
	}
}

# prefer_existing_command(key, commands...)
# Uses the first available GRUB command when the configured command is missing.
sub prefer_existing_command
{
my ($key, @commands) = @_;
my $current = $config{$key};
return if (defined($current) && $current ne '' && &has_command($current));
foreach my $cmd (@commands) {
	my $found = &has_command($cmd);
	if ($found) {
		$config{$key} = $found;
		return;
		}
	}
}

# grub2_config_value(key)
# Returns a module configuration value after defaults have been loaded.
sub grub2_config_value
{
my ($key) = @_;
return $config{$key};
}

# grub2_command(key)
# Returns a usable configured command path, if available.
sub grub2_command
{
my ($key) = @_;
my $cmd = &grub2_config_value($key);
return if (!defined($cmd) || $cmd eq '');
return &has_command($cmd);
}

# grub2_version_text()
# Returns a friendly GRUB version string for page subtitles.
sub grub2_version_text
{
foreach my $key (qw(install_cmd mkconfig_cmd editenv_cmd set_default_cmd)) {
	my $cmd = &grub2_command($key);
	next if (!$cmd);
	# Any installed GRUB helper can report the package version.
	my $out = &backquote_command(
		quotemeta($cmd).' --version 2>&1 </dev/null', 1);
	next if ($? || !$out);
	$out =~ s/\r?\n.*\z//s;
	$out =~ s/^\s+|\s+\z//g;
	if ($out =~ /\((?:GRUB|GRUB2)\)\s+(\S+)/i ||
	    $out =~ /\bGRUB\s+(\S+)/i) {
		my $version = $1;
		return &text('index_version', $version);
		}
	return $out;
	}
return;
}

# grub2_any_installed()
# Returns true if GRUB 2 files or commands are present on this system.
sub grub2_any_installed
{
return 1 if (&grub2_command('mkconfig_cmd'));
return 1 if (&grub2_command('install_cmd'));
return 1 if (&grub2_command('set_default_cmd'));
return 1 if (&grub2_command('editenv_cmd'));
return 1 if (-r (&grub2_config_value('default_file') || ''));
return 1 if (-r (&grub2_config_value('grub_cfg') || ''));
return 0;
}

# grub2_configured()
# Returns true if the module has enough files to inspect a GRUB 2 setup.
sub grub2_configured
{
return 1 if (-r (&grub2_config_value('default_file') || ''));
return 1 if (-r (&grub2_config_value('grub_cfg') || ''));
return 0;
}

# grub2_install_issues()
# Returns human-readable missing items for the module index.
sub grub2_install_issues
{
my @issues;
my $default_file = &grub2_config_value('default_file') || '';
my $grub_cfg = &grub2_config_value('grub_cfg') || '';
push(@issues, $default_file) if ($default_file ne '' && !-r $default_file);
push(@issues, $grub_cfg) if ($grub_cfg ne '' && !-r $grub_cfg);
push(@issues, &grub2_config_value('mkconfig_cmd') || 'grub-mkconfig')
	if (!&grub2_command('mkconfig_cmd'));
return @issues;
}

# grub2_default_keys()
# Returns settings edited by the structured defaults page.
sub grub2_default_keys
{
return qw(
	GRUB_DEFAULT
	GRUB_TIMEOUT_STYLE
	GRUB_TIMEOUT
	GRUB_TERMINAL_OUTPUT
	GRUB_GFXMODE
	GRUB_CMDLINE_LINUX_DEFAULT
	GRUB_CMDLINE_LINUX
	GRUB_DISABLE_RECOVERY
	GRUB_DISABLE_OS_PROBER
	GRUB_THEME
	GRUB_BACKGROUND
	GRUB_COLOR_NORMAL
	GRUB_COLOR_HIGHLIGHT
);
}

# grub2_bls_update_available([&entries])
# Returns true when existing BLS entries can be updated with grubby.
sub grub2_bls_update_available
{
my ($entries) = @_;
$entries ||= [ &grub2_boot_entries() ];
return 0 if (!&grub2_command('grubby_cmd'));
return (grep { ($_->{'source'} || '') eq 'bls' } @$entries) ? 1 : 0;
}

# grub2_defaults_updates_need_generate(&old-values, &updates, bls-args-updated?)
# Returns true when saved defaults still need grub-mkconfig regeneration.
sub grub2_defaults_updates_need_generate
{
my ($old_values, $updates, $bls_args_updated) = @_;
my %bls_updated = ref($bls_args_updated) eq 'HASH' ? %$bls_args_updated :
	$bls_args_updated ? (
		'GRUB_CMDLINE_LINUX' => 1,
		'GRUB_CMDLINE_LINUX_DEFAULT' => 1,
		) : ();
foreach my $key (keys %$updates) {
	my $old = defined($old_values->{$key}) ? $old_values->{$key} : '';
	my $new = defined($updates->{$key}) ? $updates->{$key} : '';
	next if ($old eq $new);
	# grubby already applied these BLS-facing changes to live entries.
	next if ($bls_updated{$key} &&
		 ($key eq 'GRUB_CMDLINE_LINUX' ||
		  $key eq 'GRUB_CMDLINE_LINUX_DEFAULT' ||
		  $key eq 'GRUB_DISABLE_RECOVERY'));
	return 1;
	}
return 0;
}

# grub2_password_file()
# Returns the managed GRUB password script path.
sub grub2_password_file
{
my $file = &grub2_config_value('password_file');
return $file if (defined($file) && $file ne '');
my $dir = &grub2_config_value('grub_dir') || '/etc/grub.d';
return "$dir/01_webmin_password";
}

# grub2_default_efi_directory()
# Returns a likely EFI system partition mount point, if one exists.
sub grub2_default_efi_directory
{
foreach my $dir ('/boot/efi', '/efi') {
	return $dir if (-d $dir);
	}
return '';
}

# grub2_default_bootloader_id([efi-dir])
# Returns a likely EFI boot loader ID from existing GRUB files.
sub grub2_default_bootloader_id
{
my ($efi_dir) = @_;
foreach my $file (&grub2_config_value('grub_cfg'),
		  &grub2_config_value('grubenv_file')) {
	next if (!defined($file));
	if ($file =~ m{\A/(?:boot/efi|efi)/EFI/([^/]+)/}) {
		return $1 if (&valid_bootloader_id_candidate($1));
		}
	}
$efi_dir ||= &grub2_default_efi_directory();
return '' if ($efi_dir eq '' || !-d "$efi_dir/EFI");
opendir(my $dh, "$efi_dir/EFI") || return '';
my @dirs = grep { -d "$efi_dir/EFI/$_" && &valid_bootloader_id_candidate($_) }
	   readdir($dh);
closedir($dh);
my @matches;
foreach my $dir (sort @dirs) {
	my $path = "$efi_dir/EFI/$dir";
	if (-e "$path/grub.cfg" || -e "$path/grubenv" ||
	    &efi_vendor_dir_has_loader($path)) {
		push(@matches, $dir);
		}
	}
return @matches == 1 ? $matches[0] : '';
}

# efi_vendor_dir_has_loader(dir)
# Returns true if an EFI vendor directory contains a likely GRUB/shim loader.
sub efi_vendor_dir_has_loader
{
my ($dir) = @_;
opendir(my $dh, $dir) || return 0;
my $found = grep { /^(?:grub.*|shim.*)\.efi\z/i && -f "$dir/$_" }
	    readdir($dh);
closedir($dh);
return $found ? 1 : 0;
}

# valid_bootloader_id_candidate(value)
# Returns true if a value is suitable for --bootloader-id.
sub valid_bootloader_id_candidate
{
my ($value) = @_;
return 0 if (!defined($value) || $value eq '');
return 0 if ($value =~ /[\r\n\0]/ || $value =~ /^-/ ||
	     $value !~ /\A[A-Za-z0-9_.+-]+\z/);
return 0 if ($value =~ /^(?:boot|microsoft)\z/i);
return 1;
}

# grub2_boot_mode([efi-firmware-dir])
# Returns uefi when the system booted via EFI firmware, or bios otherwise.
sub grub2_boot_mode
{
my ($efi_dir) = @_;
$efi_dir ||= '/sys/firmware/efi';
return -d $efi_dir ? 'uefi' : 'bios';
}

# grub2_secure_boot_status([efi-firmware-dir], [efivars-dir], [mokutil-cmd])
# Returns enabled, disabled, unknown, or not_applicable for Secure Boot.
sub grub2_secure_boot_status
{
my ($efi_dir, $efivars_dir, $mokutil_cmd) = @_;
$efi_dir ||= '/sys/firmware/efi';
return 'not_applicable' if (&grub2_boot_mode($efi_dir) ne 'uefi');
if (!defined($mokutil_cmd)) {
	$mokutil_cmd = &has_command('mokutil') || '';
	}
if ($mokutil_cmd ne '') {
	my $out = &backquote_command(
		quotemeta($mokutil_cmd).' --sb-state 2>&1 </dev/null', 1);
	if (!$?) {
		return 'enabled' if ($out =~ /SecureBoot\s+enabled/i);
		return 'disabled'
			if ($out =~ /SecureBoot\s+disabled/i ||
			    $out =~ /validation\s+is\s+disabled/i);
		}
	}
$efivars_dir ||= "$efi_dir/efivars";
my $efivar_status = &grub2_secure_boot_efivar_status($efivars_dir);
return $efivar_status if ($efivar_status);
return 'unknown';
}

# grub2_secure_boot_efivar_status(efivars-dir)
# Reads the SecureBoot EFI variable from efivarfs when mokutil is absent.
sub grub2_secure_boot_efivar_status
{
my ($efivars_dir) = @_;
return if (!defined($efivars_dir) || $efivars_dir eq '' ||
	   !-d $efivars_dir);
opendir(my $dh, $efivars_dir) || return;
my @vars = grep { /^SecureBoot-/ && -r "$efivars_dir/$_" } readdir($dh);
closedir($dh);
foreach my $var (@vars) {
	my $data = &read_file_contents("$efivars_dir/$var");
	next if (!defined($data) || length($data) < 5);
	return ord(substr($data, 4, 1)) ? 'enabled' : 'disabled';
	}
return;
}

# grub2_default_platform_target()
# Returns the likely grub-install platform target for this boot mode.
sub grub2_default_platform_target
{
my $machine = &backquote_command('uname -m 2>/dev/null </dev/null');
$machine =~ s/^\s+|\s+\z//g if (defined($machine));
if (&grub2_boot_mode() eq 'uefi') {
	return 'x86_64-efi' if ($machine =~ /^(?:x86_64|amd64)\z/i);
	return 'i386-efi' if ($machine =~ /^i[3-6]86\z/i);
	return 'arm64-efi' if ($machine =~ /^(?:aarch64|arm64)\z/i);
	return 'arm-efi' if ($machine =~ /^arm/i);
	return 'riscv64-efi' if ($machine =~ /^riscv64\z/i);
	}
return 'i386-pc' if ($machine =~ /^(?:x86_64|amd64|i[3-6]86)\z/i);
return '';
}

# grub2_platform_module_dirs(platform)
# Returns possible GRUB module directories for a platform target.
sub grub2_platform_module_dirs
{
my ($platform) = @_;
return () if (!defined($platform) || $platform eq '');
return (
	"/usr/lib/grub/$platform",
	"/usr/lib/grub2/$platform",
	"/usr/share/grub/$platform",
	"/usr/share/grub2/$platform",
);
}

# grub2_platform_module_dir(platform)
# Returns the first module directory containing modinfo.sh.
sub grub2_platform_module_dir
{
my ($platform) = @_;
foreach my $dir (&grub2_platform_module_dirs($platform)) {
	return $dir if (-r "$dir/modinfo.sh");
	}
return '';
}

# grub2_color_file()
# Returns the managed GRUB menu color generator script path.
sub grub2_color_file
{
my $file = &grub2_config_value('color_file');
return $file if (defined($file) && $file ne '');
my $dir = &grub2_config_value('grub_dir') || '/etc/grub.d';
return "$dir/06_webmin_colors";
}

# grub2_theme_dir()
# Returns the directory where Webmin installs GRUB themes.
sub grub2_theme_dir
{
my $dir = &grub2_config_value('theme_dir');
return $dir if (defined($dir) && $dir ne '');
my $cfg = &grub2_config_value('grub_cfg') || '';
return "$1/themes" if ($cfg =~ m{\A(/boot/grub2|/boot/grub)/});
return '/boot/grub2/themes' if (-d '/boot/grub2');
return '/boot/grub/themes';
}

# grub2_background_dir()
# Returns the directory where Webmin installs GRUB background images.
sub grub2_background_dir
{
my $dir = &grub2_config_value('background_dir');
return $dir if (defined($dir) && $dir ne '');
my $theme_dir = &grub2_theme_dir();
return "$1/backgrounds" if ($theme_dir =~ m{\A(.+)/themes/?\z});
my $cfg = &grub2_config_value('grub_cfg') || '';
return "$1/backgrounds" if ($cfg =~ m{\A(/boot/grub2|/boot/grub)/});
return '/boot/grub2/backgrounds' if (-d '/boot/grub2');
return '/boot/grub/backgrounds';
}

# grub2_color_names()
# Returns color names accepted by GRUB menu color settings.
sub grub2_color_names
{
return qw(
	black blue green cyan red magenta brown light-gray dark-gray
	light-blue light-green light-cyan light-red light-magenta yellow white
);
}

# grub2_validate_setting_path(value, label)
# Returns an error if a GRUB defaults path is not safe to save.
sub grub2_validate_setting_path
{
my ($value, $label) = @_;
return if (!defined($value) || $value eq '');
return &text('defaults_epath', $label) if ($value =~ /[\r\n\0]/);
return &text('defaults_eabspath', $label) if ($value !~ m{^/});
return &text('defaults_epathchars', $label)
	if ($value !~ m{\A/[A-Za-z0-9._/+ -]+\z});
return;
}

# grub2_validate_theme_path(value, label)
# Returns an error if a GRUB theme setting cannot affect generated menus.
sub grub2_validate_theme_path
{
my ($value, $label) = @_;
my $err = &grub2_validate_setting_path($value, $label);
return $err if ($err);
return if (!defined($value) || $value eq '');
return &text('defaults_etheme_archive', $value)
	if ($value =~ /\.(?:tar|tar\.(?:gz|bz2|xz|zst)|tgz|tbz2|txz|zip)\z/i);
return &text('defaults_etheme_file', $value) if (!-r $value || -d $value);
return;
}

# grub2_validate_background_path(value, label)
# Returns an error if a GRUB background image path is unusable.
sub grub2_validate_background_path
{
my ($value, $label) = @_;
my $err = &grub2_validate_setting_path($value, $label);
return $err if ($err);
return if (!defined($value) || $value eq '');
return &text('defaults_ebackground_file', $value) if (!-r $value || -d $value);
return &text('defaults_ebackground_type', $value)
	if ($value !~ /\.(?:png|jpe?g|tga)\z/i);
return;
}

# grub2_validate_gfxmode(value)
# Returns an error if a GRUB graphics mode setting is unsafe or malformed.
sub grub2_validate_gfxmode
{
my ($value) = @_;
$value = '' if (!defined($value));
return if ($value eq '');
return $text{'defaults_egfxmode'}
	if ($value !~
	    /\A(?:auto|keep|\d{3,5}x\d{3,5}(?:x\d{1,2})?)(?:,(?:auto|keep|\d{3,5}x\d{3,5}(?:x\d{1,2})?))*\z/);
return;
}

# grub2_install_theme_source(source)
# Installs a theme file, directory, archive, or URL under the GRUB boot tree.
sub grub2_install_theme_source
{
my ($source) = @_;
$source = '' if (!defined($source));
$source =~ s/^\s+|\s+\z//g;
return ('', $text{'defaults_etheme_source'}) if ($source eq '');

my ($file, $label, $cleanup, $err) = &grub2_prepare_theme_source($source);
return ('', $err) if ($err);
my ($theme_file, $tmpdir, $installed);
eval {
	my $type = &grub2_theme_archive_type($label || $file);
	if ($type) {
		# Archives are extracted only after their member list is validated.
		($tmpdir, $err) = &grub2_extract_theme_archive($file,
							       $type);
		die "$err\n" if ($err);
		$theme_file = &grub2_find_theme_file($tmpdir);
		die $text{'defaults_etheme_notfound'}."\n"
			if (!$theme_file);
		}
	else {
		# Direct sources may be a theme.txt file or a directory containing one.
		($theme_file, $err) = &grub2_theme_file_from_source($file);
		die "$err\n" if ($err);
		}
	($installed, $err) =
		&grub2_install_theme_directory(&grub2_dirname($theme_file),
					       $label || $file);
	die "$err\n" if ($err);
	1;
	} || do {
		$err = $@ || $!;
		$err =~ s/\s+\z// if (defined($err));
	};
if ($cleanup) {
	# Remove downloaded sources even when validation or install failed.
	if (-d $cleanup) {
		remove_tree($cleanup);
		}
	else {
		&grub2_unlink_temp($cleanup);
		}
	}
remove_tree($tmpdir) if ($tmpdir && -d $tmpdir);
return ('', $err) if ($err);
return ($installed);
}

# grub2_install_background_source(source)
# Copies a background image into the configured GRUB boot tree.
sub grub2_install_background_source
{
my ($source) = @_;
$source = '' if (!defined($source));
$source =~ s/^\s+|\s+\z//g;
return ('') if ($source eq '');
my $err = &grub2_validate_background_path($source,
					  $text{'defaults_background'});
return ('', $err) if ($err);
my $dir = &grub2_background_dir();
return ('', &text('defaults_econfigpath', $dir)) if ($dir !~ m{^/});
make_path($dir, { mode => oct("0755") }) if (!-d $dir);
return ('', &text('defaults_edir', $dir)) if (!-d $dir);
# Already-installed backgrounds can be reused without copying.
return ($source) if (&grub2_path_is_under($source, $dir));

my $base = basename($source);
# Sanitize the filename because the destination lives under /boot.
$base =~ s/[^A-Za-z0-9._+-]/_/g;
$base =~ s/\A[._-]+//;
$base = 'webmin-background.png' if ($base eq '');
my $dest = "$dir/$base";
$err = &grub2_copy_background_file($source, $dest);
return ('', $err) if ($err);
return ($dest);
}

# grub2_copy_background_file(source, destination)
# Copies one regular image file into a GRUB-readable location.
sub grub2_copy_background_file
{
my ($source, $dest) = @_;
my $real = eval { abs_path($source) };
return &text('defaults_ebackground_file', $source)
	if (!$real || !-f $real || !-r $real);
my $in;
return "$source : $!" if (!CORE::open($in, '<', $real));
CORE::binmode($in);
my $dir = &grub2_dirname($dest);
make_path($dir, { mode => oct("0755") }) if ($dir ne '' && !-d $dir);
open_tempfile(my $out, ">$dest");
my $buf;
my $err = '';
local $! = 0;
while (read($in, $buf, 32768)) {
	# Stream the copy so large images do not need to be loaded at once.
	print_tempfile($out, $buf);
	}
$err = "$!" if ($!);
my $cerr = close($in) ? '' : "$source : $!";
close_tempfile($out);
chmod(oct("0644"), $dest);
return $err if ($err);
return $cerr if ($cerr);
return;
}

# grub2_prepare_theme_source(source)
# Returns a local source file path for a local path or downloaded URL.
sub grub2_prepare_theme_source
{
my ($source) = @_;
if (&grub2_theme_source_is_url($source)) {
	# Remote sources are downloaded to a private temp directory first.
	return &grub2_download_theme_source($source);
	}
return ('', '', '', &text('defaults_eabspath', $text{'defaults_theme_source'}))
	if ($source !~ m{^/});
return ('', '', '', &text('defaults_etheme_file', $source))
	if (!-e $source || !-r $source);
return ($source, $source, '');
}

# grub2_theme_source_is_url(source)
# Returns true when a theme source is an HTTP, HTTPS, or FTP URL.
sub grub2_theme_source_is_url
{
my ($source) = @_;
return defined($source) && $source =~ m{\A(?:https?|ftp)://}i ? 1 : 0;
}

# grub2_download_theme_source(url)
# Downloads a remote theme source using Webmin's HTTP or FTP helpers.
sub grub2_download_theme_source
{
my ($url) = @_;
my ($host, $port, $page, $ssl, $user, $pass) = &parse_http_url($url);
return ('', '', 0, $text{'defaults_etheme_url'})
	if (!$host || !$page || ($ssl != 0 && $ssl != 1 && $ssl != 2));
my $base = $page;
$base =~ s/\?.*\z//;
$base = basename($base);
$base = 'theme-source' if ($base eq '' || $base =~ /\.\./);
# The downloaded filename is only a label; keep it filesystem-safe anyway.
$base =~ s/[^A-Za-z0-9._+-]/_/g;
my $tmpdir = &tempname("grub2-theme-download-$$-".int(rand(1000000)));
make_path($tmpdir, { mode => oct("0700") });
return ('', '', '', &text('defaults_edir', $tmpdir)) if (!-d $tmpdir);
my $temp = "$tmpdir/$base";
my $err = '';
if ($ssl == 2) {
	# parse_http_url uses ssl==2 for FTP URLs in Webmin helpers.
	my $ffile = $page;
	$ffile =~ s{\A/}{};
	&ftp_download($host, $ffile, $temp, \$err, undef, $user, $pass,
		      $port, 1);
	}
else {
	&http_download($host, $port, $page, $temp, \$err, undef, $ssl,
		       $user, $pass, 60, undef, 1);
	}
if ($err) {
	remove_tree($tmpdir);
	return ('', '', '', &text('defaults_etheme_download', $err));
	}
return ($temp, $url, $tmpdir);
}

# grub2_theme_archive_type(path-or-url)
# Returns the supported archive type for a source label.
sub grub2_theme_archive_type
{
my ($label) = @_;
$label = '' if (!defined($label));
$label =~ s/[?#].*\z//;
return 'targz' if ($label =~ /\.(?:tar\.gz|tgz)\z/i);
return 'tarbz2' if ($label =~ /\.(?:tar\.bz2|tbz2)\z/i);
return 'tarxz' if ($label =~ /\.(?:tar\.xz|txz)\z/i);
return 'tar' if ($label =~ /\.tar\z/i);
return 'zip' if ($label =~ /\.zip\z/i);
return '';
}

# grub2_theme_file_from_source(source)
# Returns a theme.txt file from a local source file or directory.
sub grub2_theme_file_from_source
{
my ($source) = @_;
if (-d $source) {
	my $theme = &grub2_find_theme_file($source);
	return ($theme) if ($theme);
	return ('', $text{'defaults_etheme_notfound'});
	}
return ('', &text('defaults_etheme_file', $source)) if (!-f $source);
return ($source) if (basename($source) eq 'theme.txt');
return ('', &text('defaults_etheme_nottheme', $source));
}

# grub2_extract_theme_archive(file, type)
# Extracts a validated theme archive into a private temporary directory.
sub grub2_extract_theme_archive
{
my ($file, $type) = @_;
my ($list_cmd, $err) = &grub2_archive_command($type, $file, 'list');
return ('', $err) if ($err);
# Validate the listing before extracting anything to disk.
my $out = &backquote_command($list_cmd.' 2>&1 </dev/null');
return ('', $out || $text{'defaults_etheme_archive_list'}) if ($?);
$err = &grub2_validate_archive_members(split(/\r?\n/, $out || ''));
return ('', $err) if ($err);

my $tmpdir = &tempname("grub2-theme-extract-$$-".int(rand(1000000)));
make_path($tmpdir, { mode => oct("0700") });
return ('', &text('defaults_edir', $tmpdir)) if (!-d $tmpdir);
my ($extract_cmd, $xerr) = &grub2_archive_command($type, $file, 'extract',
						 $tmpdir);
if ($xerr) {
	remove_tree($tmpdir);
	return ('', $xerr);
	}
# Validate again after extraction to catch symlinks or unusual archive output.
$out = &backquote_command($extract_cmd.' 2>&1 </dev/null');
if ($?) {
	remove_tree($tmpdir);
	return ('', $out || $text{'defaults_etheme_extract'});
	}
$err = &grub2_validate_extracted_theme_tree($tmpdir);
if ($err) {
	remove_tree($tmpdir);
	return ('', $err);
	}
return ($tmpdir);
}

# grub2_archive_command(type, file, mode, [directory])
# Returns a tar or unzip command for listing or extracting an archive.
sub grub2_archive_command
{
my ($type, $file, $mode, $dir) = @_;
if ($type eq 'zip') {
	my $unzip = &has_command('unzip');
	return ('', &text('defaults_etheme_cmd', 'unzip')) if (!$unzip);
	return ($mode eq 'list' ?
		quotemeta($unzip).' -Z -l '.quotemeta($file) :
		quotemeta($unzip).' -q -o '.quotemeta($file).
		' -d '.quotemeta($dir));
	}
my $tar = &has_command('tar');
return ('', &text('defaults_etheme_cmd', 'tar')) if (!$tar);
my %flags = (
	'tar' => [ 'tvf', 'xf' ],
	'targz' => [ 'tzvf', 'xzf' ],
	'tarbz2' => [ 'tjvf', 'xjf' ],
	'tarxz' => [ 'tJvf', 'xJf' ],
);
return ('', $text{'defaults_etheme_type'}) if (!$flags{$type});
my $flag = $mode eq 'list' ? $flags{$type}->[0] : $flags{$type}->[1];
my $cmd = quotemeta($tar).' '.$flag.' '.quotemeta($file);
$cmd .= ' -C '.quotemeta($dir) if ($mode ne 'list');
return ($cmd);
}

# grub2_validate_archive_members(member, ...)
# Rejects archive paths that could write outside the extraction directory.
sub grub2_validate_archive_members
{
foreach my $raw (@_) {
	my ($member, $type) = &grub2_archive_member_from_list_line($raw);
	next if (!defined($member));
	$member =~ s/^\s+|\s+\z//g;
	next if ($member eq '');
	# Permit regular files and directories only; reject links and devices.
	return &text('defaults_etheme_member', $member)
		if (defined($type) && $type !~ /^[-d]\z/);
	# Prevent archive traversal, absolute paths, and Windows separators.
	return &text('defaults_etheme_member', $member)
		if ($member =~ m{\A/} ||
		    $member =~ m{(?:\A|/)\.\.(?:/|\z)} ||
		    $member =~ /[\0\\]/);
	}
return;
}

# grub2_archive_member_from_list_line(line)
# Returns an archive member path and type from tar or zip verbose output.
sub grub2_archive_member_from_list_line
{
my ($line) = @_;
return if (!defined($line));
$line =~ s/^\s+|\s+\z//g;
return if ($line eq '' || $line =~ /^Archive:/ ||
	   $line =~ /^Zip file size:/ || $line =~ /^\d+\s+files?,/);
if ($line =~ /^([A-Za-z-])[-A-Za-z]+\s+\S+\s+\S+\s+\d+\s+\S+\s+\d+\s+\S+\s+\S+\s+\S+\s+(.+)\z/) {
	return ($2, $1);
	}
if ($line =~ /^([A-Za-z-])[-A-Za-z]+\s+\S+\s+\d+\s+\S+\s+\S+\s+(.+)\z/) {
	return ($2, $1);
	}
if ($line =~ /^([A-Za-z-])[-A-Za-z]+\s+\S+\s+\S+\s+\d+\s+\S+\s+\S+\s+(.+)\z/) {
	return ($2, $1);
	}
return ($line, '-');
}

# grub2_validate_extracted_theme_tree(dir)
# Rejects unsafe extracted files before copying to /boot.
sub grub2_validate_extracted_theme_tree
{
my ($dir) = @_;
my $root = eval { abs_path($dir) };
return &text('defaults_edir', $dir) if (!$root);
my $err;
find({
	no_chdir => 1,
	wanted => sub {
		return if ($err);
		my $path = $File::Find::name;
		return if ($path eq $dir);
		my @st = lstat($path);
		if (!@st || (!-d _ && !&grub2_theme_regular_source($path,
								   $root))) {
			$err = &text('defaults_etheme_member', $path);
			}
		},
	}, $dir);
return $err;
}

# grub2_find_theme_file(dir)
# Finds the most likely theme.txt under a source directory.
sub grub2_find_theme_file
{
my ($dir) = @_;
my @themes;
find({
	no_chdir => 1,
	wanted => sub {
		push(@themes, $File::Find::name)
			if (-f $File::Find::name &&
			    basename($File::Find::name) eq 'theme.txt');
		},
	}, $dir);
@themes = sort {
	my $ad = () = $a =~ /\//g;
	my $bd = () = $b =~ /\//g;
	$ad <=> $bd || length($a) <=> length($b) || $a cmp $b;
	} @themes;
return $themes[0] || '';
}

# grub2_install_theme_directory(source-dir, source-label)
# Copies one theme directory into the configured GRUB theme directory.
sub grub2_install_theme_directory
{
my ($srcdir, $label) = @_;
my $theme_dir = &grub2_theme_dir();
return ('', &text('defaults_econfigpath', $theme_dir))
	if ($theme_dir !~ m{^/});
make_path($theme_dir, { mode => oct("0755") }) if (!-d $theme_dir);
return ('', &text('defaults_edir', $theme_dir)) if (!-d $theme_dir);
my $theme_file = "$srcdir/theme.txt";
return ('', $text{'defaults_etheme_notfound'}) if (!-r $theme_file);
return ($theme_file) if (&grub2_path_is_under($theme_file, $theme_dir));

my $name = &grub2_safe_theme_name(basename($srcdir));
if ($name eq '' || $name =~ /^grub2-theme-(?:download|extract)/) {
	$name = &grub2_safe_theme_name(&grub2_theme_source_name($label));
	}
$name = 'webmin-theme' if ($name eq '');
my $dest = &grub2_unique_theme_destination($theme_dir, $name);
my $err = &grub2_copy_theme_tree($srcdir, $dest);
if ($err) {
	remove_tree($dest) if (-d $dest);
	return ('', $err);
	}
return ("$dest/theme.txt");
}

# grub2_theme_source_name(source-label)
# Returns a useful theme name from a source path or URL.
sub grub2_theme_source_name
{
my ($label) = @_;
$label = '' if (!defined($label));
$label =~ s/[?#].*\z//;
if ($label =~ m{/theme\.txt\z}i) {
	$label =~ s{/theme\.txt\z}{};
	}
my $name = basename($label);
$name =~ s/\.(?:tar\.gz|tar\.bz2|tar\.xz|tgz|tbz2|txz|tar|zip)\z//i;
$name =~ s/\.theme\z//i;
return $name;
}

# grub2_safe_theme_name(name)
# Normalizes a directory name for installing under the GRUB theme directory.
sub grub2_safe_theme_name
{
my ($name) = @_;
$name = '' if (!defined($name));
$name =~ s/^\s+|\s+\z//g;
$name =~ s/[^A-Za-z0-9._+-]+/_/g;
$name =~ s/\A[._-]+//;
$name =~ s/[._-]+\z//;
return $name;
}

# grub2_unique_theme_destination(parent, name)
# Returns a non-existing destination directory for a copied theme.
sub grub2_unique_theme_destination
{
my ($parent, $name) = @_;
my $dest = "$parent/$name";
return $dest if (!-e $dest);
for (my $i = 1; $i < 1000; $i++) {
	my $try = "$parent/$name-$i";
	return $try if (!-e $try);
	}
return "$parent/$name-".time();
}

# grub2_path_is_under(path, parent)
# Returns true if a path is already below a parent directory.
sub grub2_path_is_under
{
my ($path, $parent) = @_;
my $p = eval { abs_path($path) };
my $d = eval { abs_path($parent) };
return 0 if (!$p || !$d);
return $p eq $d || index($p, $d.'/') == 0 ? 1 : 0;
}

# grub2_copy_theme_tree(source, destination)
# Copies regular theme files into a new GRUB-readable directory.
sub grub2_copy_theme_tree
{
my ($src, $dest) = @_;
my $src_abs = eval { abs_path($src) };
return &text('defaults_etheme_file', $src) if (!$src_abs);
my $err;
find({
	no_chdir => 1,
	wanted => sub {
		return if ($err);
		my $path = $File::Find::name;
		my $rel = substr($path, length($src_abs));
		$rel =~ s{\A/}{};
		return if ($rel eq '');
		if ($rel =~ m{(?:\A|/)\.\.(?:/|\z)}) {
			$err = &text('defaults_etheme_member', $rel);
			return;
			}
		my $target = "$dest/$rel";
		my @st = lstat($path);
		if (!@st) {
			$err = &text('defaults_etheme_member', $rel);
			return;
			}
		if (-d _) {
			make_path($target, { mode => oct("0755") });
			return;
			}
		my $source = &grub2_theme_regular_source($path, $src_abs);
		if (!$source) {
			$err = &text('defaults_etheme_member', $rel);
			return;
			}
		my $tdir = &grub2_dirname($target);
		make_path($tdir, { mode => oct("0755") }) if (!-d $tdir);
		my $in;
		if (!CORE::open($in, '<', $source)) {
			$err = "$source : $!";
			return;
		}
		CORE::binmode($in);
		open_tempfile(my $out, ">$target");
		my $buf;
		while (read($in, $buf, 32768)) {
			print_tempfile($out, $buf);
			}
		if (!close($in)) {
			$err = "$path : $!";
			}
		close_tempfile($out);
		chmod(oct("0644"), $target);
		},
	}, $src_abs);
return $err;
}

# grub2_theme_regular_source(path, source-root)
# Returns a safe regular source file, dereferencing in-tree symlinks only.
sub grub2_theme_regular_source
{
my ($path, $root) = @_;
my @st = lstat($path);
return if (!@st);
if (-l _) {
	my $real = eval { abs_path($path) };
	return if (!$real || !&grub2_resolved_path_is_under($real, $root));
	return $real if (-f $real);
	return;
	}
return $path if (-f _);
return;
}

# grub2_resolved_path_is_under(path, resolved-parent)
# Returns true if a resolved path is below a resolved parent directory.
sub grub2_resolved_path_is_under
{
my ($path, $parent) = @_;
return 0 if (!defined($path) || !defined($parent) ||
	     $path eq '' || $parent eq '');
return $path eq $parent || index($path, $parent.'/') == 0 ? 1 : 0;
}

# read_grub_defaults([file])
# Reads and parses a GRUB default settings file.
sub read_grub_defaults
{
my ($file) = @_;
$file ||= &grub2_config_value('default_file');
my $data = '';
if ($file && -r $file) {
	$data = &read_file_contents($file);
	}
return &parse_grub_defaults_text($data, $file);
}

# parse_grub_defaults_text(text, [file])
# Parses shell-style GRUB default assignments while preserving all lines.
sub parse_grub_defaults_text
{
my ($data, $file) = @_;
$data = '' if (!defined($data));
$data =~ s/\r\n/\n/g;
$data =~ s/\r/\n/g;
my @lines = split(/\n/, $data);
my @parsed;
my %values;
my %assignments;
for (my $i = 0; $i < @lines; $i++) {
	my $line = $lines[$i];
	my $entry = { 'raw' => $line, 'line' => $i };
	if ($line =~ /^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/) {
		# Preserve formatting metadata so saving can make minimal diffs.
		my ($indent, $export, $key, $rest) = ($1, $2 || '', $3, $4);
		my ($raw_value, $comment) = &split_shell_comment($rest);
		$entry->{'type'} = 'assignment';
		$entry->{'indent'} = $indent;
		$entry->{'export'} = $export ? 1 : 0;
		$entry->{'key'} = $key;
		$entry->{'raw_value'} = $raw_value;
		$entry->{'comment'} = $comment;
		$entry->{'value'} = &decode_shell_value($raw_value);
		$values{$key} = $entry->{'value'};
		push(@{$assignments{$key}}, $entry);
	}
else {
	# Comments and unknown shell code are carried through unchanged.
	$entry->{'type'} = 'raw';
	}
	push(@parsed, $entry);
	}
return {
	'file' => $file,
	'lines' => \@parsed,
	'values' => \%values,
	'assignments' => \%assignments,
};
}

# split_shell_comment(text)
# Splits a shell assignment value from a trailing unquoted comment.
sub split_shell_comment
{
my ($text) = @_;
my $quote = '';
my $escape = 0;
for (my $i = 0; $i < length($text); $i++) {
	my $ch = substr($text, $i, 1);
	if ($escape) {
		# Inside double quotes, escaped characters cannot start comments.
		$escape = 0;
		next;
		}
	if ($quote eq '"') {
		if ($ch eq '\\') {
			$escape = 1;
			}
		elsif ($ch eq '"') {
			$quote = '';
			}
		next;
		}
	if ($quote eq "'") {
		$quote = '' if ($ch eq "'");
		next;
		}
	if ($ch eq '"' || $ch eq "'") {
		$quote = $ch;
		next;
		}
	if ($ch eq '#') {
		my $before = $i == 0 ? '' : substr($text, $i - 1, 1);
		if ($i == 0 || $before =~ /\s/) {
			# Shell comments start at # only when it begins a word.
			my $value = substr($text, 0, $i);
			my $comment = substr($text, $i);
			$value =~ s/\s+\z//;
			return ($value, $comment);
			}
		}
	}
$text =~ s/\s+\z//;
return ($text, '');
}

# decode_shell_value(value)
# Returns a display value for a simple shell assignment value.
sub decode_shell_value
{
my ($value) = @_;
$value = '' if (!defined($value));
$value =~ s/^\s+|\s+\z//g;
if ($value =~ /^'(.*)'\z/s) {
	return $1;
	}
if ($value =~ /^"(.*)"\z/s) {
	my $inner = $1;
	$inner =~ s/\\(["\\\$`])/$1/g;
	return $inner;
	}
return $value;
}

# format_shell_value(key, value)
# Formats a Perl string as a conservative shell assignment value.
sub format_shell_value
{
my ($key, $value) = @_;
$value = '' if (!defined($value));
if ($key eq 'GRUB_TIMEOUT' && $value =~ /^-?\d+\z/) {
	# Numeric timeout values should remain bare for readability.
	return $value;
	}
if ($key =~ /^GRUB_DISABLE_/ && $value =~ /^(true|false)\z/) {
	# GRUB boolean defaults are conventionally unquoted.
	return $value;
	}
if ($value =~ /^[A-Za-z0-9_.,:\/+=-]+\z/) {
	return $value;
	}
$value =~ s/(["\\\$`])/\\$1/g;
return '"'.$value.'"';
}

# format_grub_assignment(&line, key, value)
# Returns one shell assignment line, preserving indentation and export.
sub format_grub_assignment
{
my ($line, $key, $value) = @_;
my $prefix = $line && defined($line->{'indent'}) ? $line->{'indent'} : '';
$prefix .= 'export ' if ($line && $line->{'export'});
my $comment = $line && defined($line->{'comment'}) && $line->{'comment'} ne ''
	? ' '.$line->{'comment'} : '';
return $prefix.$key.'='.&format_shell_value($key, $value).$comment;
}

# set_grub_default_values(&parsed, &updates)
# Returns full default-file text with selected assignments updated.
sub set_grub_default_values
{
my ($parsed, $updates) = @_;
my %seen;
my @out;
foreach my $line (@{$parsed->{'lines'}}) {
	my $key = $line->{'key'};
	# Older Webmin blocks are removed instead of perpetuated.
	next if (($line->{'raw'} || '') =~ /^\s*#\s*Added by Webmin\s*\z/);
	if ($line->{'type'} eq 'assignment' && exists($updates->{$key})) {
		$seen{$key} = 1;
		if (defined($updates->{$key})) {
			# Update the first matching assignment in place.
			push(@out, &format_grub_assignment($line, $key,
							   $updates->{$key}));
			}
		next;
		}
	push(@out, $line->{'raw'});
	}
my @missing = grep { exists($updates->{$_}) && !$seen{$_} &&
		     defined($updates->{$_}) } &grub2_default_keys();
if (@missing) {
	push(@out, '') if (@out && $out[-1] ne '');
	foreach my $key (@missing) {
		# Append settings not already present, in the module's stable order.
		push(@out, &format_grub_assignment(undef, $key,
						   $updates->{$key}));
		}
	}
pop(@out) while (@out && $out[-1] eq '');
return join("\n", @out)."\n";
}

# validate_grub_defaults_text(text, [file])
# Validates the shell syntax of a GRUB default settings file.
sub validate_grub_defaults_text
{
my ($data, $file) = @_;
$file ||= &grub2_config_value('default_file') || '/etc/default/grub';
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
my $dir = &grub2_dirname($file);
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
my ($temp, $terr) = &grub2_make_temp_file($dir, 'defaults', $data);
return $terr if ($terr);
my ($out, $failed);
my $die;
eval {
	my $shell = &grub2_command('shell_cmd') ||
		    &grub2_config_value('shell_cmd') || '/bin/sh';
	$out = &backquote_command(
		quotemeta($shell).' -n '.quotemeta($temp).
		' 2>&1 </dev/null');
	$failed = $?;
	1;
	} || do { $die = $@ || $!; };
&grub2_unlink_temp($temp);
return $die if ($die);
if ($failed) {
	$out =~ s/^\s+|\s+\z//g if (defined($out));
	return $out || 'shell syntax check failed';
	}
return;
}

# save_grub_defaults_values(&updates)
# Validates and writes selected GRUB default settings.
sub save_grub_defaults_values
{
my ($updates) = @_;
my $file = &grub2_config_value('default_file');
my $parsed = &read_grub_defaults($file);
my $data = &set_grub_default_values($parsed, $updates);
my $err = &validate_grub_defaults_text($data, $file);
return $err if ($err);
open_lock_tempfile(my $fh, ">$file");
print_tempfile($fh, $data);
close_tempfile($fh);
return;
}

# grub2_manual_files()
# Returns descriptors for files allowed in the manual editor.
sub grub2_manual_files
{
my @files;
my %seen;
my $default_file = &grub2_config_value('default_file');
my $custom_file = &grub2_config_value('custom_file');
# Always include the primary structured files first for predictable menus.
&add_grub2_manual_file(\@files, \%seen, 'default_file', $default_file,
		       'default');
&add_grub2_manual_file(\@files, \%seen, 'custom_file', $custom_file,
		       'custom');

my $grub_dir = &grub2_config_value('grub_dir') || '';
if ($grub_dir ne '' && -d $grub_dir && opendir(my $dh, $grub_dir)) {
	foreach my $base (sort readdir($dh)) {
		# Hide dotfiles and only expose regular generator/text files.
		next if ($base =~ /^\./);
		my $file = "$grub_dir/$base";
		next if (!&grub2_manual_file_safe($file, $grub_dir, 1));
		my $type = defined($custom_file) && $file eq $custom_file ? 'custom' :
			   (-x $file || $base =~ /^\d+_/) ? 'grub_script' :
			   'text';
		&add_grub2_manual_file(\@files, \%seen, 'grub_dir', $file,
				       $type);
		}
	closedir($dh);
	}

my $bls_dir = &grub2_config_value('bls_dir') || '';
if ($bls_dir ne '' && -d $bls_dir && opendir(my $dh, $bls_dir)) {
	foreach my $base (sort readdir($dh)) {
		# Disabled rescue files deliberately do not appear in the editor.
		next if ($base =~ /^\./ || $base !~ /\.conf\z/);
		my $file = "$bls_dir/$base";
		next if (!&grub2_manual_file_safe($file, $bls_dir, 1));
		&add_grub2_manual_file(\@files, \%seen, 'bls_dir', $file,
				       'bls');
		}
	closedir($dh);
	}

return @files;
}

# add_grub2_manual_file(&files, &seen, key, file, type)
# Adds one allowlisted file descriptor, preserving first-seen ordering.
sub add_grub2_manual_file
{
my ($files, $seen, $key, $file, $type) = @_;
return if (!defined($file) || $file eq '' || $seen->{$file}++);
return if (!&grub2_manual_file_safe($file, undef, 0));
push(@$files, { 'key' => $key, 'file' => $file, 'type' => $type });
}

# grub2_manual_file_safe(file, [parent-dir], must-exist?)
# Returns true if a path is safe for the manual editor allowlist.
sub grub2_manual_file_safe
{
my ($file, $parent, $must_exist) = @_;
return 0 if (!defined($file) || $file eq '');
my @st = lstat($file);
return $must_exist ? 0 : 1 if (!@st);
# Symlinks would be followed by the Webmin write path, so reject them here.
return 0 if (-l _ || !-f _);
return $parent ? &grub2_path_is_under($file, $parent) : 1;
}

# grub2_manual_file(file)
# Returns the manual-edit descriptor for an allowed file path.
sub grub2_manual_file
{
my ($file) = @_;
foreach my $f (&grub2_manual_files()) {
	return $f if ($f->{'file'} eq $file);
	}
return;
}

# validate_manual_grub_file(file, data)
# Validates a manually edited GRUB file where a safe validator exists.
sub validate_manual_grub_file
{
my ($file, $data) = @_;
my $info = &grub2_manual_file($file);
return $text{'manual_efile'} if (!$info);
if ($info->{'type'} eq 'default') {
	# /etc/default/grub is shell syntax, so validate with sh.
	return &validate_grub_defaults_text($data, $file);
	}
if ($info->{'type'} eq 'grub_script') {
	# /etc/grub.d scripts are shell fragments executed by grub-mkconfig.
	return &validate_grub_defaults_text($data, $file);
	}
if ($info->{'type'} eq 'custom') {
	# Wrap custom menuentry bodies before running grub-script-check.
	return &grub2_validate_grub_script_text(
		&grub2_custom_script_text($data), $file);
	}
if ($info->{'type'} eq 'bls') {
	return &validate_bls_entry_text($data);
	}
return;
}

# validate_bls_entry_text(text)
# Validates the key/value syntax used by Boot Loader Specification entries.
sub validate_bls_entry_text
{
my ($data) = @_;
$data = '' if (!defined($data));
return $text{'manual_ebls'} if ($data =~ /\0/);
my $seen;
my $line_no = 0;
foreach my $line (split(/\n/, $data, -1)) {
	$line_no++;
	next if ($line =~ /^\s*(?:#|\z)/);
	# BLS lines are simple keys followed by a non-empty value.
	if ($line !~ /^\s*[A-Za-z0-9_.-]+\s+\S/) {
		return &text('manual_eblsline', $line_no);
		}
	$seen = 1;
	}
return $text{'manual_ebls'} if (!$seen);
return;
}

# save_manual_grub_file(file, data)
# Writes one allowlisted GRUB file from the manual editor.
sub save_manual_grub_file
{
my ($file, $data) = @_;
my $info = &grub2_manual_file($file);
return $text{'manual_efile'} if (!$info);
my $err = &validate_manual_grub_file($file, $data);
return $err if ($err);
if ($info->{'type'} eq 'custom') {
	# Custom saves preserve executable mode and use the custom writer path.
	return &grub2_with_file_lock($file, sub {
		&grub2_write_custom_file($file, $data);
		return;
		});
	}
open_lock_tempfile(my $fh, ">$file");
print_tempfile($fh, $data);
close_tempfile($fh);
return;
}

# grub2_read_security_config([file])
# Returns the current Webmin-managed GRUB password state.
sub grub2_read_security_config
{
my ($file) = @_;
$file ||= &grub2_password_file();
my %rv = (
	'file' => $file,
	'exists' => -e $file ? 1 : 0,
	'managed' => 1,
	'enabled' => 0,
	'user' => 'root',
	'hash' => '',
	);
return \%rv if (!-e $file);
if (!-r $file) {
	# Existing but unreadable files are treated as unmanaged for safety.
	$rv{'managed'} = 0;
	$rv{'unreadable'} = 1;
	return \%rv;
	}
my $data = &read_file_contents($file);
if ($data !~ /Webmin managed GRUB password protection/) {
	# Do not parse or overwrite administrator-owned password scripts.
	$rv{'managed'} = 0;
	return \%rv;
	}
if ($data =~ /^\s*password_pbkdf2\s+((?:"(?:\\.|[^"])*")|(?:'[^']*')|\S+)\s+(grub\.pbkdf2\.[A-Za-z0-9.]+)\s*$/m) {
	# Enabled state requires both a superuser token and a PBKDF2 hash.
	my ($user) = &parse_grub_word($1);
	$rv{'enabled'} = 1;
	$rv{'user'} = $user if (defined($user) && $user ne '');
	$rv{'hash'} = $2;
	}
elsif ($data =~ /^\s*set\s+superusers=((?:"(?:\\.|[^"])*")|(?:'[^']*')|\S+)/m) {
	my ($user) = &parse_grub_word($1);
	$rv{'user'} = $user if (defined($user) && $user ne '');
	}
return \%rv;
}

# grub2_save_security_config(&settings)
# Saves the Webmin-managed GRUB password protection script.
sub grub2_save_security_config
{
my ($settings) = @_;
my $file = &grub2_password_file();
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
my $dir = &grub2_dirname($file);
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
my $current = &grub2_read_security_config($file);
return $text{'security_eunmanaged'}
	if ($current->{'exists'} && !$current->{'managed'});

my $enabled = $settings->{'enabled'} ? 1 : 0;
my $user = defined($settings->{'user'}) && $settings->{'user'} ne '' ?
	$settings->{'user'} : ($current->{'user'} || 'root');
my $hash = $current->{'hash'} || '';

if ($enabled) {
	my $err = &grub2_validate_security_user($user);
	return $err if ($err);
	my $newhash = $settings->{'hash'} || '';
	my $pass = $settings->{'password'} || '';
	my $pass2 = $settings->{'password2'} || '';
	$newhash = '' if ($newhash ne '' && $newhash eq $hash);
	if ($newhash ne '' && ($pass ne '' || $pass2 ne '')) {
		# Avoid ambiguity between pasted hashes and newly entered passwords.
		return $text{'security_epassmode'};
		}
	if ($newhash ne '') {
		# A pasted PBKDF2 hash replaces the stored hash without clear text.
		$err = &grub2_validate_password_hash($newhash);
		return $err if ($err);
		$hash = $newhash;
		}
	elsif ($pass ne '' || $pass2 ne '') {
		# Generate a fresh PBKDF2 hash only when password fields are used.
		($hash, $err) = &grub2_make_password_hash($pass, $pass2);
		return $err if ($err);
		}
	return $text{'security_epass'} if ($hash eq '');
	}
return if (!$enabled && !$current->{'exists'});

my $data = &grub2_format_password_script($enabled, $user, $hash);
my $err = &validate_grub_defaults_text($data, $file);
return $err if ($err);
if ($enabled) {
	# Validate the emitted GRUB commands separately from the shell wrapper.
	$err = &grub2_validate_grub_script_text(
		&grub2_format_password_grub_script($user, $hash), $file);
	return $err if ($err);
	}
return &grub2_with_file_lock($file, sub {
	&grub2_write_password_file($file, $data);
	return;
	});
}

# grub2_validate_security_user(user)
# Returns an error if a GRUB superuser name is unsafe.
sub grub2_validate_security_user
{
my ($user) = @_;
$user = '' if (!defined($user));
return $text{'security_euser'}
	if ($user eq '' || $user =~ /[\r\n\0]/ ||
	    $user !~ /\A[A-Za-z0-9_.@+-]+\z/ || $user =~ /^-/);
return;
}

# grub2_validate_password_hash(hash)
# Returns an error if a pasted GRUB PBKDF2 hash is unsafe.
sub grub2_validate_password_hash
{
my ($hash) = @_;
$hash = '' if (!defined($hash));
return $text{'security_ehash'}
	if ($hash !~ /\Agrub\.pbkdf2\.[A-Za-z0-9.]+\z/);
return;
}

# grub2_make_password_hash(password, confirmation)
# Runs grub-mkpasswd-pbkdf2 and returns (hash, error).
sub grub2_make_password_hash
{
my ($pass, $pass2) = @_;
$pass = '' if (!defined($pass));
$pass2 = '' if (!defined($pass2));
return ('', $text{'security_epass'}) if ($pass eq '');
return ('', $text{'security_epassmatch'}) if ($pass ne $pass2);
return ('', $text{'security_epasschars'}) if ($pass =~ /[\r\n\0]/);
my $cmd = &grub2_command('mkpasswd_cmd');
return ('', $text{'security_emkpasswd'}) if (!$cmd);
my $input = $pass."\n".$pass."\n";
# Feed the password twice on stdin, matching grub-mkpasswd-pbkdf2 prompts.
my $out = '';
my $outref = \$out;
my $rv = &execute_command(quotemeta($cmd), \$input, $outref, $outref, 0, 1);
if ($rv) {
	$out =~ s/^\s+|\s+\z//g;
	return ('', $out || $text{'security_ehashgen'});
	}
if ($out =~ /(grub\.pbkdf2\.[A-Za-z0-9.]+)/) {
	return ($1);
	}
return ('', $text{'security_ehashgen'});
}

# grub2_format_password_script(enabled?, user, hash)
# Returns a shell generator script managed by Webmin.
sub grub2_format_password_script
{
my ($enabled, $user, $hash) = @_;
my $header = "#!/bin/sh\n".
	"# Webmin managed GRUB password protection\n".
	"# Edit this file from Webmin's GRUB 2 module.\n";
return $header."exit 0\n" if (!$enabled);
return $header."cat <<'EOF'\n".
	&grub2_format_password_grub_script($user, $hash).
	"EOF\n";
}

# grub2_format_password_grub_script(user, hash)
# Returns GRUB commands that configure one password-protected superuser.
sub grub2_format_password_grub_script
{
my ($user, $hash) = @_;
return 'set superusers='.&grub2_quote_word($user)."\n".
       "export superusers\n".
       "password_pbkdf2 $user $hash\n";
}

# grub2_write_password_file(file, data)
# Writes the managed password script with root-only execute permissions.
sub grub2_write_password_file
{
my ($file, $data) = @_;
open_tempfile(my $fh, ">$file");
print_tempfile($fh, $data);
close_tempfile($fh);
chmod(oct("0700"), $file);
return;
}

# grub2_save_color_script()
# Saves the Webmin-managed generator script for GRUB menu colors.
sub grub2_save_color_script
{
my $file = &grub2_color_file();
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
my $dir = &grub2_dirname($file);
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
if (-e $file) {
	return $text{'defaults_ecolorfile'} if (!-r $file);
	my $current = &read_file_contents($file);
	# Never overwrite administrator-owned GRUB generator scripts.
	return $text{'defaults_ecolorfile'}
		if ($current !~ /Webmin managed GRUB menu colors/);
	}
my $data = &grub2_format_color_script();
my $err = &validate_grub_defaults_text($data, $file);
return $err if ($err);
return &grub2_with_file_lock($file, sub {
	&grub2_write_color_file($file, $data);
	return;
	});
}

# grub2_format_color_script()
# Returns a shell generator script that emits GRUB menu color commands.
sub grub2_format_color_script
{
my $default_file = &grub2_config_value('default_file') || '/etc/default/grub';
my $source = '';
if ($default_file =~ m{^/} && $default_file !~ /[\r\n\0]/) {
	# Source defaults at generation time so color changes need one script only.
	$source = 'webmin_grub2_defaults_file='.
		  &format_shell_value('WEBMIN_GRUB2_DEFAULTS_FILE',
				      $default_file)."\n".
		  "if [ -r \"\$webmin_grub2_defaults_file\" ]; then\n".
		  "\t. \"\$webmin_grub2_defaults_file\"\n".
		  "fi\n\n";
	}
my $script = <<'EOF';
#!/bin/sh
# Webmin managed GRUB menu colors
# Reads GRUB_COLOR_NORMAL and GRUB_COLOR_HIGHLIGHT from the GRUB defaults file.

EOF
$script .= $source;
$script .= <<'EOF';

webmin_grub2_emit_color()
{
	name=$1
	value=$2
	case "$value" in
		''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_/-]*|*/*/*)
			return
			;;
	esac
	case "$value" in
		*/*)
			printf 'set %s=%s\n' "$name" "$value"
			;;
	esac
}

webmin_grub2_emit_color color_normal "$GRUB_COLOR_NORMAL"
webmin_grub2_emit_color color_highlight "$GRUB_COLOR_HIGHLIGHT"
webmin_grub2_emit_color menu_color_normal "$GRUB_COLOR_NORMAL"
webmin_grub2_emit_color menu_color_highlight "$GRUB_COLOR_HIGHLIGHT"
EOF
return $script;
}

# grub2_write_color_file(file, data)
# Writes the managed color generator script with executable permissions.
sub grub2_write_color_file
{
my ($file, $data) = @_;
open_tempfile(my $fh, ">$file");
print_tempfile($fh, $data);
close_tempfile($fh);
chmod(oct("0755"), $file);
return;
}

# grub2_boot_entries([file])
# Parses generated grub.cfg menuentry and submenu lines.
sub grub2_boot_entries
{
my ($file) = @_;
$file ||= &grub2_config_value('grub_cfg');
return () if (!$file || !-r $file);
my $data = &read_file_contents($file);
$data =~ s/\r\n/\n/g;
$data =~ s/\r/\n/g;
my @lines = split(/\n/, $data);
my @entries;
my @submenus;
my $section_file = $file;
my $depth = 0;
for (my $i = 0; $i < @lines; $i++) {
	my $line = $lines[$i];
	if ($line =~ /^### BEGIN (.+) ###\s*\z/) {
		# grub-mkconfig annotates which generator script emitted following lines.
		$section_file = $1;
		}
	elsif ($line =~ /^### END /) {
		$section_file = $file;
		}
	elsif ($line =~ /^\s*blscfg\s*$/) {
		# blscfg imports BLS files at this position in the generated menu.
		my @path = map { $_->{'title'} } @submenus;
		push(@entries, &grub2_bls_entries(scalar(@entries),
						   undef, \@path));
		}
	elsif ($line =~ /^\s*submenu\s+/) {
		my ($title, $id) = &parse_grub_statement($line, 'submenu');
		my ($opens, $closes) = &count_grub_braces($line);
		# Track submenu stack depth so child entries get a stable path.
		push(@submenus, {
			'title' => $title,
			'id' => $id,
			'depth' => $depth + $opens - $closes,
		}) if (defined($title) && $opens > $closes);
		}
	elsif ($line =~ /^\s*menuentry\s+/) {
		my ($title, $id) = &parse_grub_statement($line, 'menuentry');
		if (defined($title)) {
			# Details are read from the menuentry body, not just the title line.
			my @path = map { $_->{'title'} } @submenus;
			my $end = &grub2_statement_block_end(\@lines, $i);
			my %details = &grub2_menuentry_details(\@lines, $i,
							       $end);
			push(@entries, {
				'index' => scalar(@entries),
				'title' => $title,
				'id' => $id,
				'line' => $i + 1,
				'path' => \@path,
				'source_file' => $section_file,
				%details,
			});
			}
		}
	my ($opens, $closes) = &count_grub_braces($line);
	$depth += $opens - $closes;
	while (@submenus && $depth < $submenus[-1]->{'depth'}) {
		# Drop submenu context once its closing brace has been passed.
		pop(@submenus);
		}
	$depth = 0 if ($depth < 0);
	}
return @entries;
}

# grub2_menuentry_details(&lines, start-index, end-index)
# Extracts kernel and initrd details from one generated menuentry block.
sub grub2_menuentry_details
{
my ($lines, $start, $end) = @_;
my %details;
for (my $i = $start + 1; $i < $end; $i++) {
	my $line = $lines->[$i];
	if ($line =~ /^\s*linux(?:efi|16)?\s+(.+?)\s*\z/) {
		# The first linux line gives the kernel path and remaining arguments.
		my ($kernel, $opts) = &parse_grub_word($1);
		$details{'linux'} = $kernel
			if (defined($kernel) && $kernel ne '' &&
			    !defined($details{'linux'}));
		$opts =~ s/^\s+|\s+\z//g if (defined($opts));
		$details{'options'} = $opts
			if (defined($opts) && $opts ne '' &&
			    !defined($details{'options'}));
		}
	elsif ($line =~ /^\s*initrd(?:efi|16)?\s+(.+?)\s*\z/) {
		# Preserve multiple initrd words as a single displayed value.
		my $rest = $1;
		my ($initrd, $extra) = &parse_grub_word($rest);
		$extra =~ s/^\s+|\s+\z//g if (defined($extra));
		$details{'initrd'} = defined($extra) && $extra ne '' ?
			$rest : $initrd
			if (defined($initrd) && $initrd ne '' &&
			    !defined($details{'initrd'}));
		}
	}
if (!defined($details{'version'}) && defined($details{'linux'})) {
	my $version = &grub2_kernel_version_from_path($details{'linux'});
	$details{'version'} = $version if ($version);
	}
return %details;
}

# grub2_kernel_version_from_path(path)
# Returns the kernel version embedded in common Linux image filenames.
sub grub2_kernel_version_from_path
{
my ($kernel) = @_;
return if (!defined($kernel) || $kernel eq '');
return $1 if ($kernel =~ m{(?:\A|/)(?:vmlinuz|vmlinux|kernel|bzImage)-(.+)\z});
return;
}

# grub2_bls_entries([start-index], [dir], [&submenu-path])
# Parses Boot Loader Specification entries used by RHEL-style GRUB configs.
sub grub2_bls_entries
{
my ($start_index, $dir, $path) = @_;
$start_index ||= 0;
$dir ||= &grub2_config_value('bls_dir');
return () if (!$dir || !-d $dir);
opendir(my $dh, $dir) || return ();
my @files = sort grep { /\.conf\z/ && -r "$dir/$_" } readdir($dh);
closedir($dh);
my @entries;
foreach my $file (@files) {
	my $entry = &parse_bls_entry("$dir/$file", $file, $path);
	next if (!$entry);
	push(@entries, $entry);
	}
# GRUB shows BLS kernels newest-first, unlike lexical filename order.
@entries = &grub2_sort_bls_entries(@entries);
for (my $i = 0; $i < @entries; $i++) {
	$entries[$i]->{'index'} = $start_index + $i;
	}
return @entries;
}

# grub2_has_bls_rescue_entries([&entries])
# Returns true if the generated menu contains BLS rescue entries.
sub grub2_has_bls_rescue_entries
{
my ($entries) = @_;
$entries ||= [ &grub2_boot_entries() ];
foreach my $entry (@$entries) {
	return 1 if (&grub2_entry_is_bls_rescue($entry));
	}
return 0;
}

# grub2_entry_is_bls_rescue(&entry)
# Returns true if a parsed BLS entry is a rescue entry.
sub grub2_entry_is_bls_rescue
{
my ($entry) = @_;
return 0 if (($entry->{'source'} || '') ne 'bls');
return 1 if (($entry->{'version'} || '') =~ /^0-rescue(?:-|\z)/);
return 1 if (($entry->{'filename'} || '') =~ /(?:^|-)0-rescue(?:-|\.conf\z)/);
return 1 if (($entry->{'title'} || '') =~ /\b0-rescue-/);
return 0;
}

# grub2_has_non_bls_recovery_entries([&entries])
# Returns true if generated/custom entries look like recovery or rescue entries.
sub grub2_has_non_bls_recovery_entries
{
my ($entries) = @_;
$entries ||= [ &grub2_boot_entries() ];
foreach my $entry (@$entries) {
	next if (($entry->{'source'} || '') eq 'bls');
	my $title = $entry->{'title'} || '';
	my $path = join(' ', @{$entry->{'path'} || []});
	return 1 if ($title =~ /\b(?:recovery|rescue)\b/i ||
		     $path =~ /\b(?:recovery|rescue)\b/i);
	}
return 0;
}

# grub2_disabled_bls_rescue_files([dir])
# Returns BLS rescue entries hidden by Webmin.
sub grub2_disabled_bls_rescue_files
{
my ($dir) = @_;
$dir ||= &grub2_config_value('bls_dir');
return () if (!$dir || !-d $dir);
opendir(my $dh, $dir) || return ();
my $suffix = &grub2_disabled_bls_rescue_suffix();
my @bases = sort grep { /\Q$suffix\E\z/ && -r "$dir/$_" } readdir($dh);
closedir($dh);
my @files;
foreach my $base (@bases) {
	my $file = "$dir/$base";
	my $entry = &parse_bls_entry($file, $base, []);
	push(@files, $file)
		if ($entry && &grub2_entry_is_bls_rescue($entry));
	}
return @files;
}

# grub2_set_bls_rescue_disabled(disabled?, [&entries])
# Hides or restores BLS rescue entries without deleting their files.
sub grub2_set_bls_rescue_disabled
{
my ($disabled, $entries) = @_;
my $suffix = &grub2_disabled_bls_rescue_suffix();
my @moves;
if ($disabled) {
	# Hiding renames BLS rescue files so GRUB's blscfg no longer sees them.
	$entries ||= [ &grub2_boot_entries() ];
	my %seen;
	foreach my $entry (@$entries) {
		next if (!&grub2_entry_is_bls_rescue($entry));
		my $from = $entry->{'file'} || '';
		next if ($from eq '' || !-e $from || $seen{$from}++);
		my $to = $from.$suffix;
		return &text('defaults_ebls_rescue_exists', $from, $to)
			if (-e $to);
		push(@moves, [ $from, $to ]);
		}
	}
else {
	# Restoring reverses our suffix only when the original target is free.
	foreach my $from (&grub2_disabled_bls_rescue_files()) {
		my $to = $from;
		$to =~ s/\Q$suffix\E\z//;
		next if ($to eq $from || -e $to);
		push(@moves, [ $from, $to ]);
		}
	}
foreach my $move (@moves) {
	my ($from, $to) = @$move;
	# Lock both names so Webmin logging can capture the rename safely.
	&lock_file($from);
	&lock_file($to);
	my $ok = &rename_file($from, $to);
	my $err = "$!";
	&unlock_file($to);
	&unlock_file($from);
	return &text('defaults_ebls_rescue_move', $from, $to, $err)
		if (!$ok);
	}
return;
}

# grub2_disabled_bls_rescue_suffix()
# Returns suffix used for BLS rescue entries hidden by Webmin.
sub grub2_disabled_bls_rescue_suffix
{
return ".disabled";
}

# grub2_kernel_options_source_keys([&entries])
# Returns detected sources for Linux kernel command line options.
sub grub2_kernel_options_source_keys
{
my ($entries) = @_;
$entries ||= [ &grub2_boot_entries() ];
my %sources;
foreach my $entry (@$entries) {
	my $opts = $entry->{'options'} || '';
	if (($entry->{'source'} || '') eq 'bls') {
		# BLS entries can contain direct options or defer to grubenv kernelopts.
		if (&grub2_entry_uses_kernelopts($entry)) {
			$sources{'kernelopts'} = 1;
			}
		else {
			$sources{'bls'} = 1;
			}
		}
	elsif (($entry->{'linux'} || '') ne '' || $opts ne '') {
		$sources{'defaults'} = 1;
		}
	}
return grep { $sources{$_} } qw(kernelopts bls defaults);
}

# grub2_entry_uses_kernelopts(&entry)
# Returns true when a BLS entry defers kernel options to grubenv.
sub grub2_entry_uses_kernelopts
{
my ($entry) = @_;
my $opts = $entry->{'options'} || '';
return $opts =~ /(?:^|\s)(?:\$kernelopts|\$\{kernelopts\})(?:\s|\z)/ ?
	1 : 0;
}

# grub2_kernel_options_source_text([&entries])
# Returns localized text for detected Linux kernel option sources.
sub grub2_kernel_options_source_text
{
my ($entries) = @_;
my @keys = &grub2_kernel_options_source_keys($entries);
return $text{'index_not_set'} if (!@keys);
return join(', ',
	    map { $text{'index_kernel_options_source_'.$_} || $_ } @keys);
}

# grub2_bls_kernel_option_warnings([&entries], [&env])
# Returns warnings for BLS entries whose kernel options live outside defaults.
sub grub2_bls_kernel_option_warnings
{
my ($entries, $env) = @_;
$entries ||= [ &grub2_boot_entries() ];
if (!$env) {
	my %read_env = &grub2_read_env();
	$env = \%read_env;
	}
my @sources = &grub2_kernel_options_source_keys($entries);
return () if (!grep { $_ eq 'kernelopts' || $_ eq 'bls' } @sources);
my @warnings;
# Direct BLS options need grubby to keep existing entries in sync.
push(@warnings, $text{'index_warn_bls_options'})
	if ((grep { $_ eq 'bls' } @sources) &&
	    !&grub2_bls_update_available($entries));
if (grep { $_ eq 'kernelopts' } @sources) {
	# kernelopts is stored in grubenv, so report when it is missing or unmanaged.
	push(@warnings, $text{'index_warn_kernelopts_source'})
		if (!&grub2_bls_update_available($entries));
	push(@warnings, $text{'index_warn_kernelopts_missing'})
		if (!defined($env->{'kernelopts'}) ||
		    $env->{'kernelopts'} eq '');
	}
return @warnings;
}

# grub2_update_bls_kernel_args(old-args, new-args, [&kernel-targets])
# Applies changed kernel options to existing BLS entries with grubby.
sub grub2_update_bls_kernel_args
{
my ($old_args, $new_args, $targets) = @_;
my $cmd = &grub2_command('grubby_cmd');
return $text{'defaults_egrubby'} if (!$cmd);
my ($remove, $add) = &grub2_kernel_args_delta($old_args, $new_args);
return if (!@$remove && !@$add);
my @targets = $targets && @$targets ? @$targets : ('ALL');
foreach my $target (@targets) {
	# Pass only the delta so grubby preserves unrelated boot-critical args.
	my @run = (quotemeta($cmd), quotemeta('--update-kernel='.$target));
	push(@run, quotemeta('--remove-args='.join(' ', @$remove))) if (@$remove);
	push(@run, quotemeta('--args='.join(' ', @$add))) if (@$add);
	my $out = &backquote_logged(join(' ', @run).' 2>&1 </dev/null');
	if ($?) {
		$out =~ s/^\s+|\s+\z//g if (defined($out));
		return $out || $text{'defaults_egrubby_failed'};
		}
	}
return;
}

# grub2_bls_kernel_arg_targets([&entries], [include-rescue?])
# Returns grubby kernel targets for BLS entries.
sub grub2_bls_kernel_arg_targets
{
my ($entries, $include_rescue) = @_;
$entries ||= [ &grub2_boot_entries() ];
my (@targets, %seen);
foreach my $entry (@$entries) {
	next if (($entry->{'source'} || '') ne 'bls');
	# Default-only args should not be applied to rescue entries.
	next if (!$include_rescue && &grub2_entry_is_bls_rescue($entry));
	my $target = &grub2_bls_kernel_target($entry->{'linux'});
	next if ($target eq '' || $seen{$target}++);
	push(@targets, $target);
	}
return @targets;
}

# grub2_bls_kernel_target(linux-path)
# Converts a BLS linux path to the kernel path expected by grubby.
sub grub2_bls_kernel_target
{
my ($linux) = @_;
return '' if (!defined($linux) || $linux eq '');
return $linux if ($linux =~ m{^/boot/});
my $boot = &grub2_bls_boot_dir();
return $boot.$linux if ($linux =~ m{^/});
return $boot.'/'.$linux;
}

# grub2_bls_boot_dir()
# Returns the boot directory that contains configured BLS entries.
sub grub2_bls_boot_dir
{
my $bls_dir = &grub2_config_value('bls_dir') || '';
return $1 if ($bls_dir =~ m{\A(.+)/loader/entries/?\z});
my $parent = &grub2_dirname($bls_dir);
return $parent if ($parent ne '');
return '/boot';
}

# grub2_kernel_args_delta(old-args, new-args)
# Returns argument tokens to remove and add while preserving unrelated tokens.
sub grub2_kernel_args_delta
{
my ($old_args, $new_args) = @_;
my @old = &grub2_split_kernel_args($old_args);
my @new = &grub2_split_kernel_args($new_args);
my (%old, %new);
# Track full tokens so replacing foo=old with foo=new removes and adds cleanly.
$old{$_}++ foreach (@old);
$new{$_}++ foreach (@new);
my (@remove, @add);
foreach my $arg (@old) {
	next if ($new{$arg});
	push(@remove, $arg);
	}
foreach my $arg (@new) {
	next if ($old{$arg});
	push(@add, $arg);
	}
return (\@remove, \@add);
}

# grub2_split_kernel_args(args)
# Splits a Linux kernel command line using GRUB/shell-style words.
sub grub2_split_kernel_args
{
my ($args) = @_;
$args = '' if (!defined($args));
my @rv;
while (defined($args) && $args =~ /\S/) {
	my ($word, $rest) = &parse_grub_word($args);
	last if (!defined($word));
	# Empty quoted strings are ignored because kernel args are token-based.
	push(@rv, $word) if ($word ne '');
	$args = $rest;
	}
return @rv;
}

# grub2_sort_bls_entries(@entries)
# Returns BLS entries in the same newest-first order that GRUB displays.
sub grub2_sort_bls_entries
{
my @entries = @_;
my $use_version = @entries &&
	!grep { !defined($_->{'version'}) || $_->{'version'} eq '' } @entries;
my @sorted = sort {
	my $akey = $use_version ? $a->{'version'} : $a->{'filename'};
	my $bkey = $use_version ? $b->{'version'} : $b->{'filename'};
	# rpmvercmp handles kernel release components better than string compare.
	my $cmp = &grub2_rpmvercmp($akey, $bkey);
	$cmp ||= &grub2_rpmvercmp($a->{'filename'}, $b->{'filename'});
	$cmp ||= ($a->{'filename'} || '') cmp ($b->{'filename'} || '');
	$cmp;
	} @entries;
return reverse(@sorted);
}

# parse_bls_entry(file, basename, [&submenu-path])
# Parses one Boot Loader Specification entry file.
sub parse_bls_entry
{
my ($file, $base, $path) = @_;
my $data = &read_file_contents($file);
my %entry = (
	'line' => 1,
	'path' => [ @{$path || []} ],
	'source' => 'bls',
	'file' => $file,
	'filename' => $base,
	'source_file' => $file,
);
foreach my $line (split(/\r?\n/, $data || '')) {
	next if ($line =~ /^\s*(?:#|\z)/);
	if ($line =~ /^\s*([A-Za-z0-9_.-]+)\s+(.+?)\s*\z/) {
		# BLS permits repeated keys; the last one wins like most parsers.
		my ($key, $value) = ($1, $2);
		$entry{$key} = $value;
		}
	}
return if (!$entry{'title'});
$base =~ s/\.conf\z//;
$entry{'id'} ||= $base;
$entry{'title'} ||= $entry{'version'} || $entry{'id'};
return \%entry;
}

# grub2_rpmvercmp(left, right)
# Compares version-like strings with the rpmvercmp rules used by GRUB BLS.
sub grub2_rpmvercmp
{
my ($left, $right) = @_;
$left = '' if (!defined($left));
$right = '' if (!defined($right));
return 0 if ($left eq $right);
my ($la, $ra) = ($left, $right);
while ($la ne '' || $ra ne '') {
	# Skip separators; rpmvercmp compares only alphanumeric and tilde runs.
	$la =~ s/\A[^A-Za-z0-9~]+//;
	$ra =~ s/\A[^A-Za-z0-9~]+//;
	if ($la =~ /\A~/ || $ra =~ /\A~/) {
		# Tilde sorts before the empty string and before regular segments.
		return -1 if ($la =~ /\A~/ && $ra !~ /\A~/);
		return 1 if ($la !~ /\A~/ && $ra =~ /\A~/);
		$la =~ s/\A~//;
		$ra =~ s/\A~//;
		next;
		}
	last if ($la eq '' || $ra eq '');
	my $left_num = $la =~ /\A[0-9]/;
	my ($ls, $rs);
	if ($left_num) {
		# Numeric segments compare by length after trimming leading zeros.
		($ls) = $la =~ /\A([0-9]+)/;
		($rs) = $ra =~ /\A([0-9]+)/;
		return 1 if (!defined($rs));
		$la = substr($la, length($ls));
		$ra = substr($ra, length($rs));
		$ls =~ s/\A0+//;
		$rs =~ s/\A0+//;
		$ls = '0' if ($ls eq '');
		$rs = '0' if ($rs eq '');
		return length($ls) <=> length($rs)
			if (length($ls) != length($rs));
		my $cmp = $ls cmp $rs;
		return $cmp if ($cmp);
		}
	else {
		# Alphabetic segments compare lexically and sort below numeric runs.
		($ls) = $la =~ /\A([A-Za-z]+)/;
		($rs) = $ra =~ /\A([A-Za-z]+)/;
		return -1 if (!defined($rs));
		$la = substr($la, length($ls));
		$ra = substr($ra, length($rs));
		my $cmp = $ls cmp $rs;
		return $cmp if ($cmp);
		}
	}
return 0 if ($la eq '' && $ra eq '');
return $la eq '' ? -1 : 1;
}

# grub2_custom_entries([file])
# Parses editable custom GRUB menu entries with source line ranges.
sub grub2_custom_entries
{
my ($file) = @_;
$file ||= &grub2_config_value('custom_file');
return () if (!$file || !-r $file);
my $data = &read_file_contents($file);
$data =~ s/\r\n/\n/g;
$data =~ s/\r/\n/g;
my @lines = split(/\n/, $data);
return &grub2_custom_entries_from_lines(\@lines, $file);
}

# grub2_custom_entries_from_lines(&lines, file)
# Parses editable custom GRUB entries from already-read file lines.
sub grub2_custom_entries_from_lines
{
my ($lines, $file) = @_;
my (@entries, @submenus);
my $depth = 0;
for (my $i = 0; $i < @$lines; $i++) {
	my $line = $lines->[$i];
	if ($line =~ /^\s*submenu\s+/) {
		# Custom entries may be nested; keep a submenu path like grub.cfg.
		my ($title, $id) = &parse_grub_statement($line, 'submenu');
		my ($opens, $closes) = &count_grub_braces($line);
		push(@submenus, {
			'title' => $title,
			'id' => $id,
			'depth' => $depth + $opens - $closes,
		}) if (defined($title) && $opens > $closes);
		}
	elsif ($line =~ /^\s*menuentry\s+/) {
		my ($title, $id) = &parse_grub_statement($line, 'menuentry');
		if (defined($title)) {
			# Store source ranges so edits can replace exact blocks.
			my @path = map { $_->{'title'} } @submenus;
			push(@entries, {
				'custom_index' => scalar(@entries),
				'title' => $title,
				'id' => $id,
				'path' => \@path,
				'source_file' => $file,
				'start' => $i,
				'end' => &grub2_statement_block_end($lines, $i),
			});
			}
		}
	my ($opens, $closes) = &count_grub_braces($line);
	$depth += $opens - $closes;
	while (@submenus && $depth < $submenus[-1]->{'depth'}) {
		# Closing braces pop submenu context before subsequent entries.
		pop(@submenus);
		}
	$depth = 0 if ($depth < 0);
	}
return @entries;
}

# grub2_custom_entry_by_index(index)
# Returns one custom entry descriptor by non-negative custom index.
sub grub2_custom_entry_by_index
{
my ($index) = @_;
return if (!defined($index) || $index !~ /^\d+\z/);
my @entries = &grub2_custom_entries();
return if ($index >= @entries);
return $entries[$index];
}

# grub2_custom_entry_body(&entry, [file])
# Returns the editable body inside a custom menuentry block.
sub grub2_custom_entry_body
{
my ($entry, $file) = @_;
return '' if (!$entry);
$file ||= $entry->{'source_file'} || &grub2_config_value('custom_file');
return '' if (!$file || !-r $file);
my $data = &read_file_contents($file);
$data =~ s/\r\n/\n/g;
$data =~ s/\r/\n/g;
my @lines = split(/\n/, $data);
return '' if ($entry->{'end'} <= $entry->{'start'} + 1);
my @body = @lines[$entry->{'start'} + 1 .. $entry->{'end'} - 1];
@body = &grub2_unindent_custom_body(\@body);
return join("\n", @body).(@body ? "\n" : "");
}

# grub2_unindent_custom_body(&lines)
# Removes the common storage indentation from a custom entry body.
sub grub2_unindent_custom_body
{
my ($lines) = @_;
my $prefix;
foreach my $line (@$lines) {
	next if ($line !~ /\S/);
	my ($indent) = $line =~ /^([ \t]*)/;
	if (!defined($prefix)) {
		# First nonblank line sets the candidate common indentation.
		$prefix = $indent;
		}
	else {
		# Trim the prefix until every nonblank line shares it.
		while ($prefix ne '' && index($indent, $prefix) != 0) {
			chop($prefix);
			}
		}
	last if (defined($prefix) && $prefix eq '');
	}
return @$lines if (!defined($prefix) || $prefix eq '');
my @out = @$lines;
foreach my $line (@out) {
	$line =~ s/^\Q$prefix\E//;
	}
return @out;
}

# grub2_save_custom_entry(index|undef, title, id, body)
# Adds or replaces a custom GRUB menuentry in the configured custom file.
sub grub2_save_custom_entry
{
my ($index, $title, $id, $body) = @_;
my $file = &grub2_config_value('custom_file') || '';
return $text{'custom_efile'} if ($file eq '');
return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
my $dir = &grub2_dirname($file);
return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
my $err = &grub2_validate_custom_entry($title, $id, $body);
return $err if ($err);
my $entry_text = &grub2_format_custom_entry($title, $id, $body);
my @entry_lines = split(/\n/, $entry_text);
return &grub2_with_file_lock($file, sub {
	my @lines = &grub2_custom_file_lines($file);
	if (defined($index) && $index ne '') {
		# Replacement uses the latest parsed ranges under the file lock.
		my @entries = &grub2_custom_entries_from_lines(\@lines, $file);
		my $entry = $index =~ /^\d+\z/ ? $entries[$index] : undef;
		return $text{'custom_eentry'} if (!$entry);
		splice(@lines, $entry->{'start'},
		       $entry->{'end'} - $entry->{'start'} + 1, @entry_lines);
		}
	else {
		# New entries are appended after a blank separator when needed.
		push(@lines, '') if (@lines && $lines[-1] ne '');
		push(@lines, @entry_lines);
		}
	my $data = join("\n", @lines)."\n";
	&grub2_write_custom_file($file, $data);
	return;
	});
}

# grub2_delete_custom_entry_indexes(index, ...)
# Deletes entries by custom-file index.
sub grub2_delete_custom_entry_indexes
{
my (@indexes) = @_;
return $text{'delete_enone'} if (!@indexes);
my $file = &grub2_config_value('custom_file') || '';
return $text{'delete_ecustom'} if ($file eq '' || !-r $file);
return &grub2_with_file_lock($file, sub {
	my @lines = &grub2_custom_file_lines($file);
	my @entries = &grub2_custom_entries_from_lines(\@lines, $file);
	my %seen;
	my @ranges;
	foreach my $index (@indexes) {
		# Validate all selected indexes before deleting any ranges.
		return $text{'custom_eentry'}
			if (!defined($index) || $index !~ /^\d+\z/ ||
			    !$entries[$index]);
		next if ($seen{$index}++);
		push(@ranges,
		     [ $entries[$index]->{'start'}, $entries[$index]->{'end'} ]);
		}
	return &grub2_delete_custom_ranges($file, \@ranges, \@lines);
	});
}

# grub2_move_custom_entry(index, direction)
# Moves one custom entry up or down within the custom file.
sub grub2_move_custom_entry
{
my ($index, $direction) = @_;
return $text{'custom_eentry'} if (!defined($index) || $index !~ /^\d+\z/);
return $text{'custom_emove'} if ($direction !~ /^(up|down)\z/);
my $file = &grub2_config_value('custom_file') || '';
return $text{'delete_ecustom'} if ($file eq '' || !-r $file);
return &grub2_with_file_lock($file, sub {
	my @lines = &grub2_custom_file_lines($file);
	my @entries = &grub2_custom_entries_from_lines(\@lines, $file);
	return $text{'custom_eentry'} if (!$entries[$index]);
	return $text{'custom_emove'} if ($direction eq 'up' && $index == 0);
	return $text{'custom_emove'} if ($direction eq 'down' && $index == $#entries);
	my $entry = $entries[$index];
	my $other = $direction eq 'up' ? $entries[$index - 1] :
		    $entries[$index + 1];
	# Moving across submenu paths would alter the meaning of the custom file.
	return $text{'custom_emove'} if (!&grub2_paths_equal($entry, $other));
	my @block = @lines[$entry->{'start'} .. $entry->{'end'}];
	my $len = scalar(@block);
	splice(@lines, $entry->{'start'}, $len);
	my $insert = $direction eq 'up' ? $other->{'start'} :
		     $other->{'end'} - $len + 1;
	splice(@lines, $insert, 0, @block);
	my $data = join("\n", @lines)."\n";
	&grub2_write_custom_file($file, $data);
	return;
	});
}

# grub2_validate_custom_entry(title, id, body)
# Validates one custom entry before writing it to the custom file.
sub grub2_validate_custom_entry
{
my ($title, $id, $body) = @_;
$title = '' if (!defined($title));
$id = '' if (!defined($id));
$body = '' if (!defined($body));
return $text{'custom_etitle'} if ($title eq '' || $title =~ /[\r\n\0]/);
return $text{'custom_eid'} if ($id =~ /[\r\n\0]/ ||
			       ($id ne '' && $id !~ /^[A-Za-z0-9_.:+=,\@-]+\z/));
return $text{'custom_eid'} if ($id =~ /^-/);
return $text{'custom_ebody'} if ($body =~ /\0/);
# Validate the full wrapped menuentry, not just the user-entered body.
return &grub2_validate_grub_script_text(
	&grub2_format_custom_entry($title, $id, $body));
}

# grub2_validate_grub_script_text(text, [context-file])
# Validates GRUB script with grub-script-check when available, or braces.
sub grub2_validate_grub_script_text
{
my ($data, $context_file) = @_;
my $cmd = &grub2_command('script_check_cmd');
if ($cmd) {
	# Prefer GRUB's own parser when it is installed.
	my $file = $context_file || &grub2_config_value('custom_file') || '';
	return &text('defaults_econfigpath', $file) if ($file !~ m{^/});
	my $dir = &grub2_dirname($file);
	return &text('defaults_edir', $dir) if ($dir eq '' || !-d $dir);
	my ($temp, $terr) = &grub2_make_temp_file($dir, 'script', $data);
	return $terr if ($terr);
	my ($out, $failed);
	my $die;
	eval {
		$out = &backquote_command(
			quotemeta($cmd).' '.quotemeta($temp).
			' 2>&1 </dev/null');
		$failed = $?;
		1;
		} || do { $die = $@ || $!; };
	&grub2_unlink_temp($temp);
	return $die if ($die);
	if ($failed) {
		$out =~ s/^\s+|\s+\z//g if (defined($out));
		return $out || $text{'custom_evalidate'};
		}
	return;
	}
my $depth = 0;
foreach my $line (split(/\n/, $data)) {
	# Fallback validation catches unbalanced braces on minimal systems.
	my ($opens, $closes) = &count_grub_braces($line);
	$depth += $opens - $closes;
	return $text{'custom_ebraces'} if ($depth < 0);
	}
return if ($depth == 0);
return $text{'custom_ebraces'};
}

# grub2_custom_script_text(text)
# Returns the GRUB script portion of a 40_custom-style file.
sub grub2_custom_script_text
{
my ($data) = @_;
$data = '' if (!defined($data));
$data =~ s/\r\n/\n/g;
$data =~ s/\r/\n/g;
my @lines = split(/\n/, $data, -1);
if (@lines >= 2 && $lines[0] =~ /^#!/ &&
    $lines[1] =~ /^\s*exec\s+tail\s+-n\s+\+3\b/) {
	# Standard 40_custom has a shell wrapper that GRUB never sees.
	splice(@lines, 0, 2);
	}
return join("\n", @lines);
}

# grub2_format_custom_entry(title, id, body)
# Returns normalized GRUB script for one custom menuentry.
sub grub2_format_custom_entry
{
my ($title, $id, $body) = @_;
$body = '' if (!defined($body));
$body =~ s/\r\n/\n/g;
$body =~ s/\r/\n/g;
my @body = split(/\n/, $body);
@body = &grub2_unindent_custom_body(\@body);
@body = ('true') if (!grep { /\S/ } @body);
my $line = 'menuentry '.&grub2_quote_word($title);
$line .= ' --id '.&grub2_quote_word($id) if (defined($id) && $id ne '');
$line .= ' {';
# Store custom bodies with one tab so future edits can unindent cleanly.
my @indented = map { "\t".$_ } @body;
return join("\n", $line, @indented, '}')."\n";
}

# grub2_quote_word(text)
# Quotes one GRUB command word using double-quote shell syntax.
sub grub2_quote_word
{
my ($text) = @_;
$text = '' if (!defined($text));
$text =~ s/(["\\\$`])/\\$1/g;
return '"'.$text.'"';
}

# grub2_paths_equal(&entry-a, &entry-b)
# Returns true when two entries belong to the same submenu path.
sub grub2_paths_equal
{
my ($a, $b) = @_;
return join("\n", @{$a->{'path'} || []}) eq
       join("\n", @{$b->{'path'} || []});
}

# grub2_with_file_lock(file, &code)
# Runs a code reference while holding a Webmin lock on a file.
sub grub2_with_file_lock
{
my ($file, $code) = @_;
my ($ret, $die);
&lock_file($file);
eval {
	# The callback performs any validation that must happen under the lock.
	$ret = $code->();
	1;
	} || do { $die = $@ || $!; };
&unlock_file($file);
return $die if ($die);
return $ret;
}

# grub2_write_custom_file(file, data)
# Writes the custom file and ensures it remains executable.
sub grub2_write_custom_file
{
my ($file, $data) = @_;
open_tempfile(my $fh, ">$file");
print_tempfile($fh, $data);
close_tempfile($fh);
chmod(oct("0755"), $file);
return;
}

# grub2_make_temp_file(dir, prefix, [data])
# Creates a private temporary file in a live config directory.
sub grub2_make_temp_file
{
my ($dir, $prefix, $data) = @_;
return ('', &text('defaults_edir', $dir)) if ($dir eq '' || !-d $dir);
&seed_random();
my $last_err;
for (my $i = 0; $i < 20; $i++) {
	my $temp = "$dir/.webmin-grub2-$prefix-$$-".
		   int(rand(1000000000))."-$i";
	my $fh;
	# O_EXCL avoids racing another Webmin process in the same directory.
	if (!sysopen($fh, $temp, O_WRONLY|O_CREAT|O_EXCL, oct("0600"))) {
		$last_err = $!;
		next if ($! == EEXIST);
		next;
		}
	binmode($fh);
	if (defined($data) && !(print $fh $data)) {
		my $err = $!;
		close($fh);
		unlink($temp);
		return ('', $err);
		}
	if (!close($fh)) {
		my $err = $!;
		unlink($temp);
		return ('', $err);
		}
	push(@main::temporary_files, $temp);
	return ($temp);
	}
return ('', "Failed to create temporary file in $dir : $last_err");
}

# grub2_unlink_temp(file)
# Removes a temporary file and unregisters it from Webmin cleanup.
sub grub2_unlink_temp
{
my ($temp) = @_;
return if (!defined($temp) || $temp eq '');
unlink($temp);
@main::temporary_files = grep { $_ ne $temp } @main::temporary_files;
}

# grub2_custom_file_lines(file)
# Returns current or new custom file contents as editable lines.
sub grub2_custom_file_lines
{
my ($file) = @_;
my $data;
if ($file && -r $file) {
	# Existing custom files are preserved, including their shell wrapper.
	$data = &read_file_contents($file);
	}
else {
	# New custom files use the conventional 40_custom wrapper.
	$data = "#!/bin/sh\nexec tail -n +3 \$0\n";
	}
$data =~ s/\r\n/\n/g;
$data =~ s/\r/\n/g;
my @lines = split(/\n/, $data, -1);
pop(@lines) if (@lines && $lines[-1] eq '');
return @lines;
}

# parse_grub_statement(line, keyword)
# Returns the title and ID from a menuentry or submenu line.
sub parse_grub_statement
{
my ($line, $keyword) = @_;
$line =~ s/^\s*\Q$keyword\E\s+//;
my ($title, $rest) = &parse_grub_word($line);
my $id;
# Accept both explicit --id and distro scripts using $menuentry_id_option.
if (defined($rest) &&
    $rest =~ /(?:--id|\$menuentry_id_option)\s+((?:"(?:\\.|[^"])*")|(?:'[^']*')|\S+)/) {
	($id) = &parse_grub_word($1);
	}
return ($title, $id);
}

# parse_grub_word(text)
# Parses one GRUB shell-style word.
sub parse_grub_word
{
my ($text) = @_;
return if (!defined($text));
$text =~ s/^\s+//;
return if ($text eq '');
my $quote = substr($text, 0, 1);
if ($quote eq "'" || $quote eq '"') {
	# Parse one quoted word and return the unparsed remainder to the caller.
	my $out = '';
	my $escape = 0;
	for (my $i = 1; $i < length($text); $i++) {
		my $ch = substr($text, $i, 1);
		if ($escape) {
			$out .= $ch;
			$escape = 0;
			next;
			}
		if ($quote eq '"' && $ch eq '\\') {
			$escape = 1;
			next;
			}
		if ($ch eq $quote) {
			return ($out, substr($text, $i + 1));
			}
		$out .= $ch;
		}
	return ($out, '');
	}
if ($text =~ /^(\S+)(.*)\z/s) {
	# Unquoted words end at the next whitespace.
	return ($1, $2);
	}
return;
}

# count_grub_braces(line)
# Counts unquoted braces on a GRUB config line.
sub count_grub_braces
{
my ($line) = @_;
my ($opens, $closes) = (0, 0);
my $quote = '';
my $escape = 0;
for (my $i = 0; $i < length($line); $i++) {
	my $ch = substr($line, $i, 1);
	if ($escape) {
		# Escaped characters inside double quotes are not syntax braces.
		$escape = 0;
		next;
		}
	if ($quote eq '"') {
		if ($ch eq '\\') {
			$escape = 1;
			}
		elsif ($ch eq '"') {
			$quote = '';
			}
		next;
		}
	if ($quote eq "'") {
		$quote = '' if ($ch eq "'");
		next;
		}
	if ($ch eq '"' || $ch eq "'") {
		$quote = $ch;
		next;
		}
	# Comments terminate syntax scanning for this line.
	last if ($ch eq '#');
	$opens++ if ($ch eq '{');
	$closes++ if ($ch eq '}');
	}
return ($opens, $closes);
}

# grub2_statement_block_end(&lines, start-index)
# Returns the final line index for a GRUB statement block.
sub grub2_statement_block_end
{
my ($lines, $start) = @_;
my $depth = 0;
my $seen_open = 0;
for (my $i = $start; $i < @$lines; $i++) {
	my ($opens, $closes) = &count_grub_braces($lines->[$i]);
	$seen_open ||= $opens;
	$depth += $opens - $closes;
	# A block ends on the first line that balances the opening brace.
	return $i if ($seen_open && $depth <= 0);
	}
return $start;
}

# grub2_entry_selector(&entry)
# Returns the safest selector to pass to grub-set-default or grub-reboot.
sub grub2_entry_selector
{
my ($entry) = @_;
return $entry->{'id'} if ($entry->{'id'});
my @path = (@{$entry->{'path'} || []}, $entry->{'title'});
return if (grep { !defined($_) || />/ } @path);
return join('>', @path);
}

# grub2_entry_by_index(index)
# Returns a parsed boot entry by non-negative index.
sub grub2_entry_by_index
{
my ($index) = @_;
return if (!defined($index) || $index !~ /^\d+\z/);
my @entries = &grub2_boot_entries();
return if ($index >= @entries);
return $entries[$index];
}

# grub2_entry_selection_roles(&entries, [&parsed-defaults], [&env])
# Returns entry indexes mapped to active default and next-boot roles.
sub grub2_entry_selection_roles
{
my ($entries, $parsed, $env) = @_;
$entries ||= [ &grub2_boot_entries() ];
$parsed ||= &read_grub_defaults();
if (!$env) {
	my %read_env = &grub2_read_env();
	$env = \%read_env;
	}
my %roles;
my $default = $parsed->{'values'}->{'GRUB_DEFAULT'};
$default = '0' if (!defined($default) || $default eq '');
if ($default eq 'saved') {
	# saved resolves through grubenv, not directly through grub.cfg.
	&grub2_mark_entry_role(\%roles, $entries, $env->{'saved_entry'}, 'saved');
	}
else {
	&grub2_mark_entry_role(\%roles, $entries, $default, 'default');
	}
&grub2_mark_entry_role(\%roles, $entries, $env->{'next_entry'}, 'next');
return %roles;
}

# grub2_mark_entry_role(&roles, &entries, selector, role)
# Adds one selection role to the entry matched by a GRUB selector.
sub grub2_mark_entry_role
{
my ($roles, $entries, $selector, $role) = @_;
return if (!defined($selector) || $selector eq '');
foreach my $entry (@$entries) {
	if (&grub2_entry_matches_selector($entry, $selector)) {
		push(@{$roles->{$entry->{'index'}}}, $role);
		return;
		}
	}
}

# grub2_entry_matches_selector(&entry, selector)
# Returns true when a selector names an entry by index, ID, title, or path.
sub grub2_entry_matches_selector
{
my ($entry, $selector) = @_;
return 0 if (!defined($entry) || !defined($selector) || $selector eq '');
return 1 if ($selector =~ /^\d+\z/ && $selector == $entry->{'index'});
return 1 if (defined($entry->{'id'}) && $entry->{'id'} eq $selector);
return 1 if (defined($entry->{'title'}) && $entry->{'title'} eq $selector);
my @path = (@{$entry->{'path'} || []}, $entry->{'title'});
return 0 if (grep { !defined($_) || />/ } @path);
return join('>', @path) eq $selector;
}

# grub2_delete_custom_ranges(file, &ranges)
# Removes line ranges from the custom file.
sub grub2_delete_custom_ranges
{
my ($file, $ranges, $lines) = @_;
my @lines = $lines ? @$lines : &grub2_custom_file_lines($file);
foreach my $range (sort { $b->[0] <=> $a->[0] } @$ranges) {
	# Delete from bottom to top so earlier indexes remain valid.
	splice(@lines, $range->[0], $range->[1] - $range->[0] + 1);
	}
my $new_data = join("\n", @lines);
$new_data .= "\n" if (@lines);
&grub2_write_custom_file($file, $new_data);
return;
}

# grub2_run_entry_command(command-key, &entry)
# Runs a GRUB command that takes one menu entry selector.
sub grub2_run_entry_command
{
my ($key, $entry) = @_;
my $cmd = &grub2_command($key);
return $text{'runtime_ecmd'} if (!$cmd);
my $selector = &grub2_entry_selector($entry);
return $text{'runtime_eselector'}
	if (!defined($selector) || $selector eq '' || $selector =~ /^-/);
# Quote the selector because titles and submenu paths may contain spaces.
my $out = &backquote_logged(
	quotemeta($cmd).' '.quotemeta($selector).' 2>&1 </dev/null');
if ($?) {
	$out =~ s/^\s+|\s+\z//g if (defined($out));
	return $out || $text{'runtime_err'};
	}
return;
}

# grub2_read_env()
# Reads key/value state from grubenv using grub-editenv when available.
sub grub2_read_env
{
my %env;
my $file = &grub2_config_value('grubenv_file') || '';
my $cmd = &grub2_command('editenv_cmd');
if ($cmd && $file ne '' && -e $file) {
	# grub-editenv handles the binary environment format when available.
	my $out = &backquote_command(
		quotemeta($cmd).' '.quotemeta($file).' list 2>&1 </dev/null',
		1);
	if (!$?) {
		foreach my $line (split(/\r?\n/, $out || '')) {
			if ($line =~ /^([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/) {
				$env{$1} = $2;
				}
			}
		return %env;
		}
	}
if ($file ne '' && -r $file) {
	# Plain fallback helps in tests and on systems with text grubenv files.
	my $data = &read_file_contents($file);
	foreach my $line (split(/\r?\n/, $data || '')) {
		if ($line =~ /^([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/) {
			$env{$1} = $2;
			}
		}
	}
return %env;
}

# grub2_validate_install_options(&options)
# Returns an error if GRUB installation options are unsafe or incomplete.
sub grub2_validate_install_options
{
my ($opts) = @_;
my $target = $opts->{'target'} || '';
my $efi_dir = $opts->{'efi_dir'} || '';
my $platform = $opts->{'platform'} || '';
my $directory = $opts->{'directory'} || '';
my $boot_directory = $opts->{'boot_directory'} || '';
my $bootloader_id = $opts->{'bootloader_id'} || '';
return $text{'install_etarget'} if ($target eq '' && $efi_dir eq '');
if ($target ne '') {
	# BIOS targets must be existing absolute device paths under /dev.
	return $text{'install_etarget'}
		if ($target =~ /[\r\n\0]/ || $target !~ m{\A/} ||
		    $target !~ m{\A/dev/} ||
		    $target !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
		    $target =~ m{/(?:\.|\.\.)(?:/|\z)} || $target =~ /^-/);
	return &text('install_etarget_missing', $target) if (!-e $target);
	}
if ($efi_dir ne '') {
	# EFI installs target an existing absolute ESP mount path.
	return $text{'install_eefi'}
		if ($efi_dir =~ /[\r\n\0]/ || $efi_dir !~ m{\A/} ||
		    $efi_dir !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
		    $efi_dir =~ m{/(?:\.|\.\.)(?:/|\z)});
	return &text('install_eefi_missing', $efi_dir) if (!-d $efi_dir);
	}
if ($platform ne '') {
	# Platform targets are GRUB names such as x86_64-efi or arm64-efi.
	return $text{'install_eplatform'}
		if ($platform =~ /[\r\n\0]/ ||
		    $platform !~ /\A[A-Za-z0-9_-]+\z/ || $platform =~ /^-/);
	}
if ($directory ne '') {
	# Custom module directories must contain modinfo.sh for grub-install.
	return $text{'install_edirectory'}
		if ($directory =~ /[\r\n\0]/ || $directory !~ m{\A/} ||
		    $directory !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
		    $directory =~ m{/(?:\.|\.\.)(?:/|\z)});
	return &text('install_edirectory_missing', $directory)
		if (!-d $directory);
	return &text('install_edirectory_modinfo', $directory)
		if (!-r "$directory/modinfo.sh");
	}
elsif ($platform ne '' && !&grub2_platform_module_dir($platform)) {
	# Without an explicit directory, require a discoverable platform directory.
	return &text('install_eplatform_modules', $platform,
		     join(', ', &grub2_platform_module_dirs($platform)));
	}
if ($boot_directory ne '') {
	# --boot-directory is optional but still needs the same path hygiene.
	return $text{'install_eboot_directory'}
		if ($boot_directory =~ /[\r\n\0]/ ||
		    $boot_directory !~ m{\A/} ||
		    $boot_directory !~ m{\A/[A-Za-z0-9._/+:-]+\z} ||
		    $boot_directory =~ m{/(?:\.|\.\.)(?:/|\z)});
	return &text('install_eboot_directory_missing', $boot_directory)
		if (!-d $boot_directory);
	}
if ($bootloader_id ne '') {
	# EFI boot loader IDs become paths below EFI/, so keep them simple.
	return $text{'install_ebootloader'}
		if ($bootloader_id =~ /[\r\n\0]/ ||
		    $bootloader_id !~ /\A[A-Za-z0-9_.+-]+\z/ ||
		    $bootloader_id =~ /^-/);
	}
foreach my $key (qw(recheck removable no_nvram force)) {
	return $text{'install_eoption'}
		if (defined($opts->{$key}) && $opts->{$key} !~ /^[01]\z/);
	}
return;
}

# grub2_install_command(&options)
# Returns shell and display forms of the grub-install command.
sub grub2_install_command
{
my ($opts) = @_;
my $cmd = &grub2_command('install_cmd');
return ('', '') if (!$cmd);
my @run = (quotemeta($cmd));
my @display = ($cmd);
foreach my $pair (
	[ 'recheck', '--recheck' ],
	[ 'removable', '--removable' ],
	[ 'no_nvram', '--no-nvram' ],
	[ 'force', '--force' ],
    )
{
	my ($key, $arg) = @$pair;
	if ($opts->{$key}) {
		# Boolean options are emitted before path-like options.
		push(@run, quotemeta($arg));
		push(@display, $arg);
		}
	}
if (($opts->{'efi_dir'} || '') ne '') {
	# EFI installs can omit a BIOS-style block-device target.
	push(@run, quotemeta('--efi-directory='.$opts->{'efi_dir'}));
	push(@display, '--efi-directory='.$opts->{'efi_dir'});
	}
if (($opts->{'platform'} || '') ne '') {
	push(@run, quotemeta('--target='.$opts->{'platform'}));
	push(@display, '--target='.$opts->{'platform'});
	}
if (($opts->{'directory'} || '') ne '') {
	push(@run, quotemeta('--directory='.$opts->{'directory'}));
	push(@display, '--directory='.$opts->{'directory'});
	}
if (($opts->{'boot_directory'} || '') ne '') {
	push(@run, quotemeta('--boot-directory='.$opts->{'boot_directory'}));
	push(@display, '--boot-directory='.$opts->{'boot_directory'});
	}
if (($opts->{'bootloader_id'} || '') ne '') {
	push(@run, quotemeta('--bootloader-id='.$opts->{'bootloader_id'}));
	push(@display, '--bootloader-id='.$opts->{'bootloader_id'});
	}
if (($opts->{'target'} || '') ne '') {
	push(@run, quotemeta($opts->{'target'}));
	push(@display, $opts->{'target'});
	}
return (join(' ', @run).' </dev/null', join(' ', @display));
}

# grub2_install_bootloader(&options, [&callback])
# Runs grub-install with explicit administrator-selected options.
sub grub2_install_bootloader
{
my ($opts, $callback) = @_;
my $cmd = &grub2_command('install_cmd');
return $text{'install_ecmd'} if (!$cmd);
my $err = &grub2_validate_install_options($opts);
return $err if ($err);
my ($run, $display) = &grub2_install_command($opts);
my ($out, $failed);
my $die;
eval {
	&grub2_generate_progress($callback, 'command', $display);
	if ($callback) {
		# Progress mode streams output and records the executed command.
		$out = &grub2_run_command_progress($run, $callback);
		$failed = $?;
		}
	else {
		# Non-progress callers still get logged command execution.
		$out = &backquote_logged($run.' 2>&1');
		$failed = $?;
		}
	&grub2_generate_progress($callback,
		$failed ? 'command_failed' : 'command_done', $display);
	1;
	} || do { $die = $@ || $!; };
return $die if ($die);
if ($failed) {
	$out =~ s/^\s+|\s+\z//g if (defined($out));
	return $out || $text{'install_failed'};
	}
return;
}

# grub2_install_log_target(&options)
# Returns a concise installation target string for logs.
sub grub2_install_log_target
{
my ($opts) = @_;
return $opts->{'target'} if (($opts->{'target'} || '') ne '');
return &text('install_log_efi', $opts->{'efi_dir'})
	if (($opts->{'efi_dir'} || '') ne '');
return '';
}

# grub2_generate_config([&callback])
# Runs grub-mkconfig to a test file, then replaces the live generated menu.
sub grub2_generate_config
{
my ($callback) = @_;
my $cmd = &grub2_command('mkconfig_cmd');
return $text{'generate_missing'} if (!$cmd);
my $target = &grub2_config_value('grub_cfg') || '';
return $text{'index_warn_missing_cfg'} if ($target eq '');
return &text('defaults_econfigpath', $target) if ($target !~ m{^/});
my $dir = &grub2_dirname($target);
return &text('index_missing_detail', $dir) if ($dir eq '' || !-d $dir);
my ($temp, $terr) = &grub2_make_temp_file($dir, 'mkconfig');
return $terr if ($terr);
my ($out, $failed, $data, $validation_err);
my $die;
eval {
	# Always generate to a sibling temp file before touching the live menu.
	my $run = quotemeta($cmd).' -o '.quotemeta($temp).' </dev/null';
	my $display = $cmd.' -o '.$temp;
	&grub2_generate_progress($callback, 'command', $display);
	if ($callback) {
		$out = &grub2_run_command_progress($run, $callback);
		$failed = $?;
		}
	else {
		$out = &backquote_logged($run.' 2>&1');
		$failed = $?;
		}
	if (!$failed) {
		&grub2_generate_progress($callback, 'command_done', $display);
		&grub2_generate_progress($callback, 'check', $temp);
		if (-s $temp) {
			# Non-empty output is the first guard against broken generators.
			$data = &read_file_contents($temp);
			}
		if (defined($data) && $data ne '') {
			# grub-script-check catches syntax errors before replacement.
			$validation_err =
				&grub2_validate_grub_script_text($data, $target);
			}
		if (defined($data) && $data ne '' && !$validation_err) {
			&grub2_generate_progress($callback, 'check_done', $temp);
			}
		else {
			&grub2_generate_progress($callback, 'check_failed', $temp);
			}
		}
	else {
		&grub2_generate_progress($callback, 'command_failed', $display);
		}
	1;
	} || do { $die = $@ || $!; };
if ($die) {
	&grub2_unlink_temp($temp);
	return $die;
	}
if ($failed || !defined($data) || $data eq '' || $validation_err) {
	# Leave the existing grub.cfg untouched on all generation/check failures.
	&grub2_unlink_temp($temp);
	$out =~ s/^\s+|\s+\z//g if (defined($out));
	return &text('generate_evalidate', $validation_err)
		if ($validation_err);
	return $out || ($failed ? $text{'generate_failed'} :
			$text{'generate_empty'});
	}
&grub2_unlink_temp($temp);
&grub2_generate_progress($callback, 'replace', $target);
# Re-open the live target via Webmin's locked tempfile writer for logging.
open_lock_tempfile(my $fh, ">$target");
print_tempfile($fh, $data);
close_tempfile($fh);
&grub2_generate_progress($callback, 'replace_done', $target);
return;
}

# grub2_run_command_progress(command, &callback)
# Runs one command and streams combined stdout/stderr to a callback.
sub grub2_run_command_progress
{
my ($cmd, $callback) = @_;
my $out = '';
&additional_log('exec', undef, $cmd);
local *GRUB2CMD;
my $pid = &open_execute_command(\*GRUB2CMD, $cmd, 2, 0);
if (!$pid) {
	# Match shell-style command failure status for callers checking $?
	$? = 1 << 8;
	return "$cmd : $!";
	}
while (defined(my $line = readline(\*GRUB2CMD))) {
	# Stream line-by-line so progress pages update during long commands.
	$out .= $line;
	&grub2_generate_progress($callback, 'output', $line);
	}
close(\*GRUB2CMD);
return $out;
}

# grub2_generate_progress(&callback, event, value)
# Sends a generation progress event when a callback was supplied.
sub grub2_generate_progress
{
my ($callback, $event, $value) = @_;
return if (!$callback);
$callback->($event, $value);
return;
}

# grub2_config_files()
# Returns files and directories that should be included in config backups.
sub grub2_config_files
{
my @files;
foreach my $key (qw(default_file grub_cfg grub_dir custom_file password_file
		   color_file theme_dir background_dir grubenv_file bls_dir)) {
	my $file = &grub2_config_value($key);
	push(@files, $file) if (defined($file) && $file ne '');
	}
return &unique(@files);
}

# grub2_status_warnings()
# Returns actionable warnings for the index page.
sub grub2_status_warnings
{
my @warnings;
my $default_file = &grub2_config_value('default_file') || '';
my $grub_cfg = &grub2_config_value('grub_cfg') || '';
push(@warnings, $text{'index_warn_missing_default'})
	if ($default_file ne '' && !-r $default_file);
push(@warnings, $text{'index_warn_missing_cfg'})
	if ($grub_cfg ne '' && !-r $grub_cfg);
push(@warnings, $text{'index_warn_mkconfig'})
	if (!&grub2_command('mkconfig_cmd'));
my %env = &grub2_read_env();
return @warnings if ($default_file eq '' || !-r $default_file);
my $parsed = &read_grub_defaults($default_file);
if (($parsed->{'values'}->{'GRUB_DEFAULT'} || '') eq 'saved' &&
    !$env{'saved_entry'}) {
	push(@warnings, $text{'index_warn_saved'});
	}
my $theme = $parsed->{'values'}->{'GRUB_THEME'} || '';
if ($theme ne '') {
	my $terr = &grub2_validate_theme_path($theme, $text{'defaults_theme'});
	push(@warnings, &text('index_warn_theme_invalid', $terr)) if ($terr);
	}
if ($theme ne '' &&
    ($parsed->{'values'}->{'GRUB_TERMINAL_OUTPUT'} || '') eq 'console') {
	push(@warnings, $text{'index_warn_theme_console'});
	}
return @warnings;
}

# grub2_dirname(path)
# Returns the directory component of a Unix path.
sub grub2_dirname
{
my ($path) = @_;
return '' if (!defined($path) || $path eq '');
my $dir = dirname($path);
return '' if ($dir eq '.');
return $dir;
}

1;
