From 9a3fb5d4473469bb227d0bb45a61472b4e3330e7 Mon Sep 17 00:00:00 2001
From: Tristan Partin <tristan@partin.io>
Date: Thu, 30 Jul 2026 08:26:26 +0000
Subject: [PATCH v2 2/3] Fix empty proto_version integer parsing in pgoutput

strtoul() can set two different values for errno: EINVAL and ERANGE.
Quoting the man page for EINVAL:

> The value of base is not supported or no conversion could be performed
> (the last feature is not portable across all platforms).

Emphasis on the portion in parentheses.

Take the following example:

    strtoul("", &endptr, 10);

On glibc, the output value is 0, errno *is not* set, and endptr points
to the empty string.

On Apple libc, the output value is 0, errno *is* set to EINVAL, and
endptr points to the empty string.

In this case, when we try to parse an empty string, glibc treats that as
a protocol version of 0, while Apple libc treats that as an invalid
protocol version.

Signed-off-by: Tristan Partin <tristan@partin.io>
---
 src/backend/replication/pgoutput/pgoutput.c |  5 ++--
 src/test/subscription/t/100_bugs.pl         | 31 +++++++++++++++++++++
 2 files changed, 34 insertions(+), 2 deletions(-)

diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 4ecfcbff7ab..0afdb1432ca 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -316,6 +316,7 @@ parse_output_parameters(List *options, PGOutputData *data)
 		{
 			unsigned long parsed;
 			char	   *endptr;
+			const char *val = strVal(defel->arg);
 
 			if (protocol_version_given)
 				ereport(ERROR,
@@ -324,8 +325,8 @@ parse_output_parameters(List *options, PGOutputData *data)
 			protocol_version_given = true;
 
 			errno = 0;
-			parsed = strtoul(strVal(defel->arg), &endptr, 10);
-			if (errno != 0 || *endptr != '\0')
+			parsed = strtoul(val, &endptr, 10);
+			if (endptr == val || errno != 0 || *endptr != '\0')
 				ereport(ERROR,
 						(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
 						 errmsg("invalid proto_version")));
diff --git a/src/test/subscription/t/100_bugs.pl b/src/test/subscription/t/100_bugs.pl
index 075c52f98fd..d9045e4d871 100644
--- a/src/test/subscription/t/100_bugs.pl
+++ b/src/test/subscription/t/100_bugs.pl
@@ -650,6 +650,37 @@ BEGIN
 	"DROP PUBLICATION pub_rowfilter_error");
 $node_publisher->safe_psql('postgres', "DROP TABLE tab_upsert");
 
+# Verify that an empty proto_version option is rejected as invalid
+$node_publisher->safe_psql(
+	'postgres', qq(
+	CREATE TABLE tab_proto_version (a INT PRIMARY KEY);
+	CREATE PUBLICATION pub_proto_version FOR TABLE tab_proto_version;
+	SELECT * FROM pg_create_logical_replication_slot('proto_version_slot', 'pgoutput');
+));
+
+($ret, $stdout, $stderr) = $node_publisher->psql(
+	'postgres', qq(
+	SELECT *
+	FROM pg_logical_slot_peek_binary_changes(
+		'proto_version_slot',
+		NULL,
+		NULL,
+		'proto_version', '',
+		'publication_names', 'pub_proto_version'
+	);
+));
+
+ok($stderr =~ qr/invalid proto_version/,
+	'empty proto_version is rejected as invalid'
+);
+
+# Clean up
+$node_publisher->safe_psql('postgres',
+	"SELECT pg_drop_replication_slot('proto_version_slot')");
+$node_publisher->safe_psql('postgres',
+	"DROP PUBLICATION pub_proto_version");
+$node_publisher->safe_psql('postgres', "DROP TABLE tab_proto_version");
+
 $node_publisher->stop('fast');
 
 done_testing();
-- 
Tristan Partin
https://tristan.partin.io

