From b4773e4cf28dfa62605981134f62c2b72cddbabc Mon Sep 17 00:00:00 2001
From: Nitin Motiani <nitinmotiani@google.com>
Date: Sat, 15 Feb 2025 04:29:17 +0000
Subject: [PATCH v17 4/5] Add tests for pipe

* These tests include the invalid usages of --pipe-command with other flags.

* Also test pg_dump and pg_restore with pipe command along with various other flags.
---
 src/bin/pg_dump/t/001_basic.pl              |  72 ++++-
 src/bin/pg_dump/t/002_pg_dump.pl            | 296 +++++++++++++++++++-
 src/bin/pg_dump/t/004_pg_dump_parallel.pl   |  88 ++++++
 src/bin/pg_dump/t/005_pg_dump_filterfile.pl |  18 ++
 4 files changed, 464 insertions(+), 10 deletions(-)

diff --git a/src/bin/pg_dump/t/001_basic.pl b/src/bin/pg_dump/t/001_basic.pl
index 687e842cde9..92d47e4fd93 100644
--- a/src/bin/pg_dump/t/001_basic.pl
+++ b/src/bin/pg_dump/t/001_basic.pl
@@ -74,6 +74,48 @@ command_fails_like(
 	'pg_dump: options --statistics-only and --no-statistics cannot be used together'
 );
 
+command_fails_like(
+	[ 'pg_dump', '-Fd', '--pipe="cat"', '-f', 'testdir', 'test'],
+	qr/\Qpg_dump: error: options -f\/--file and --pipe cannot be used together\E/,
+	'pg_dump: options -f/--file and --pipe cannot be used together'
+);
+
+command_fails_like(
+	[ 'pg_dump', '-Fd', '--pipe="cat"', '-Z', 'gzip', 'test'],
+	qr/\Qpg_dump: error: option --pipe is not supported with any compression type\E/,
+	'pg_dump: option --pipe is not supported with any compression type'
+);
+
+command_fails_like(
+	[ 'pg_dump', '-Fd', '--pipe="cat"', '--compress=lz4', 'test'],
+	qr/\Qpg_dump: error: option --pipe is not supported with any compression type\E/,
+	'pg_dump: option --pipe is not supported with any compression type'
+);
+
+command_fails_like(
+	[ 'pg_dump', '-Fd', '--pipe="cat"', '--compress=gzip', 'test'],
+	qr/\Qpg_dump: error: option --pipe is not supported with any compression type\E/,
+	'pg_dump: option --pipe is not supported with any compression type'
+);
+
+command_fails_like(
+	[ 'pg_dump', '-Fd', '--pipe="cat"', '-Z', '1', 'test'],
+	qr/\Qpg_dump: error: option --pipe is not supported with any compression type\E/,
+	'pg_dump: option --pipe is not supported with any compression type'
+);
+
+command_fails_like(
+	[ 'pg_dump', '-Fc', '--pipe="cat"', 'test'],
+	qr/\Qpg_dump: error: option --pipe is only supported with directory format\E/,
+	'pg_dump: option --pipe is only supported with directory format'
+);
+
+command_fails_like(
+	[ 'pg_dump', '--format=tar', '--pipe="cat"', 'test'],
+	qr/\Qpg_dump: error: option --pipe is only supported with directory format\E/,
+	'pg_dump: option --pipe is only supported with directory format'
+);
+
 command_fails_like(
 	[ 'pg_dump', '-j2', '--include-foreign-data=xxx' ],
 	qr/\Qpg_dump: error: option --include-foreign-data is not supported with parallel backup\E/,
@@ -94,12 +136,38 @@ command_fails_like(
 command_fails_like(
 	[ 'pg_restore', '-d', 'xxx', '-f', 'xxx' ],
 	qr/\Qpg_restore: error: options -d\/--dbname and -f\/--file cannot be used together\E/,
-	'pg_restore: options -d/--dbname and -f/--file cannot be used together');
+	'pg_restore: options -d/--dbname and -f/--file cannot be used together'
+);
+
+command_fails_like(
+	[ 'pg_restore', '-f', '-', '--pipe="cat"', 'dumpdir' ],
+	qr/\Qpg_restore: error: cannot specify both an input file and --pipe\E/,
+	'pg_restore: cannot specify both an input file and --pipe'
+);
+
+command_fails_like(
+	[ 'pg_restore', '-Fd', '-f', '-', '--pipe="cat"', 'dumpdir' ],
+	qr/\Qpg_restore: error: cannot specify both an input file and --pipe\E/,
+	'pg_restore: cannot specify both an input file and --pipe'
+);
+
+command_fails_like(
+	[ 'pg_restore', '-Fc', '-f', '-', '--pipe="cat"' ],
+	qr/\Qpg_restore: error: option --pipe is only supported with directory format\E/,
+	'pg_restore: option --pipe is only supported with directory format'
+);
+
+command_fails_like(
+	[ 'pg_restore', '--format=tar', '-f', '-', '--pipe="cat"' ],
+	qr/\Qpg_restore: error: option --pipe is only supported with directory format\E/,
+	'pg_restore: option --pipe is only supported with directory format'
+);
 
 command_fails_like(
 	[ 'pg_dump', '-c', '-a' ],
 	qr/\Qpg_dump: error: options -c\/--clean and -a\/--data-only cannot be used together\E/,
-	'pg_dump: options -c/--clean and -a/--data-only cannot be used together');
+	'pg_dump: options -c/--clean and -a/--data-only cannot be used together'
+);
 
 command_fails_like(
 	[ 'pg_dumpall', '-c', '-a' ],
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 3ee9fda50e4..2e37dcb4482 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -7,8 +7,10 @@ use warnings FATAL => 'all';
 use PostgreSQL::Test::Cluster;
 use PostgreSQL::Test::Utils;
 use Test::More;
+use File::Spec;
 
 my $tempdir = PostgreSQL::Test::Utils::tempdir;
+$tempdir =~ s!\\!/!g if $PostgreSQL::Test::Utils::windows_os;
 
 ###############################################################
 # Definition of the pg_dump runs to make.
@@ -46,6 +48,68 @@ my $tempdir = PostgreSQL::Test::Utils::tempdir;
 my $supports_icu = ($ENV{with_icu} eq 'yes');
 my $supports_gzip = check_pg_config("#define HAVE_LIBZ 1");
 
+# Use perl one-liner as a portable 'cat' replacement for Windows compatibility.
+# On Windows, perl opens file handles in text mode by default, which corrupts
+# binary archive data by translating newlines and interpreting EOF characters.
+# We use -Mopen=IO,:raw to force raw binary mode. We use -pe 1 instead of
+# -pe '' to avoid shell quoting issues with empty strings on Windows cmd.exe.
+my $perlbin = $^X;
+$perlbin =~ s!\\!/!g if $PostgreSQL::Test::Utils::windows_os;
+my $perl_cat = "\"$perlbin\" -Mopen=IO,:raw -pe 1";
+
+# Check for external gzip program for pipe tests.
+my $gzip_path = $ENV{GZIP_PROGRAM} || 'gzip';
+my $gzip_bin = "\"$gzip_path\"";
+my $has_gzip_bin =
+  (system("$gzip_bin --version >" . File::Spec->devnull() . " 2>&1") == 0);
+
+# Pre-calculate complex pipe commands to keep the test definitions readable
+# and ensure unified --pipe=... syntax for Windows stability.
+# On Windows, we use space-wrapped quoting (e.g., " ... ") to protect
+# internal quotes and shell operators from destructive cmd.exe /c stripping.
+my $is_win = $PostgreSQL::Test::Utils::windows_os;
+
+my $raw_pipe_defaults_dir = "$perl_cat > \"$tempdir/defaults_dir_format/%f\"";
+my $raw_pipe_defaults_res = "$perl_cat \"$tempdir/defaults_dir_format/%f\"";
+my $raw_pipe_cross_dump = "$perl_cat > \"$tempdir/pipe_cross_dump/%f\"";
+my $raw_pipe_cross_restore = ($supports_gzip && !$is_win)
+  ? "if [ -f \"$tempdir/pipe_cross_restore/%f.gz\" ]; then $gzip_bin -d -c \"$tempdir/pipe_cross_restore/%f.gz\"; else $perl_cat \"$tempdir/pipe_cross_restore/%f\"; fi"
+  : "$perl_cat \"$tempdir/pipe_cross_restore/%f\"";
+my $raw_pipe_parallel_out = "$perl_cat > \"$tempdir/pipe_out_dir_parallel/%f\"";
+my $raw_pipe_parallel_in = "$perl_cat \"$tempdir/pipe_out_dir_parallel/%f\"";
+my $raw_pipe_parallel_8_out = "$perl_cat > \"$tempdir/pipe_out_dir_parallel_8/%f\"";
+my $raw_pipe_parallel_8_in = "$perl_cat \"$tempdir/pipe_out_dir_parallel_8/%f\"";
+my $raw_pipe_complex_out = "$gzip_bin | $perl_cat > \"$tempdir/pipe_out_dir_complex/%f.gz\"";
+my $raw_pipe_complex_in = "$perl_cat \"$tempdir/pipe_out_dir_complex/%f.gz\" | $gzip_bin -d";
+my $raw_pipe_lo_out = "$perl_cat > \"$tempdir/pipe_out_dir_lo/%f\"";
+my $raw_pipe_lo_in = "$perl_cat \"$tempdir/pipe_out_dir_lo/%f\"";
+my $raw_pipe_schema_out = "$perl_cat > \"$tempdir/schema_only_pipe_dir/%f\"";
+my $raw_pipe_schema_in = "$perl_cat \"$tempdir/schema_only_pipe_dir/%f\"";
+
+my $pipe_defaults_dir = $is_win ? "\" $raw_pipe_defaults_dir \"" : $raw_pipe_defaults_dir;
+my $pipe_defaults_res = $is_win ? "\" $raw_pipe_defaults_res \"" : $raw_pipe_defaults_res;
+my $pipe_cross_dump = $is_win ? "\" $raw_pipe_cross_dump \"" : $raw_pipe_cross_dump;
+my $pipe_cross_restore = $is_win ? "\" $raw_pipe_cross_restore \"" : $raw_pipe_cross_restore;
+my $pipe_parallel_out = $is_win ? "\" $raw_pipe_parallel_out \"" : $raw_pipe_parallel_out;
+my $pipe_parallel_in = $is_win ? "\" $raw_pipe_parallel_in \"" : $raw_pipe_parallel_in;
+my $pipe_parallel_8_out = $is_win ? "\" $raw_pipe_parallel_8_out \"" : $raw_pipe_parallel_8_out;
+my $pipe_parallel_8_in = $is_win ? "\" $raw_pipe_parallel_8_in \"" : $raw_pipe_parallel_8_in;
+my $pipe_complex_out = $is_win ? "\" $raw_pipe_complex_out \"" : $raw_pipe_complex_out;
+my $pipe_complex_in = $is_win ? "\" $raw_pipe_complex_in \"" : $raw_pipe_complex_in;
+my $pipe_lo_out = $is_win ? "\" $raw_pipe_lo_out \"" : $raw_pipe_lo_out;
+my $pipe_lo_in = $is_win ? "\" $raw_pipe_lo_in \"" : $raw_pipe_lo_in;
+my $pipe_schema_out = $is_win ? "\" $raw_pipe_schema_out \"" : $raw_pipe_schema_out;
+my $pipe_schema_in = $is_win ? "\" $raw_pipe_schema_in \"" : $raw_pipe_schema_in;
+
+# Create output directories for pipe tests
+mkdir "$tempdir/pipe_out_dir_parallel";
+mkdir "$tempdir/pipe_out_dir_parallel_8";
+mkdir "$tempdir/pipe_out_dir_complex";
+mkdir "$tempdir/pipe_out_dir_lo";
+mkdir "$tempdir/pipe_cross_dump";
+mkdir "$tempdir/pipe_cross_restore";
+mkdir "$tempdir/schema_only_pipe_dir";
+
 my %pgdump_runs = (
 	binary_upgrade => {
 		dump_cmd => [
@@ -223,6 +287,139 @@ my %pgdump_runs = (
 		],
 	},
 
+	defaults_dir_format_pipe => {
+		test_key => 'defaults',
+		dump_cmd => [
+			'pg_dump',
+			'--format' => 'directory',
+			"--pipe=$pipe_defaults_dir",
+			'--statistics',
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/defaults_dir_format_pipe.sql",
+			"--pipe=$pipe_defaults_res",
+			'--statistics',
+		],
+	},
+
+	defaults_dir_format_pipe_dump_only => {
+		test_key => 'defaults',
+		dump_cmd => [
+			'pg_dump',
+			'--format' => 'directory',
+			"--pipe=$pipe_cross_dump",
+			'--statistics',
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/defaults_dir_format_pipe_dump_only.sql",
+			'--statistics',
+			"$tempdir/pipe_cross_dump",
+		],
+	},
+
+	defaults_dir_format_pipe_restore_only => {
+		test_key => 'defaults',
+		dump_cmd => [
+			'pg_dump',
+			'--format' => 'directory',
+			'--file' => "$tempdir/pipe_cross_restore",
+			'--statistics',
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/defaults_dir_format_pipe_restore_only.sql",
+			"--pipe=$pipe_cross_restore",
+			'--statistics',
+		],
+	},
+
+	defaults_parallel_pipe => {
+		test_key => 'defaults',
+		dump_cmd => [
+			'pg_dump',
+			'--format' => 'directory',
+			'--jobs' => 2,
+			"--pipe=$pipe_parallel_out",
+			'--statistics',
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/defaults_parallel_pipe.sql",
+			"--pipe=$pipe_parallel_in",
+			'--statistics',
+		],
+	},
+
+	defaults_parallel_8_pipe => {
+		test_key => 'defaults',
+		dump_cmd => [
+			'pg_dump',
+			'--format' => 'directory',
+			'--jobs' => 8,
+			"--pipe=$pipe_parallel_8_out",
+			'--statistics',
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/defaults_parallel_8_pipe.sql",
+			"--pipe=$pipe_parallel_8_in",
+			'--statistics',
+		],
+	},
+
+	defaults_complex_pipe => {
+		test_key => 'defaults',
+		skip_unless => \$has_gzip_bin,
+		dump_cmd => [
+			'pg_dump',
+			'--format' => 'directory',
+			"--pipe=$pipe_complex_out",
+			'--statistics',
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/defaults_complex_pipe.sql",
+			"--pipe=$pipe_complex_in",
+			'--statistics',
+		],
+	},
+
+	defaults_lo_pipe => {
+		test_key => 'defaults',
+		dump_cmd => [
+			'pg_dump',
+			'--format' => 'directory',
+			'--statistics',
+			"--pipe=$pipe_lo_out",
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/defaults_lo_pipe.sql",
+			'--statistics',
+			"--pipe=$pipe_lo_in",
+		],
+		glob_patterns => [
+			"$tempdir/pipe_out_dir_lo/toc.dat",
+			"$tempdir/pipe_out_dir_lo/blobs_*.toc",
+		],
+	},
+
 	# Do not use --no-sync to give test coverage for data sync.
 	defaults_parallel => {
 		test_key => 'defaults',
@@ -527,6 +724,22 @@ my %pgdump_runs = (
 			'postgres',
 		],
 	},
+	schema_only_pipe => {
+		test_key => 'schema_only',
+		dump_cmd => [
+			'pg_dump', '--no-sync',
+			'--format' => 'directory',
+			'--schema-only',
+			"--pipe=$pipe_schema_out",
+			'postgres',
+		],
+		restore_cmd => [
+			'pg_restore',
+			'--format' => 'directory',
+			'--file' => "$tempdir/schema_only_pipe.sql",
+			"--pipe=$pipe_schema_in",
+		],
+	},
 	section_pre_data => {
 		dump_cmd => [
 			'pg_dump', '--no-sync',
@@ -5212,25 +5425,24 @@ command_fails_like(
 #########################################
 # Run all runs
 
+
 foreach my $run (sort keys %pgdump_runs)
 {
 	my $test_key = $run;
-	my $run_db = 'postgres';
+	my $run_db   = 'postgres';
 
 	$node->command_ok(\@{ $pgdump_runs{$run}->{dump_cmd} },
 		"$run: pg_dump runs");
 
 	if ($pgdump_runs{$run}->{glob_patterns})
 	{
-		my $glob_patterns = $pgdump_runs{$run}->{glob_patterns};
-		foreach my $glob_pattern (@{$glob_patterns})
+		foreach my $glob_pattern (@{ $pgdump_runs{$run}->{glob_patterns} })
 		{
-			my @glob_output = glob($glob_pattern);
 			my $ok = 0;
-			# certainly found some files if glob() returned multiple matches
-			$ok = 1 if (scalar(@glob_output) > 1);
-			# if just one match, we need to check if it's real
-			$ok = 1 if (scalar(@glob_output) == 1 && -f $glob_output[0]);
+			foreach my $file (glob("$glob_pattern"))
+			{
+				$ok = 1 if -e $file;
+			}
 			is($ok, 1, "$run: glob check for $glob_pattern");
 		}
 	}
@@ -5334,6 +5546,74 @@ foreach my $run (sort keys %pgdump_runs)
 	}
 }
 
+#########################################
+# Test error reporting for a failing pipe command.
+# We use a perl one-liner that exits with 1 after processing input.
+# This ensures we test the error handling in pclose() at the end of the dump,
+# verifying that the child's exit status is correctly captured and reported.
+my $failing_perl_cat = "\"$perlbin\" -Mopen=IO,:raw -pe \"END { exit 1 }\"";
+
+# Verify that pg_dump's error message includes the exact failing command,
+# including the specific "-pe "END { exit 1 }"" payload.
+$node->command_fails_like(
+	[ 'pg_dump', '-Fd', $is_win ? "--pipe=\"$failing_perl_cat > \\\"%f\\\"\"" : "--pipe=$failing_perl_cat > \"%f\"", 'postgres' ],
+	qr/pipe command failed: ".*perl.*-Mopen=IO,:raw.*-pe.*END \{ exit 1 \}.*": /,
+	'pg_dump pipe command error reporting includes full command string'
+);
+
+
+# Verify that pg_restore's error message also includes the exact failing command
+# and its arguments, including the target file path.
+$node->command_fails_like(
+	[ 'pg_restore', '-Fd', '-l', $is_win ? "--pipe=\" $failing_perl_cat \\\"$tempdir/pipe_cross_dump/%f\\\" \"" : "--pipe=$failing_perl_cat \"$tempdir/pipe_cross_dump/%f\"", ],
+	qr/pipe command failed: ".*perl.*-Mopen=IO,:raw.*-pe.*END \{ exit 1 \}.*pipe_cross_dump.*": /,
+	'pg_restore pipe command error reporting includes full command string'
+);
+
+# Targeted Edge Case Tests
+$node->command_fails_like(
+	[ 'pg_dump', '-Fd', '--pipe=/nonexistent/binary', 'postgres' ],
+	qr/could not write to file: (?:Broken pipe|The pipe has been ended)|Permission denied/,
+	'pg_dump early pipe command execution failure'
+);
+
+$node->command_fails_like(
+	[ 'pg_dump', '-Fd', '--pipe=no_such_command_at_all', 'postgres' ],
+	qr/could not write to file: (?:Broken pipe|The pipe has been ended)|not found|not recognized/,
+	'pg_dump command not found error reporting'
+);
+
+$node->command_fails_like(
+	[ 'pg_dump', '-Fd', '-f', '-', $is_win ? "--pipe=\" $perl_cat > \\\"%f\\\" \"" : "--pipe=$perl_cat > \"%f\"", 'postgres' ],
+	qr/options -f\/--file and --pipe cannot be used together/,
+	'pg_dump options -f/--file and --pipe conflict check'
+);
+
+# Test that pg_restore rejects a positional argument when --pipe is used.
+# We create a dummy cluster archive (containing toc.glo) to verify that
+# even in cluster mode, the mutual exclusivity holds.
+mkdir "$tempdir/dummy_cluster_archive";
+open my $fh, '>', "$tempdir/dummy_cluster_archive/toc.glo";
+close $fh;
+
+$node->command_fails_like(
+	[ 'pg_restore', '-Fd', '-l', $is_win ? "--pipe=\" $perl_cat \\\"%f\\\" \"" : "--pipe=$perl_cat \"%f\"", "$tempdir/dummy_cluster_archive" ],
+	qr/cannot specify both an input file and --pipe/,
+	'pg_restore --pipe rejects positional argument even for cluster archive'
+);
+
+# Test that pg_dump --pipe bypasses local directory existence check.
+# We use a pipe command that writes to a subdirectory that hasn't been created.
+# The dump itself will fail when the pipe command tries to write to the
+# non-existent directory, but the error should come from the pipe command/write
+# failure, not from pg_dump's directory initialization.
+my $remote_dir = "$tempdir/non_existent_remote_dir";
+$node->command_fails_like(
+	[ 'pg_dump', '-Fd', $is_win ? "--pipe=\" $perl_cat > \\\"$remote_dir/%f\\\" \"" : "--pipe=$perl_cat > \"$remote_dir/%f\"", 'postgres' ],
+	qr/could not write to file: (?:Broken pipe|The pipe has been ended)|pipe command failed/,
+	'pg_dump --pipe bypasses local directory existence check'
+);
+
 #########################################
 # Stop the database instance, which will be removed at the end of the tests.
 
diff --git a/src/bin/pg_dump/t/004_pg_dump_parallel.pl b/src/bin/pg_dump/t/004_pg_dump_parallel.pl
index 738f34b1c1b..57edf5697c0 100644
--- a/src/bin/pg_dump/t/004_pg_dump_parallel.pl
+++ b/src/bin/pg_dump/t/004_pg_dump_parallel.pl
@@ -8,19 +8,33 @@ use PostgreSQL::Test::Cluster;
 use PostgreSQL::Test::Utils;
 use Test::More;
 
+# Use perl one-liner as a portable 'cat' replacement for Windows compatibility.
+# On Windows, perl opens file handles in text mode by default, which corrupts
+# binary archive data by translating newlines and interpreting EOF characters.
+# We use -Mopen=IO,:raw to force raw binary mode. We use -pe 1 instead of
+# -pe '' to avoid shell quoting issues with empty strings on Windows cmd.exe.
+my $perlbin = $^X;
+$perlbin =~ s!\\!/!g if $PostgreSQL::Test::Utils::windows_os;
+my $perl_cat = "\"$perlbin\" -Mopen=IO,:raw -pe 1";
+
 my $dbname1 = 'regression_src';
 my $dbname2 = 'regression_dest1';
 my $dbname3 = 'regression_dest2';
+my $dbname4 = 'regression_dest3';
+my $dbname5 = 'regression_dest_warning';
 
 my $node = PostgreSQL::Test::Cluster->new('main');
 $node->init;
 $node->start;
 
 my $backupdir = $node->backup_dir;
+$backupdir =~ s!\\!/!g if $PostgreSQL::Test::Utils::windows_os;
 
 $node->run_log([ 'createdb', $dbname1 ]);
 $node->run_log([ 'createdb', $dbname2 ]);
 $node->run_log([ 'createdb', $dbname3 ]);
+$node->run_log([ 'createdb', $dbname4 ]);
+$node->run_log([ 'createdb', $dbname5 ]);
 
 $node->safe_psql(
 	$dbname1,
@@ -87,4 +101,78 @@ $node->command_ok(
 	],
 	'parallel restore as inserts');
 
+mkdir "$backupdir/dump_pipe";
+
+# Pre-calculate pipe commands for readability and unified syntax.
+# Use space-wrapped quoting only on Windows to protect shell operators.
+my $is_win = $PostgreSQL::Test::Utils::windows_os;
+my $raw_pipe_dump = "$perl_cat > \"$backupdir/dump_pipe/%f\"";
+my $raw_pipe_restore = "$perl_cat \"$backupdir/dump_pipe/%f\"";
+
+my $pipe_dump = $is_win ? "\" $raw_pipe_dump \"" : $raw_pipe_dump;
+my $pipe_restore = $is_win ? "\" $raw_pipe_restore \"" : $raw_pipe_restore;
+
+$node->command_ok(
+	[
+		'pg_dump',
+		'--format' => 'directory',
+		'--no-sync',
+		'--jobs' => 2,
+		"--pipe=$pipe_dump",
+		$node->connstr($dbname1),
+	],
+	'parallel dump with pipe');
+
+$node->command_ok(
+	[
+		'pg_restore', '--verbose',
+		'--dbname' => $node->connstr($dbname4),
+		'--format' => 'directory',
+		'--jobs' => 3,
+		"--pipe=$pipe_restore",
+	],
+	'parallel restore with pipe');
+
+# Test warning when parallel jobs are used without %f in pipe command.
+# Use a simple command that doesn't use %f.
+my $pipe_no_f = $is_win ? "\" $perl_cat > \\\"$backupdir/no_f.out\\\" \"" : "$perl_cat > \"$backupdir/no_f.out\"";
+$node->command_checks_all(
+	[
+		'pg_dump',
+		'--format' => 'directory',
+		'--no-sync',
+		'--jobs' => 2,
+		"--pipe=$pipe_no_f",
+		$node->connstr($dbname1),
+	],
+	0,
+	[],
+	[qr/parallel jobs with --pipe usually require the "%f" placeholder/],
+	'parallel dump without %f issues warning');
+
+# Test warning in pg_restore when parallel jobs are used without %f.
+# We restore into a clean database ($dbname5) using --schema-only to prevent
+# pg_restore from attempting to read table data files. We configure the pipe
+# command to cat the TOC file directly (lacking %f), which allows pg_restore
+# to successfully read and restore the metadata and exit with 0, while still
+# emitting the warning on stderr.
+my $pipe_restore_no_f_success = $is_win
+  ? "\" $perl_cat \\\"$backupdir/dump_pipe/toc.dat\\\" \""
+  : "$perl_cat \"$backupdir/dump_pipe/toc.dat\"";
+
+$node->command_checks_all(
+	[
+		'pg_restore',
+		'--format' => 'directory',
+		'--jobs' => 2,
+		'--schema-only',
+		"--pipe=$pipe_restore_no_f_success",
+		'--dbname' => $node->connstr($dbname5),
+	],
+	0,
+	[],
+	[qr/parallel jobs with --pipe usually require the "%f" placeholder/],
+	'parallel restore without %f issues warning and succeeds');
+
 done_testing();
+
diff --git a/src/bin/pg_dump/t/005_pg_dump_filterfile.pl b/src/bin/pg_dump/t/005_pg_dump_filterfile.pl
index cecf0442088..f4b1afd8ef6 100644
--- a/src/bin/pg_dump/t/005_pg_dump_filterfile.pl
+++ b/src/bin/pg_dump/t/005_pg_dump_filterfile.pl
@@ -8,6 +8,11 @@ use PostgreSQL::Test::Cluster;
 use PostgreSQL::Test::Utils;
 use Test::More;
 
+# Use perl one-liner as a portable 'cat' replacement for Windows compatibility.
+my $perlbin = $^X;
+$perlbin =~ s!\\!/!g if $PostgreSQL::Test::Utils::windows_os;
+my $perl_cat = "$perlbin -pe ''";
+
 my $tempdir = PostgreSQL::Test::Utils::tempdir;
 my $inputfile;
 
@@ -98,6 +103,19 @@ command_ok(
 	],
 	"filter file without patterns");
 
+mkdir "$backupdir/dump_pipe_filter";
+
+command_ok(
+	[
+		'pg_dump',
+		'--port' => $port,
+		'--format' => 'directory',
+		'--pipe' => "$perl_cat > $backupdir/dump_pipe_filter/%f",
+		'--filter' => "$tempdir/inputfile.txt",
+		'postgres'
+	],
+	"filter file without patterns with pipe");
+
 my $dump = slurp_file($plainfile);
 
 like($dump, qr/^CREATE TABLE public\.table_one/m, "table one dumped");
-- 
2.54.0.1064.gd145956f57-goog

