# Copyright (c) 2021, PostgreSQL Global Development Group

=pod

=head1 NAME

TestLib - helper module for writing PostgreSQL's C<prove> tests.

=head1 SYNOPSIS

  use TestLib;

  # Test basic output of a command
  program_help_ok('initdb');
  program_version_ok('initdb');
  program_options_handling_ok('initdb');

  # Test option combinations
  command_fails(['initdb', '--invalid-option'],
              'command fails with invalid option');
  my $tempdir = TestLib::tempdir;
  command_ok('initdb', '-D', $tempdir);

  # Miscellanea
  print "on Windows" if $TestLib::windows_os;
  my $path = TestLib::perl2host($backup_dir);
  ok(check_mode_recursive($stream_dir, 0700, 0600),
    "check stream dir permissions");
  TestLib::system_log('pg_ctl', 'kill', 'QUIT', $slow_pid);

=head1 DESCRIPTION

C<TestLib> contains a set of routines dedicated to environment setup for
a PostgreSQL regression test run and includes some low-level routines
aimed at controlling command execution, logging and test functions.

=cut

# This module should never depend on any other PostgreSQL regression test
# modules.

package TestLib;

use strict;
use warnings;

use Carp;
use Config;
use Cwd;
use Exporter 'import';
use Fcntl qw(:mode :seek);
use File::Basename;
use File::Find;
use File::Spec;
use File::stat qw(stat);
use File::Temp ();
use IPC::Run;
use SimpleTee;

# specify a recent enough version of Test::More to support the
# done_testing() function
use Test::More 0.87;

our @EXPORT = qw(
  generate_ascii_string
  slurp_dir
  slurp_file
  append_to_file
  check_mode_recursive
  chmod_recursive
  check_pg_config
  dir_symlink
  system_or_bail
  system_log
  run_log
  run_command

  command_ok
  command_fails
  command_exit_is
  program_help_ok
  program_version_ok
  program_options_handling_ok
  command_like
  command_like_safe
  command_fails_like
  command_checks_all

  $windows_os
  $is_msys2
  $use_unix_sockets
);

our ($windows_os, $is_msys2, $use_unix_sockets, $tmp_check, $log_path,
	$test_logfile);

BEGIN
{

	# Set to untranslated messages, to be able to compare program output
	# with expected strings.
	delete $ENV{LANGUAGE};
	delete $ENV{LC_ALL};
	$ENV{LC_MESSAGES} = 'C';

	# This list should be kept in sync with pg_regress.c.
	my @envkeys = qw (
	  PGCHANNELBINDING
	  PGCLIENTENCODING
	  PGCONNECT_TIMEOUT
	  PGDATA
	  PGDATABASE
	  PGGSSENCMODE
	  PGGSSLIB
	  PGHOSTADDR
	  PGKRBSRVNAME
	  PGPASSFILE
	  PGPASSWORD
	  PGREQUIREPEER
	  PGREQUIRESSL
	  PGSERVICE
	  PGSERVICEFILE
	  PGSSLCERT
	  PGSSLCRL
	  PGSSLCRLDIR
	  PGSSLKEY
	  PGSSLMAXPROTOCOLVERSION
	  PGSSLMINPROTOCOLVERSION
	  PGSSLMODE
	  PGSSLROOTCERT
	  PGSSLSNI
	  PGTARGETSESSIONATTRS
	  PGUSER
	  PGPORT
	  PGHOST
	  PG_COLOR
	);
	delete @ENV{@envkeys};

	$ENV{PGAPPNAME} = basename($0);

	# Must be set early
	$windows_os = $Config{osname} eq 'MSWin32' || $Config{osname} eq 'msys';
	# Check if this environment is MSYS2.
	$is_msys2 = $^O eq 'msys' && `uname -or` =~ /^[2-9].*Msys/;

	if ($windows_os)
	{
		require Win32API::File;
		Win32API::File->import(
			qw(createFile OsFHandleOpen CloseHandle setFilePointer));
	}

	# Specifies whether to use Unix sockets for test setups.  On
	# Windows we don't use them by default since it's not universally
	# supported, but it can be overridden if desired.
	$use_unix_sockets =
	  (!$windows_os || defined $ENV{PG_TEST_USE_UNIX_SOCKETS});
}

=pod

=head1 EXPORTED VARIABLES

=over

=item C<$windows_os>

Set to true when running under Windows, except on Cygwin.

=item C<$is_msys2>

Set to true when running under MSYS2.

=back

=cut

INIT
{

	# Return EPIPE instead of killing the process with SIGPIPE.  An affected
	# test may still fail, but it's more likely to report useful facts.
	$SIG{PIPE} = 'IGNORE';

	# Determine output directories, and create them.  The base path is the
	# TESTDIR environment variable, which is normally set by the invoking
	# Makefile.
	$tmp_check = $ENV{TESTDIR} ? "$ENV{TESTDIR}/tmp_check" : "tmp_check";
	$log_path = "$tmp_check/log";

	mkdir $tmp_check;
	mkdir $log_path;

	# Open the test log file, whose name depends on the test name.
	$test_logfile = basename($0);
	$test_logfile =~ s/\.[^.]+$//;
	$test_logfile = "$log_path/regress_log_$test_logfile";
	open my $testlog, '>', $test_logfile
	  or die "could not open STDOUT to logfile \"$test_logfile\": $!";

	# Hijack STDOUT and STDERR to the log file
	open(my $orig_stdout, '>&', \*STDOUT);
	open(my $orig_stderr, '>&', \*STDERR);
	open(STDOUT,          '>&', $testlog);
	open(STDERR,          '>&', $testlog);

	# The test output (ok ...) needs to be printed to the original STDOUT so
	# that the 'prove' program can parse it, and display it to the user in
	# real time. But also copy it to the log file, to provide more context
	# in the log.
	my $builder = Test::More->builder;
	my $fh      = $builder->output;
	tie *$fh, "SimpleTee", $orig_stdout, $testlog;
	$fh = $builder->failure_output;
	tie *$fh, "SimpleTee", $orig_stderr, $testlog;

	# Enable auto-flushing for all the file handles. Stderr and stdout are
	# redirected to the same file, and buffering causes the lines to appear
	# in the log in confusing order.
	autoflush STDOUT 1;
	autoflush STDERR 1;
	autoflush $testlog 1;
}

END
{

	# Test files have several ways of causing prove_check to fail:
	# 1. Exit with a non-zero status.
	# 2. Call ok(0) or similar, indicating that a constituent test failed.
	# 3. Deviate from the planned number of tests.
	#
	# Preserve temporary directories after (1) and after (2).
	$File::Temp::KEEP_ALL = 1 unless $? == 0 && all_tests_passing();
}

=pod

=head1 ROUTINES

=over

=item all_tests_passing()

Return 1 if all the tests run so far have passed. Otherwise, return 0.

=cut

sub all_tests_passing
{
	foreach my $status (Test::More->builder->summary)
	{
		return 0 unless $status;
	}
	return 1;
}

=pod

=item tempdir(prefix)

Securely create a temporary directory inside C<$tmp_check>, like C<mkdtemp>,
and return its name.  The directory will be removed automatically at the
end of the tests.

If C<prefix> is given, the new directory is templated as C<${prefix}_XXXX>.
Otherwise the template is C<tmp_test_XXXX>.

=cut

sub tempdir
{
	my ($prefix) = @_;
	$prefix = "tmp_test" unless defined $prefix;
	return File::Temp::tempdir(
		$prefix . '_XXXX',
		DIR     => $tmp_check,
		CLEANUP => 1);
}

=pod

=item tempdir_short()

As above, but the directory is outside the build tree so that it has a short
name, to avoid path length issues.

=cut

sub tempdir_short
{

	return File::Temp::tempdir(CLEANUP => 1);
}

=pod

=item perl2host()

Translate a virtual file name to a host file name.  Currently, this is a no-op
except for the case of Perl=msys and host=mingw32.  The subject need not
exist, but its parent or grandparent directory must exist unless cygpath is
available.

The returned path uses forward slashes but has no trailing slash.

=cut

sub perl2host
{
	my ($subject) = @_;
	return $subject unless $Config{osname} eq 'msys';
	if ($is_msys2)
	{
		# get absolute, windows type path
		my $path = qx{cygpath -a -m "$subject"};
		if (!$?)
		{
			chomp $path;
			$path =~ s!/$!!;
			return $path if $path;
		}
		# fall through if this didn't work.
	}
	my $here = cwd;
	my $leaf;
	if (chdir $subject)
	{
		$leaf = '';
	}
	else
	{
		$leaf = '/' . basename $subject;
		my $parent = dirname $subject;
		if (!chdir $parent)
		{
			$leaf   = '/' . basename($parent) . $leaf;
			$parent = dirname $parent;
			chdir $parent or die "could not chdir \"$parent\": $!";
		}
	}

	# this odd way of calling 'pwd -W' is the only way that seems to work.
	my $dir = qx{sh -c "pwd -W"};
	chomp $dir;
	$dir =~ s!/$!!;
	chdir $here;
	return $dir . $leaf;
}

=pod

=item system_log(@cmd)

Run (via C<system()>) the command passed as argument; the return
value is passed through.

=cut

sub system_log
{
	print("# Running: " . join(" ", @_) . "\n");
	return system(@_);
}

=pod

=item system_or_bail(@cmd)

Run (via C<system()>) the command passed as argument, and returns
if the command is successful.
On failure, abandon further tests and exit the program.

=cut

sub system_or_bail
{
	if (system_log(@_) != 0)
	{
		BAIL_OUT("system $_[0] failed");
	}
	return;
}

=pod

=item run_log(@cmd)

Run the given command via C<IPC::Run::run()>, noting it in the log.
The return value from the command is passed through.

=cut

sub run_log
{
	print("# Running: " . join(" ", @{ $_[0] }) . "\n");
	return IPC::Run::run(@_);
}

=pod

=item run_command(cmd)

Run (via C<IPC::Run::run()>) the command passed as argument.
The return value from the command is ignored.
The return value is C<($stdout, $stderr)>.

=cut

sub run_command
{
	my ($cmd) = @_;
	my ($stdout, $stderr);
	my $result = IPC::Run::run $cmd, '>', \$stdout, '2>', \$stderr;
	chomp($stdout);
	chomp($stderr);
	return ($stdout, $stderr);
}

=pod

=item generate_ascii_string(from_char, to_char)

Generate a string made of the given range of ASCII characters.

=cut

sub generate_ascii_string
{
	my ($from_char, $to_char) = @_;
	my $res;

	for my $i ($from_char .. $to_char)
	{
		$res .= sprintf("%c", $i);
	}
	return $res;
}

=pod

=item slurp_dir(dir)

Return the complete list of entries in the specified directory.

=cut

sub slurp_dir
{
	my ($dir) = @_;
	opendir(my $dh, $dir)
	  or croak "could not opendir \"$dir\": $!";
	my @direntries = readdir $dh;
	closedir $dh;
	return @direntries;
}

=pod

=item slurp_file(filename [, $offset])

Return the full contents of the specified file, beginning from an
offset position if specified.

=cut

sub slurp_file
{
	my ($filename, $offset) = @_;
	local $/;
	my $contents;
	if ($Config{osname} ne 'MSWin32')
	{
		open(my $in, '<', $filename)
		  or croak "could not read \"$filename\": $!";
		if (defined($offset))
		{
			seek($in, $offset, SEEK_SET)
			  or croak "could not seek \"$filename\": $!";
		}
		$contents = <$in>;
		close $in;
	}
	else
	{
		my $fHandle = createFile($filename, "r", "rwd")
		  or croak "could not open \"$filename\": $^E";
		OsFHandleOpen(my $fh = IO::Handle->new(), $fHandle, 'r')
		  or croak "could not read \"$filename\": $^E\n";
		if (defined($offset))
		{
			setFilePointer($fh, $offset, qw(FILE_BEGIN))
			  or croak "could not seek \"$filename\": $^E\n";
		}
		$contents = <$fh>;
		CloseHandle($fHandle)
		  or croak "could not close \"$filename\": $^E\n";
	}
	$contents =~ s/\r\n/\n/g if $Config{osname} eq 'msys';
	return $contents;
}

=pod

=item append_to_file(filename, str)

Append a string at the end of a given file.  (Note: no newline is appended at
end of file.)

=cut

sub append_to_file
{
	my ($filename, $str) = @_;
	open my $fh, ">>", $filename
	  or croak "could not write \"$filename\": $!";
	print $fh $str;
	close $fh;
	return;
}

=pod

=item check_mode_recursive(dir, expected_dir_mode, expected_file_mode, ignore_list)

Check that all file/dir modes in a directory match the expected values,
ignoring files in C<ignore_list> (basename only).

=cut

sub check_mode_recursive
{
	my ($dir, $expected_dir_mode, $expected_file_mode, $ignore_list) = @_;

	# Result defaults to true
	my $result = 1;

	find(
		{
			follow_fast => 1,
			wanted      => sub {
				# Is file in the ignore list?
				foreach my $ignore ($ignore_list ? @{$ignore_list} : [])
				{
					if ("$dir/$ignore" eq $File::Find::name)
					{
						return;
					}
				}

				# Allow ENOENT.  A running server can delete files, such as
				# those in pg_stat.  Other stat() failures are fatal.
				my $file_stat = stat($File::Find::name);
				unless (defined($file_stat))
				{
					my $is_ENOENT = $!{ENOENT};
					my $msg       = "unable to stat $File::Find::name: $!";
					if ($is_ENOENT)
					{
						warn $msg;
						return;
					}
					else
					{
						die $msg;
					}
				}

				my $file_mode = S_IMODE($file_stat->mode);

				# Is this a file?
				if (S_ISREG($file_stat->mode))
				{
					if ($file_mode != $expected_file_mode)
					{
						print(
							*STDERR,
							sprintf("$File::Find::name mode must be %04o\n",
								$expected_file_mode));

						$result = 0;
						return;
					}
				}

				# Else a directory?
				elsif (S_ISDIR($file_stat->mode))
				{
					if ($file_mode != $expected_dir_mode)
					{
						print(
							*STDERR,
							sprintf("$File::Find::name mode must be %04o\n",
								$expected_dir_mode));

						$result = 0;
						return;
					}
				}

				# Else something we can't handle
				else
				{
					die "unknown file type for $File::Find::name";
				}
			}
		},
		$dir);

	return $result;
}

=pod

=item chmod_recursive(dir, dir_mode, file_mode)

C<chmod> recursively each file and directory within the given directory.

=cut

sub chmod_recursive
{
	my ($dir, $dir_mode, $file_mode) = @_;

	find(
		{
			follow_fast => 1,
			wanted      => sub {
				my $file_stat = stat($File::Find::name);

				if (defined($file_stat))
				{
					chmod(
						S_ISDIR($file_stat->mode) ? $dir_mode : $file_mode,
						$File::Find::name
					) or die "unable to chmod $File::Find::name";
				}
			}
		},
		$dir);
	return;
}

=pod

=item check_pg_config(regexp)

Return the number of matches of the given regular expression
within the installation's C<pg_config.h>.

=cut

sub check_pg_config
{
	my ($regexp) = @_;
	my ($stdout, $stderr);
	my $result = IPC::Run::run [ 'pg_config', '--includedir' ], '>',
	  \$stdout, '2>', \$stderr
	  or die "could not execute pg_config";
	chomp($stdout);
	$stdout =~ s/\r$//;

	open my $pg_config_h, '<', "$stdout/pg_config.h" or die "$!";
	my $match = (grep { /^$regexp/ } <$pg_config_h>);
	close $pg_config_h;
	return $match;
}

=pod

=item dir_symlink(oldname, newname)

Portably create a symlink for a directory. On Windows this creates a junction
point. Elsewhere it just calls perl's builtin symlink.

=cut

sub dir_symlink
{
	my $oldname = shift;
	my $newname = shift;
	if ($windows_os)
	{
		$oldname = perl2host($oldname);
		$newname = perl2host($newname);
		$oldname =~ s,/,\\,g;
		$newname =~ s,/,\\,g;
		my $cmd = qq{mklink /j "$newname" "$oldname"};
		if ($Config{osname} eq 'msys')
		{
			# need some indirection on msys
			$cmd = qq{echo '$cmd' | \$COMSPEC /Q};
		}
		system($cmd);
	}
	else
	{
		symlink $oldname, $newname;
	}
	die "No $newname" unless -e $newname;
}

=pod

=back

=head1 Test::More-LIKE METHODS

=over

=item command_ok(cmd, test_name)

Check that the command runs (via C<run_log>) successfully.

=cut

sub command_ok
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd, $test_name) = @_;
	my $result = run_log($cmd);
	ok($result, $test_name);
	return;
}

=pod

=item command_fails(cmd, test_name)

Check that the command fails (when run via C<run_log>).

=cut

sub command_fails
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd, $test_name) = @_;
	my $result = run_log($cmd);
	ok(!$result, $test_name);
	return;
}

=pod

=item command_exit_is(cmd, expected, test_name)

Check that the command exit code matches the expected exit code.

=cut

sub command_exit_is
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd, $expected, $test_name) = @_;
	print("# Running: " . join(" ", @{$cmd}) . "\n");
	my $h = IPC::Run::start $cmd;
	$h->finish();

	# On Windows, the exit status of the process is returned directly as the
	# process's exit code, while on Unix, it's returned in the high bits
	# of the exit code (see WEXITSTATUS macro in the standard <sys/wait.h>
	# header file). IPC::Run's result function always returns exit code >> 8,
	# assuming the Unix convention, which will always return 0 on Windows as
	# long as the process was not terminated by an exception. To work around
	# that, use $h->full_results on Windows instead.
	my $result =
	    ($Config{osname} eq "MSWin32")
	  ? ($h->full_results)[0]
	  : $h->result(0);
	is($result, $expected, $test_name);
	return;
}

=pod

=item program_help_ok(cmd)

Check that the command supports the C<--help> option.

=cut

sub program_help_ok
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd) = @_;
	my ($stdout, $stderr);
	print("# Running: $cmd --help\n");
	my $result = IPC::Run::run [ $cmd, '--help' ], '>', \$stdout, '2>',
	  \$stderr;
	ok($result, "$cmd --help exit code 0");
	isnt($stdout, '', "$cmd --help goes to stdout");
	is($stderr, '', "$cmd --help nothing to stderr");
	return;
}

=pod

=item program_version_ok(cmd)

Check that the command supports the C<--version> option.

=cut

sub program_version_ok
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd) = @_;
	my ($stdout, $stderr);
	print("# Running: $cmd --version\n");
	my $result = IPC::Run::run [ $cmd, '--version' ], '>', \$stdout, '2>',
	  \$stderr;
	ok($result, "$cmd --version exit code 0");
	isnt($stdout, '', "$cmd --version goes to stdout");
	is($stderr, '', "$cmd --version nothing to stderr");
	return;
}

=pod

=item program_options_handling_ok(cmd)

Check that a command with an invalid option returns a non-zero
exit code and error message.

=cut

sub program_options_handling_ok
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd) = @_;
	my ($stdout, $stderr);
	print("# Running: $cmd --not-a-valid-option\n");
	my $result = IPC::Run::run [ $cmd, '--not-a-valid-option' ], '>',
	  \$stdout,
	  '2>', \$stderr;
	ok(!$result, "$cmd with invalid option nonzero exit code");
	isnt($stderr, '', "$cmd with invalid option prints error message");
	return;
}

=pod

=item command_like(cmd, expected_stdout, test_name)

Check that the command runs successfully and the output
matches the given regular expression.

=cut

sub command_like
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd, $expected_stdout, $test_name) = @_;
	my ($stdout, $stderr);
	print("# Running: " . join(" ", @{$cmd}) . "\n");
	my $result = IPC::Run::run $cmd, '>', \$stdout, '2>', \$stderr;
	ok($result, "$test_name: exit code 0");
	is($stderr, '', "$test_name: no stderr");
	like($stdout, $expected_stdout, "$test_name: matches");
	return;
}

=pod

=item command_like_safe(cmd, expected_stdout, test_name)

Check that the command runs successfully and the output
matches the given regular expression.  Doesn't assume that the
output files are closed.

=cut

sub command_like_safe
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;

	# Doesn't rely on detecting end of file on the file descriptors,
	# which can fail, causing the process to hang, notably on Msys
	# when used with 'pg_ctl start'
	my ($cmd, $expected_stdout, $test_name) = @_;
	my ($stdout, $stderr);
	my $stdoutfile = File::Temp->new();
	my $stderrfile = File::Temp->new();
	print("# Running: " . join(" ", @{$cmd}) . "\n");
	my $result = IPC::Run::run $cmd, '>', $stdoutfile, '2>', $stderrfile;
	$stdout = slurp_file($stdoutfile);
	$stderr = slurp_file($stderrfile);
	ok($result, "$test_name: exit code 0");
	is($stderr, '', "$test_name: no stderr");
	like($stdout, $expected_stdout, "$test_name: matches");
	return;
}

=pod

=item command_fails_like(cmd, expected_stderr, test_name)

Check that the command fails and the error message matches
the given regular expression.

=cut

sub command_fails_like
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;
	my ($cmd, $expected_stderr, $test_name) = @_;
	my ($stdout, $stderr);
	print("# Running: " . join(" ", @{$cmd}) . "\n");
	my $result = IPC::Run::run $cmd, '>', \$stdout, '2>', \$stderr;
	ok(!$result, "$test_name: exit code not 0");
	like($stderr, $expected_stderr, "$test_name: matches");
	return;
}

=pod

=item command_checks_all(cmd, ret, out, err, test_name)

Run a command and check its status and outputs.
Arguments:

=over

=item C<cmd>: Array reference of command and arguments to run

=item C<ret>: Expected exit code

=item C<out>: Expected stdout from command

=item C<err>: Expected stderr from command

=item C<test_name>: test name

=back

=cut

sub command_checks_all
{
	local $Test::Builder::Level = $Test::Builder::Level + 1;

	my ($cmd, $expected_ret, $out, $err, $test_name) = @_;

	# run command
	my ($stdout, $stderr);
	print("# Running: " . join(" ", @{$cmd}) . "\n");
	IPC::Run::run($cmd, '>', \$stdout, '2>', \$stderr);

	# See http://perldoc.perl.org/perlvar.html#%24CHILD_ERROR
	my $ret = $?;
	die "command exited with signal " . ($ret & 127)
	  if $ret & 127;
	$ret = $ret >> 8;

	# check status
	ok($ret == $expected_ret,
		"$test_name status (got $ret vs expected $expected_ret)");

	# check stdout
	for my $re (@$out)
	{
		like($stdout, $re, "$test_name stdout /$re/");
	}

	# check stderr
	for my $re (@$err)
	{
		like($stderr, $re, "$test_name stderr /$re/");
	}

	return;
}

=pod

=back

=cut

1;