#!/usr/bin/perl
# Full-line comments must survive Apache block rewrites.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use Cwd qw(abs_path);
my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..', '..'));
my $tmp = abs_path(tempdir(CLEANUP => 1));
my $webmin_config = File::Spec->catdir($tmp, 'webmin-config');
my $webmin_var = File::Spec->catdir($tmp, 'webmin-var');
my $apache_root = File::Spec->catdir($tmp, 'apache2');
my $apache_conf = File::Spec->catfile($apache_root, 'apache2.conf');
make_path($webmin_config, $webmin_var, "$webmin_config/apache",
"$webmin_var/apache", $apache_root);
sub write_text
{
my ($file, $text) = @_;
open(my $fh, '>', $file) || die "Failed to write $file: $!";
print $fh $text;
close($fh) || die "Failed to close $file: $!";
}
sub read_text
{
my ($file) = @_;
open(my $fh, '<', $file) || die "Failed to read $file: $!";
local $/ = undef;
my $text = <$fh>;
close($fh) || die "Failed to close $file: $!";
return $text;
}
# Load the Apache module with an isolated Webmin configuration.
write_text(File::Spec->catfile($webmin_config, 'config'),
"os_type=debian-linux\n".
"os_version=12\n");
write_text(File::Spec->catfile($webmin_config, 'miniserv.conf'),
"root=$root\n");
write_text(File::Spec->catfile($webmin_config, 'apache', 'config'),
"httpd_dir=$apache_root\n".
"httpd_path=/bin/true\n".
"httpd_conf=$apache_conf\n".
"apachectl_path=/bin/true\n".
"httpd_version=2.4.57\n");
write_text($apache_conf, "Listen 80\n");
$ENV{'WEBMIN_CONFIG'} = $webmin_config;
$ENV{'WEBMIN_VAR'} = $webmin_var;
$ENV{'FOREIGN_MODULE_NAME'} = 'apache';
$ENV{'FOREIGN_ROOT_DIRECTORY'} = $root;
$ENV{'REMOTE_USER'} = 'root';
unshift(@INC, $root);
require File::Spec->catfile($root, 'apache', 'apache-lib.pl');
# Comments at multiple indentation levels must round-trip with the directives.
my $roundtrip = File::Spec->catfile($tmp, 'comments.conf');
my $roundtrip_text =
"# outer comment\n".
"\n".
" # nested comment\n".
"\n".
" Require all granted\n".
"\n";
write_text($roundtrip, $roundtrip_text);
open(my $fh, '<', $roundtrip) || die "Failed to read $roundtrip: $!";
my $line = 0;
my @roundtrip_config = main::parse_config_file($fh, $line, $roundtrip);
close($fh) || die "Failed to close $roundtrip: $!";
my ($directory) =
main::find_directive_struct('Directory', \@roundtrip_config);
my ($require) =
main::find_directive_struct('Require', $directory->{'members'});
is_deeply($directory->{'comments'}, [ '# outer comment' ],
'outer comment is attached to the directory');
is_deeply($require->{'comments'}, [ ' # nested comment', '' ],
'nested comment block is attached to the following directive');
ok(!$roundtrip_config[0]->{'comments'} &&
!$directory->{'members'}->[0]->{'comments'},
'dummy block markers do not store comments');
is(join("\n", main::directive_lines(@roundtrip_config))."\n",
$roundtrip_text, 'comments survive parsing and serialization');
# Rewriting and removing virtual hosts must handle attached comments with them.
my $vhosts_file = File::Spec->catfile($tmp, 'vhosts.conf');
my $first_vhost =
"# first virtual host\n".
"\n".
" # first comment\n".
"\n".
" ServerName first.example\n".
"\n";
my $second_vhost =
"# second virtual host\n".
"\n".
" # second comment\n".
" ServerName second.example\n".
"\n";
write_text($vhosts_file, $first_vhost.$second_vhost);
open($fh, '<', $vhosts_file) || die "Failed to read $vhosts_file: $!";
$line = 0;
my @vhost_config = main::parse_config_file($fh, $line, $vhosts_file);
close($fh) || die "Failed to close $vhosts_file: $!";
my @vhosts = main::find_directive_struct('VirtualHost', \@vhost_config);
main::recursive_set_lines_files(\@vhost_config, 0, $vhosts_file);
is($vhosts[0]->{'line'}, 1, 'block line follows its attached comment');
is($vhosts[0]->{'eline'}, 5, 'member comment is included in block line count');
is($vhosts[1]->{'line'}, 7, 'next block follows its attached comment');
my %replacement = %{$vhosts[0]};
delete($replacement{'comments'});
$replacement{'value'} = '*:8080';
my $rewritten_first = $first_vhost;
$rewritten_first =~ s/\*:80>/*:8080>/;
main::save_directive_struct($vhosts[0], \%replacement,
\@vhost_config, \@vhost_config);
main::flush_file_lines($vhosts_file);
is(read_text($vhosts_file), $rewritten_first.$second_vhost,
'replacement block preserves surrounding comments');
is_deeply($replacement{'comments'}, [ '# first virtual host' ],
'replacement block inherits attached comments');
main::save_directive_struct($vhosts[1], undef,
\@vhost_config, \@vhost_config);
main::flush_file_lines($vhosts_file);
is(read_text($vhosts_file),
$rewritten_first,
'deleting a block removes its attached comment');
# Normal directive edits preserve comments, while deletion removes their owner.
my $directives_file = File::Spec->catfile($tmp, 'directives.conf');
my $directives_text =
"# listen comment\n".
"Listen 80\n".
"# server name comment\n".
"ServerName old.example\n";
write_text($directives_file, $directives_text);
open($fh, '<', $directives_file) ||
die "Failed to read $directives_file: $!";
$line = 0;
my @directive_config =
main::parse_config_file($fh, $line, $directives_file);
close($fh) || die "Failed to close $directives_file: $!";
main::save_directive('ServerName', [ 'new.example' ],
\@directive_config, \@directive_config);
main::flush_file_lines($directives_file);
my $updated_directives =
"# listen comment\n".
"Listen 80\n".
"# server name comment\n".
"ServerName new.example\n";
is(read_text($directives_file), $updated_directives,
'comments survive a normal directive edit');
main::save_directive('Listen', [ ], \@directive_config, \@directive_config);
main::flush_file_lines($directives_file);
my $deleted_directive =
"# server name comment\n".
"ServerName new.example\n";
is(read_text($directives_file), $deleted_directive,
'deleting a directive removes its attached comment');
open($fh, '<', $directives_file) ||
die "Failed to read $directives_file: $!";
$line = 0;
@directive_config = main::parse_config_file($fh, $line, $directives_file);
close($fh) || die "Failed to close $directives_file: $!";
my ($servername) =
main::find_directive_struct('ServerName', \@directive_config);
is_deeply($servername->{'comments'},
[ '# server name comment' ],
'remaining comment stays attached to its directive');
# Nested block positions must include comments at every level.
my $inner = {
'name' => 'Files', 'value' => '*.php', 'type' => 1,
'comments' => [ ' # files comment' ],
'members' => [
{ 'name' => 'dummy', 'type' => 0 },
{ 'name' => 'Require', 'value' => 'all denied',
'comments' => [ ' # access comment' ] },
],
};
my $outer = {
'name' => 'Directory', 'value' => '/srv/example', 'type' => 1,
'members' => [
{ 'name' => 'dummy', 'type' => 0 },
$inner,
{ 'name' => 'Options', 'value' => 'Indexes' },
],
};
my @nested = ($outer);
my @nested_lines = main::directive_lines(@nested);
my $next_line = main::recursive_set_lines_files(\@nested, 20, $vhosts_file);
is($inner->{'line'}, 22, 'nested block line follows its comment');
is($inner->{'members'}->[1]->{'line'}, 24,
'nested member line follows its comment');
is($outer->{'members'}->[2]->{'line'}, 26,
'directive after nested block has the correct line');
is($outer->{'eline'}, 27, 'outer block ends after all nested lines');
is($next_line, 20 + scalar(@nested_lines),
'line count matches nested serialized output');
# Comments on skipped conditionals must not move to a later directive.
my @skipped_conditionals = (
[ 'IfModule',
'', '' ],
[ 'IfDefine',
'', '' ],
[ 'IfVersion', '= 999.0>', '' ],
);
foreach my $conditional (@skipped_conditionals) {
my ($name, $opening, $closing) = @{$conditional};
my $conditional_file =
File::Spec->catfile($tmp, "skipped-$name.conf");
write_text($conditional_file,
"# skipped $name comment\n".
"$opening\n".
" IgnoredDirective value\n".
"$closing\n".
"ServerName example.test\n");
open($fh, '<', $conditional_file) ||
die "Failed to read $conditional_file: $!";
$line = 0;
my @conditional_config =
main::parse_config_file($fh, $line, $conditional_file);
close($fh) || die "Failed to close $conditional_file: $!";
my ($conditional_servername) =
main::find_directive_struct('ServerName', \@conditional_config);
ok(!$conditional_servername->{'comments'},
"skipped $name comment is not attached to ServerName");
}
done_testing();