0002-Add-TOML-format-support-for-the-pg_hosts-configurati.patch

application/octet-stream

Filename: 0002-Add-TOML-format-support-for-the-pg_hosts-configurati.patch
Type: application/octet-stream
Part: 1
Message: Proposal: new file format for hba/ident/hosts configuration?

Patch

Format: format-patch
Series: patch 0002
Subject: Add TOML format support for the pg_hosts configuration file
File+
src/backend/libpq/be-secure-common.c 227 0
src/backend/libpq/Makefile 2 1
src/backend/libpq/meson.build 1 0
src/backend/libpq/toml_config.c 129 0
src/include/libpq/toml_config.h 48 0
src/test/ssl/meson.build 1 0
src/test/ssl/t/005_sni_toml.pl 105 0
From cdb1c72199d7b6a90d887c4445c5ff0c6d8e36e0 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Tue, 7 Jul 2026 16:55:18 +0000
Subject: [PATCH 2/2] Add TOML format support for the pg_hosts configuration
 file

Support a TOML-format hosts file (selected when hosts_file ends in
".toml"), parsed via the vendored tomlc17 parser
---
 src/backend/libpq/Makefile           |   3 +-
 src/backend/libpq/be-secure-common.c | 227 +++++++++++++++++++++++++++
 src/backend/libpq/meson.build        |   1 +
 src/backend/libpq/toml_config.c      | 129 +++++++++++++++
 src/include/libpq/toml_config.h      |  48 ++++++
 src/test/ssl/meson.build             |   1 +
 src/test/ssl/t/005_sni_toml.pl       | 105 +++++++++++++
 7 files changed, 513 insertions(+), 1 deletion(-)
 create mode 100644 src/backend/libpq/toml_config.c
 create mode 100644 src/include/libpq/toml_config.h
 create mode 100644 src/test/ssl/t/005_sni_toml.pl

diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 98eb2a8242d..65002555f74 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -28,7 +28,8 @@ OBJS = \
 	pqcomm.o \
 	pqformat.o \
 	pqmq.o \
-	pqsignal.o
+	pqsignal.o \
+	toml_config.o
 
 ifeq ($(with_ssl),openssl)
 OBJS += be-secure-openssl.o
diff --git a/src/backend/libpq/be-secure-common.c b/src/backend/libpq/be-secure-common.c
index 6ec887b8a47..c1bd5cf5db5 100644
--- a/src/backend/libpq/be-secure-common.c
+++ b/src/backend/libpq/be-secure-common.c
@@ -24,7 +24,9 @@
 
 #include "common/percentrepl.h"
 #include "common/string.h"
+#include "common/tomlc17.h"
 #include "libpq/libpq.h"
+#include "libpq/toml_config.h"
 #include "storage/fd.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
@@ -351,6 +353,199 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel)
 	return parsedline;
 }
 
+static const char *const hosts_toml_keys[] = {
+	"ssl_certificate",
+	"ssl_key",
+	"ssl_ca",
+	"passphrase_command",
+	"passphrase_command_reload",
+	NULL,
+};
+
+/*
+ * Validate one TOML table (a host entry or the _defaults table) and copy its
+ * values into *out (a HostsLine); string values are pstrdup'd so they survive
+ * freeing the parsed TOML document. "label" is the dotted path for
+ * diagnostics, e.g. "hosts._defaults".
+ *
+ * *reload_present is set true if passphrase_command_reload was specified, so
+ * the merge can tell "omitted" from "set to false".
+ */
+static bool
+hosts_toml_collect(toml_datum_t tab, const char *label, const char *filename,
+				   int elevel, HostsLine *out, bool *reload_present)
+{
+	toml_datum_t cert = toml_get(tab, "ssl_certificate");
+	toml_datum_t key = toml_get(tab, "ssl_key");
+	toml_datum_t ca = toml_get(tab, "ssl_ca");
+	toml_datum_t cmd = toml_get(tab, "passphrase_command");
+	toml_datum_t rel = toml_get(tab, "passphrase_command_reload");
+
+	*reload_present = false;
+
+	if (!toml_reject_unknown(hosts_toml_keys, tab, label, filename, elevel))
+		return false;
+
+	if (cert.type != TOML_UNKNOWN && cert.type != TOML_STRING)
+		return toml_type_error(label, "ssl_certificate", "string", filename, elevel);
+	if (key.type != TOML_UNKNOWN && key.type != TOML_STRING)
+		return toml_type_error(label, "ssl_key", "string", filename, elevel);
+	if (ca.type != TOML_UNKNOWN && ca.type != TOML_STRING)
+		return toml_type_error(label, "ssl_ca", "string", filename, elevel);
+	if (cmd.type != TOML_UNKNOWN && cmd.type != TOML_STRING)
+		return toml_type_error(label, "passphrase_command", "string", filename, elevel);
+	if (rel.type != TOML_UNKNOWN && rel.type != TOML_BOOLEAN)
+		return toml_type_error(label, "passphrase_command_reload", "boolean", filename, elevel);
+
+	if (cert.type == TOML_STRING)
+		out->ssl_cert = pstrdup(cert.u.s);
+	if (key.type == TOML_STRING)
+		out->ssl_key = pstrdup(key.u.s);
+	if (ca.type == TOML_STRING)
+		out->ssl_ca = pstrdup(ca.u.s);
+	if (cmd.type == TOML_STRING)
+		out->ssl_passphrase_cmd = pstrdup(cmd.u.s);
+	if (rel.type == TOML_BOOLEAN)
+	{
+		out->ssl_passphrase_reload = rel.u.boolean;
+		*reload_present = true;
+	}
+
+	return true;
+}
+
+/*
+ * Parse a pg_hosts TOML file into a list of HostsLine.
+ *   *missing    = true  -> file does not exist (ENOENT); returns NIL
+ *   *file_err  != NULL  -> whole-file open/parse error; returns NIL
+ *   *had_errors = true  -> some entries were invalid; they were reported at
+ *                          elevel and left out of the result
+ */
+static List *
+parse_hosts_toml(const char *filename, int elevel,
+				 bool *missing, bool *had_errors, char **file_err)
+{
+	toml_result_t result;
+	toml_datum_t hosts_section;
+	toml_datum_t defaults_dat;
+	HostsLine	defaults = {0};
+	bool		defaults_reload_present = false;
+	bool		have_defaults = false;
+	List	   *entries = NIL;
+
+	*missing = false;
+	*had_errors = false;
+	*file_err = NULL;
+
+	if (!toml_config_load(filename, elevel, &result, file_err))
+	{
+		if (*file_err == NULL)
+			*missing = true;	/* ENOENT */
+		return NIL;
+	}
+
+	hosts_section = toml_get(result.toptab, "hosts");
+	if (hosts_section.type == TOML_UNKNOWN)
+	{
+		toml_free(result);
+		return NIL;				/* no [hosts] table: empty */
+	}
+	if (hosts_section.type != TOML_TABLE)
+	{
+		*file_err = psprintf("\"hosts\" in \"%s\" is not a table", filename);
+		ereport(elevel,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("\"hosts\" in \"%s\" is not a table", filename)));
+		toml_free(result);
+		return NIL;
+	}
+
+	defaults_dat = toml_get(hosts_section, "_defaults");
+	if (defaults_dat.type == TOML_TABLE)
+	{
+		if (hosts_toml_collect(defaults_dat, "hosts._defaults", filename,
+							   elevel, &defaults, &defaults_reload_present))
+			have_defaults = true;
+		else
+			*had_errors = true;
+	}
+	else if (defaults_dat.type != TOML_UNKNOWN)
+	{
+		ereport(elevel,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("\"hosts._defaults\" in \"%s\" must be a table", filename)));
+		*had_errors = true;
+	}
+
+	for (int i = 0; i < hosts_section.u.tab.size; i++)
+	{
+		const char *name = hosts_section.u.tab.key[i];
+		toml_datum_t val = hosts_section.u.tab.value[i];
+		HostsLine  *hl;
+		bool		reload_present = false;
+
+		if (strcmp(name, "_defaults") == 0)
+			continue;
+
+		if (val.type != TOML_TABLE)
+		{
+			ereport(elevel,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("\"hosts.%s\" in \"%s\" must be a table", name, filename)));
+			*had_errors = true;
+			continue;
+		}
+
+		hl = palloc0_object(HostsLine);
+		hl->linenumber = val.lineno;
+		hl->sourcefile = pstrdup(filename);
+		hl->rawline = pstrdup("");
+		hl->hostnames = list_make1(pstrdup(name));
+
+		{
+			char	   *label = psprintf("hosts.%s", name);
+			bool		ok = hosts_toml_collect(val, label, filename, elevel,
+												hl, &reload_present);
+
+			pfree(label);
+			if (!ok)
+			{
+				*had_errors = true;
+				continue;
+			}
+		}
+
+		if (have_defaults)
+		{
+			if (hl->ssl_cert == NULL)
+				hl->ssl_cert = defaults.ssl_cert;
+			if (hl->ssl_key == NULL)
+				hl->ssl_key = defaults.ssl_key;
+			if (hl->ssl_ca == NULL)
+				hl->ssl_ca = defaults.ssl_ca;
+			if (hl->ssl_passphrase_cmd == NULL)
+				hl->ssl_passphrase_cmd = defaults.ssl_passphrase_cmd;
+			if (!reload_present && defaults_reload_present)
+				hl->ssl_passphrase_reload = defaults.ssl_passphrase_reload;
+		}
+
+		if (hl->ssl_cert == NULL || hl->ssl_key == NULL)
+		{
+			ereport(elevel,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("\"hosts.%s\" in \"%s\" is missing required \"ssl_certificate\" or \"ssl_key\"",
+							name, filename)));
+			*had_errors = true;
+			continue;
+		}
+
+		entries = lappend(entries, hl);
+	}
+
+	toml_free(result);
+	return entries;
+}
+
 /*
  * load_hosts
  *
@@ -384,6 +579,38 @@ load_hosts(List **hosts, char **err_msg)
 	}
 	*hosts = NIL;
 
+	if (toml_path(HostsFileName))
+	{
+		bool		missing = false;
+		bool		had_errors = false;
+		char	   *file_err = NULL;
+		List	   *entries;
+
+		entries = parse_hosts_toml(HostsFileName, LOG, &missing, &had_errors,
+								   &file_err);
+		if (missing)
+			return HOSTSFILE_MISSING;
+		if (file_err)
+		{
+			if (err_msg)
+				*err_msg = file_err;
+			return HOSTSFILE_LOAD_FAILED;
+		}
+
+		*hosts = entries;
+
+		if (had_errors)
+		{
+			if (err_msg)
+				*err_msg = psprintf("loading config from \"%s\" failed due to parsing error",
+									HostsFileName);
+			return HOSTSFILE_LOAD_FAILED;
+		}
+		if (entries == NIL)
+			return HOSTSFILE_EMPTY;
+		return HOSTSFILE_LOAD_OK;
+	}
+
 	/*
 	 * This is not an auth file per se, but it is using the same file format
 	 * as the pg_hba and pg_ident files and thus the same code infrastructure.
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 8571f652844..590b18b4b76 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -15,6 +15,7 @@ backend_sources += files(
   'pqformat.c',
   'pqmq.c',
   'pqsignal.c',
+  'toml_config.c',
 )
 
 if ssl.found()
diff --git a/src/backend/libpq/toml_config.c b/src/backend/libpq/toml_config.c
new file mode 100644
index 00000000000..50b1bc8c4ad
--- /dev/null
+++ b/src/backend/libpq/toml_config.c
@@ -0,0 +1,129 @@
+/*-------------------------------------------------------------------------
+ *
+ * toml_config.c
+ *	  Minimal loader for TOML configuration files.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/libpq/toml_config.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <errno.h>
+
+#include "libpq/toml_config.h"
+#include "storage/fd.h"
+
+bool
+toml_config_load(const char *filename, int elevel,
+				 toml_result_t *result, char **err_msg)
+{
+	FILE	   *fp;
+
+	if (err_msg)
+		*err_msg = NULL;
+
+	fp = AllocateFile(filename, "r");
+	if (fp == NULL)
+	{
+		int			save_errno = errno;
+
+		if (save_errno == ENOENT)
+			return false;		/* "missing" - not an error */
+
+		if (err_msg)
+			*err_msg = psprintf("could not open TOML config file \"%s\": %m",
+								filename);
+		errno = save_errno;
+		ereport(elevel,
+				(errcode_for_file_access(),
+				 errmsg("could not open TOML config file \"%s\": %m",
+						filename)));
+		return false;
+	}
+
+	*result = toml_parse_file(fp);
+	FreeFile(fp);
+
+	if (!result->ok)
+	{
+		if (err_msg)
+			*err_msg = psprintf("TOML parse error in \"%s\": %s",
+								filename, result->errmsg);
+		ereport(elevel,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("TOML parse error in \"%s\": %s",
+						filename, result->errmsg)));
+		toml_free(*result);
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Reject keys of "tab" not in the NULL-terminated "allowed" list. "label" is
+ * the dotted path for diagnostics (e.g. "hosts.foo"); each offender is
+ * reported at elevel. Returns true if all keys known.
+ */
+bool
+toml_reject_unknown(const char *const allowed[], toml_datum_t tab,
+					const char *label, const char *filename, int elevel)
+{
+	bool		ok = true;
+
+	for (int i = 0; i < tab.u.tab.size; i++)
+	{
+		const char *key = tab.u.tab.key[i];
+		bool		known = false;
+
+		for (int j = 0; allowed[j] != NULL; j++)
+		{
+			if (strcmp(key, allowed[j]) == 0)
+			{
+				known = true;
+				break;
+			}
+		}
+		if (known)
+			continue;
+
+		ereport(elevel,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("\"%s.%s\" in \"%s\" is not a recognized key",
+						label, key, filename)));
+		ok = false;
+	}
+
+	return ok;
+}
+
+/*
+ * Report that "label.field" in "filename" must be of type "expected" and
+ * return false for use as a one-line bail-out.
+ */
+bool
+toml_type_error(const char *label, const char *field, const char *expected,
+				const char *filename, int elevel)
+{
+	ereport(elevel,
+			(errcode(ERRCODE_CONFIG_FILE_ERROR),
+			 errmsg("\"%s.%s\" in \"%s\" must be a %s",
+					label, field, filename, expected)));
+	return false;
+}
+
+/* True if "filename" names a TOML file (".toml" extension). */
+bool
+toml_path(const char *filename)
+{
+	const char *dot;
+
+	if (filename == NULL)
+		return false;
+	dot = strrchr(filename, '.');
+	return dot != NULL && strcmp(dot, ".toml") == 0;
+}
diff --git a/src/include/libpq/toml_config.h b/src/include/libpq/toml_config.h
new file mode 100644
index 00000000000..9635c2b4f85
--- /dev/null
+++ b/src/include/libpq/toml_config.h
@@ -0,0 +1,48 @@
+/*-------------------------------------------------------------------------
+ *
+ * toml_config.h
+ *	  Minimal loader for TOML configuration files.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/include/libpq/toml_config.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef TOML_CONFIG_H
+#define TOML_CONFIG_H
+
+#include "common/tomlc17.h"
+
+/*
+ * Open and parse a TOML file.
+ *
+ *   return true              -> parsed; caller owns *result and must
+ *                               toml_free(*result) when done
+ *   return false, *err_msg==NULL -> file missing (ENOENT)
+ *   return false, *err_msg!=NULL -> open/parse error (already ereport'd at
+ *                               elevel); *err_msg holds the message
+ */
+extern bool toml_config_load(const char *filename, int elevel,
+							 toml_result_t *result, char **err_msg);
+
+/*
+ * Reject keys of "tab" not in the NULL-terminated "allowed" list. Reports each
+ * offender at elevel. Returns true if all keys known.
+ */
+extern bool toml_reject_unknown(const char *const allowed[], toml_datum_t tab,
+								const char *label, const char *filename,
+								int elevel);
+
+/*
+ * Report that "label.field" in "filename" must be type "expected" and return
+ * false.
+ */
+extern bool toml_type_error(const char *label, const char *field,
+							const char *expected, const char *filename,
+							int elevel);
+
+/* True if "filename" names a TOML file (".toml" extension). */
+extern bool toml_path(const char *filename);
+
+#endif							/* TOML_CONFIG_H */
diff --git a/src/test/ssl/meson.build b/src/test/ssl/meson.build
index d7e7ce23433..2b7552242f7 100644
--- a/src/test/ssl/meson.build
+++ b/src/test/ssl/meson.build
@@ -14,6 +14,7 @@ tests += {
       't/002_scram.pl',
       't/003_sslinfo.pl',
       't/004_sni.pl',
+      't/005_sni_toml.pl',
     ],
   },
 }
diff --git a/src/test/ssl/t/005_sni_toml.pl b/src/test/ssl/t/005_sni_toml.pl
new file mode 100644
index 00000000000..21d67e2f5a6
--- /dev/null
+++ b/src/test/ssl/t/005_sni_toml.pl
@@ -0,0 +1,105 @@
+
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use SSL::Server;
+
+my $SERVERHOSTADDR = '127.0.0.1';
+my $SERVERHOSTCIDR = '127.0.0.1/32';
+
+if ($ENV{with_ssl} ne 'openssl')
+{
+	plan skip_all => 'OpenSSL not supported by this build';
+}
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bssl\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test SSL not enabled in PG_TEST_EXTRA';
+}
+
+my $ssl_server = SSL::Server->new();
+
+if ($ssl_server->is_libressl)
+{
+	plan skip_all => 'SNI not supported when building with LibreSSL';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+
+$ENV{PGHOST} = $node->host;
+$ENV{PGPORT} = $node->port;
+$node->start;
+
+$ssl_server->configure_test_server_for_ssl($node, $SERVERHOSTADDR,
+	$SERVERHOSTCIDR, 'trust');
+
+$ssl_server->switch_server_cert($node, certfile => 'server-cn-only');
+
+my $connstr =
+  "user=ssltestuser dbname=trustdb hostaddr=$SERVERHOSTADDR sslsni=1";
+
+##############################################################################
+# pg_hosts.toml - happy path
+##############################################################################
+
+ok(unlink($node->data_dir . '/pg_hosts.conf'),
+	'remove pg_hosts.conf so dispatch picks pg_hosts.toml');
+
+my $datadir = $node->data_dir;
+my $tomlpath = "$datadir/pg_hosts.toml";
+open(my $fh, '>', $tomlpath)
+  or die "open pg_hosts.toml for writing: $!";
+print $fh <<'TOML';
+[hosts]
+"example.org" = { ssl_certificate = "server-cn-only+server_ca.crt", ssl_key = "server-cn-only.key", ssl_ca = "root_ca.crt" }
+"*" = { ssl_certificate = "server-cn-only.crt", ssl_key = "server-cn-only.key" }
+TOML
+close($fh);
+
+$node->append_conf('pg_hba.conf', "local all all trust");
+$node->append_conf('postgresql.conf',
+	"hosts_file = '$tomlpath'\n" . "ssl_sni = on\n");
+$node->restart;
+
+$node->connect_ok(
+	"$connstr host=example.org sslrootcert=ssl/root_ca.crt sslmode=verify-ca",
+	'pg_hosts.toml: connect to example.org and verify per-host CA');
+
+$node->connect_ok(
+	"$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require",
+	'pg_hosts.toml: connect to default host with sslmode=require');
+
+##############################################################################
+# pg_hosts.toml - broken file logs the TOML parse error on reload
+##############################################################################
+
+open($fh, '>', $tomlpath)
+  or die "open pg_hosts.toml for rewriting: $!";
+print $fh "[hosts\n";    # unterminated table header
+close($fh);
+
+my $log_offset = -s $node->logfile;
+$node->reload;
+$node->wait_for_log(qr/could not load "pg_hosts\.conf"/, $log_offset);
+my $log = PostgreSQL::Test::Utils::slurp_file($node->logfile, $log_offset);
+like(
+	$log,
+	qr/TOML parse error/,
+	"pg_hosts.toml: malformed file produces 'TOML parse error' log entry");
+
+$node->connect_ok(
+	"$connstr host=example.org sslrootcert=ssl/root_ca.crt sslmode=verify-ca",
+	'pg_hosts.toml: connection still works after malformed reload');
+
+done_testing();
-- 
2.43.0