From 119a0cd7dfa72fedce1beda4ea6172d5c935708c Mon Sep 17 00:00:00 2001
From: Diego <mrstephenamell@gmail.com>
Date: Mon, 31 Aug 2026 16:38:53 -0300
Subject: [PATCH v1] libpq: Add PQpassfileLookup()

An application that connects through an intermediary, such as a local
SSH tunnel, connects to a host and port that no longer match the
password file entry written for the real server, so libpq's password
file lookup comes up empty during connection establishment.  Until now,
such an application had to reimplement the password file parser on its
side to keep .pgpass working.

Expose the existing lookup as a public function, PQpassfileLookup(), so
that a client can look up the password under the real server's host and
port and pass the result as the password connection parameter while
connecting to the intermediary's address.  The function applies the
same rules as connection establishment: the same field matching and
de-escaping, the same localhost and default-port handling, the same
permission checks, and the same fallback to PGPASSFILE and the default
password file location when no file is given.

Also add a --passfile mode to libpq_testclient, and a TAP test
exercising the lookup; it needs no server.

Author: Diego <mrstephenamell@gmail.com>
Suggested-by: Denis Smirnov <darthunix@gmail.com>
Discussion: https://postgr.es/m/B2EDB5AE-27F7-4580-871E-1C2433BAEF18@gmail.com
---
 doc/src/sgml/libpq.sgml                      |  59 +++++++++
 src/interfaces/libpq/exports.txt             |   1 +
 src/interfaces/libpq/fe-connect.c            |  50 ++++++++
 src/interfaces/libpq/libpq-fe.h              |   9 ++
 src/interfaces/libpq/meson.build             |   1 +
 src/interfaces/libpq/t/007_passfile.pl       | 128 +++++++++++++++++++
 src/interfaces/libpq/test/libpq_testclient.c |  36 +++++-
 7 files changed, 283 insertions(+), 1 deletion(-)
 create mode 100644 src/interfaces/libpq/t/007_passfile.pl

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 68487a3954f..5a439d55f83 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -7976,6 +7976,59 @@ char *PQencryptPassword(const char *passwd, const char *user);
     </listitem>
    </varlistentry>
 
+   <varlistentry id="libpq-PQpassfileLookup">
+    <term><function>PQpassfileLookup</function><indexterm><primary>PQpassfileLookup</primary></indexterm></term>
+
+    <listitem>
+     <para>
+      Looks up a password in a password file
+      (see <xref linkend="libpq-pgpass"/>).
+<synopsis>
+char *PQpassfileLookup(const char *hostname, const char *port, const char *dbname, const char *username, const char *passfile);
+</synopsis>
+     </para>
+
+     <para>
+      This function performs the same password file lookup that connection
+      establishment performs when no password has been specified, and
+      returns the password from the first matching line.  It is intended
+      for applications that connect through an intermediary, for example a
+      local SSH tunnel: such an application can look up the password under
+      the real server's host and port, and then pass the result as the
+      <xref linkend="libpq-connect-password"/> connection parameter while
+      connecting to the intermediary's address.
+     </para>
+
+     <para>
+      The <parameter>hostname</parameter>, <parameter>port</parameter>,
+      <parameter>dbname</parameter> and <parameter>username</parameter>
+      arguments correspond to the first four fields of a password file
+      line.  If <parameter>hostname</parameter> is <symbol>NULL</symbol> or
+      empty, or matches <application>libpq</application>'s default socket
+      directory path, the host name <literal>localhost</literal> is
+      searched for; if <parameter>port</parameter> is <symbol>NULL</symbol>
+      or empty, the compiled-in default port is used.  No defaults are
+      applied for <parameter>dbname</parameter> and
+      <parameter>username</parameter>; if either is <symbol>NULL</symbol>
+      or empty, no password is returned.
+      <parameter>passfile</parameter> is the password file to use; if it is
+      <symbol>NULL</symbol> or empty, the file named by the
+      <envar>PGPASSFILE</envar> environment variable is used if set, else
+      the default password file location.
+     </para>
+
+     <para>
+      The return value is a string allocated by <function>malloc</function>,
+      or <symbol>NULL</symbol> if no password could be found.  Use
+      <xref linkend="libpq-PQfreemem"/> to free the result when done with
+      it.  The password file permission requirements described in
+      <xref linkend="libpq-pgpass"/> apply, and, as during connection
+      establishment, a warning is written to <filename>stderr</filename>
+      if the file is ignored because of them.
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry id="libpq-PQmakeEmptyPGresult">
     <term><function>PQmakeEmptyPGresult</function><indexterm><primary>PQmakeEmptyPGresult</primary></indexterm></term>
 
@@ -9462,6 +9515,12 @@ myEventProc(PGEventId evtId, void *evtInfo, void *passThrough)
    is assumed that the file is stored in a directory that is secure, so
    no special permissions check is made.
   </para>
+
+  <para>
+   An application can perform the same password file lookup that
+   connection establishment performs by calling
+   <xref linkend="libpq-PQpassfileLookup"/>.
+  </para>
  </sect1>
 
 
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 1e3d5bd5867..def61d63724 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -211,3 +211,4 @@ PQdefaultAuthDataHook     208
 PQfullProtocolVersion     209
 appendPQExpBufferVA       210
 PQgetThreadLock           211
+PQpassfileLookup          212
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index ec98c58a407..e6beda8c002 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -8142,6 +8142,56 @@ passwordFromFile(const char *hostname, const char *port,
 }
 
 
+/*
+ * PQpassfileLookup
+ *
+ * Look up a password in a password file, applying the same rules that
+ * connection establishment applies when no password has been specified.
+ * This lets applications that connect through an intermediary (for
+ * example, a local SSH tunnel) look up the password under the real
+ * server's host and port while connecting elsewhere.
+ *
+ * The first four arguments correspond to the fields of a password file
+ * line, and NULL or empty values are treated the same way as during
+ * connection establishment: hostname is matched as "localhost", port
+ * defaults to DEF_PGPORT_STR, while dbname and username must be
+ * supplied.  If passfile is NULL or empty, PGPASSFILE or the default
+ * password file location is used.
+ *
+ * Returns a malloc'd string the caller must free with PQfreemem(), or
+ * NULL if no password could be found.
+ */
+char *
+PQpassfileLookup(const char *hostname, const char *port,
+				 const char *dbname, const char *username,
+				 const char *passfile)
+{
+	char		pgpassfile[MAXPGPATH];
+	const char *errmsg;
+
+	if (passfile == NULL || passfile[0] == '\0')
+	{
+		const char *pgpassenv = getenv("PGPASSFILE");
+
+		if (pgpassenv != NULL && pgpassenv[0] != '\0')
+			strlcpy(pgpassfile, pgpassenv, sizeof(pgpassfile));
+		else
+		{
+			char		homedir[MAXPGPATH];
+
+			if (!pqGetHomeDirectory(homedir, sizeof(homedir)))
+				return NULL;
+			snprintf(pgpassfile, sizeof(pgpassfile), "%s/%s",
+					 homedir, PGPASSFILE);
+		}
+		passfile = pgpassfile;
+	}
+
+	return passwordFromFile(hostname, port, dbname, username,
+							passfile, &errmsg);
+}
+
+
 /*
  *	If the connection failed due to bad password, we should mention
  *	if we got the password from the pgpassfile.
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index f51fd620b0a..b63489a3bfe 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -69,6 +69,10 @@ extern "C"
 /* Indicates presence of the PQAUTHDATA_OAUTH_BEARER_TOKEN_V2 authdata hook */
 #define LIBPQ_HAS_OAUTH_BEARER_TOKEN_V2 1
 
+/* Features added in PostgreSQL v20: */
+/* Indicates presence of PQpassfileLookup */
+#define LIBPQ_HAS_PASSFILE_LOOKUP 1
+
 /*
  * Option flags for PQcopyResult
  */
@@ -367,6 +371,11 @@ extern PQconninfoOption *PQconninfo(PGconn *conn);
 /* free the data structure returned by PQconndefaults() or PQconninfoParse() */
 extern void PQconninfoFree(PQconninfoOption *connOptions);
 
+/* look up a password in a password file */
+extern char *PQpassfileLookup(const char *hostname, const char *port,
+							  const char *dbname, const char *username,
+							  const char *passfile);
+
 /*
  * close the current connection and reestablish a new one with the same
  * parameters
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index b0ae72167a1..b9f93ddb852 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -161,6 +161,7 @@ tests += {
       't/004_load_balance_dns.pl',
       't/005_negotiate_encryption.pl',
       't/006_service.pl',
+      't/007_passfile.pl',
     ],
     'env': {
       'with_ssl': ssl_library,
diff --git a/src/interfaces/libpq/t/007_passfile.pl b/src/interfaces/libpq/t/007_passfile.pl
new file mode 100644
index 00000000000..d5c839ff500
--- /dev/null
+++ b/src/interfaces/libpq/t/007_passfile.pl
@@ -0,0 +1,128 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+use strict;
+use warnings FATAL => 'all';
+use File::Copy;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Test PQpassfileLookup(), via libpq_testclient --passfile.  The lookup is
+# purely client-side, so no server is involved.  An argument of "-" is
+# passed to the function as NULL.
+
+my $td = PostgreSQL::Test::Utils::tempdir;
+my $passfile = "$td/pgpass";
+
+delete $ENV{PGPASSFILE};
+
+append_to_file($passfile, <<'EOF');
+# a comment line
+server.example.com:5432:proddb:diego:secret1
+server.example.com:5433:*:diego:secret2
+localhost:*:mydb:me:localpw
+special.example.com:5432:db\:colon:us\\er:pa\\ss\:word
+*:*:*:fallback:fallpw:anything after an unescaped colon is ignored
+server.example.com:5432:proddb:diego:shadowed
+EOF
+chmod 0600, $passfile or die "chmod: $!";
+
+my ($out, $err);
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', $passfile,
+		'server.example.com', '5432', 'proddb', 'diego'
+	]);
+is($out, 'secret1', 'exact match returns the first matching password');
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', $passfile,
+		'server.example.com', '5433', 'anydb', 'diego'
+	]);
+is($out, 'secret2', 'wildcard field matches any value');
+
+($out, $err) = run_command(
+	[ 'libpq_testclient', '--passfile', $passfile, '-', '-', 'mydb', 'me' ]);
+is($out, 'localpw', 'NULL hostname and port match a localhost entry');
+
+($out, $err) = run_command(
+	[ 'libpq_testclient', '--passfile', $passfile, '', '-', 'mydb', 'me' ]);
+is($out, 'localpw', 'empty hostname matches a localhost entry');
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', $passfile,
+		'special.example.com', '5432', 'db:colon', 'us\\er'
+	]);
+is($out, 'pa\\ss:word', 'escaped characters are matched and de-escaped');
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', $passfile,
+		'anyhost', '1234', 'anydb', 'fallback'
+	]);
+is($out, 'fallpw', 'password field ends at the first unescaped colon');
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', $passfile,
+		'server.example.com', '5432', 'otherdb', 'diego'
+	]);
+is($err, 'no password found', 'no matching line returns no password');
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', "$td/does_not_exist",
+		'server.example.com', '5432', 'proddb', 'diego'
+	]);
+is($err, 'no password found', 'missing password file returns no password');
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', $passfile,
+		'server.example.com', '5432', '-', 'diego'
+	]);
+is($err, 'no password found', 'NULL dbname returns no password');
+
+($out, $err) = run_command(
+	[
+		'libpq_testclient', '--passfile', $passfile,
+		'server.example.com', '5432', 'proddb', '-'
+	]);
+is($err, 'no password found', 'NULL username returns no password');
+
+# A NULL passfile falls back to the PGPASSFILE environment variable.
+{
+	local $ENV{PGPASSFILE} = $passfile;
+
+	($out, $err) = run_command(
+		[
+			'libpq_testclient', '--passfile', '-',
+			'server.example.com', '5432', 'proddb', 'diego'
+		]);
+	is($out, 'secret1', 'NULL passfile falls back to PGPASSFILE');
+}
+
+SKIP:
+{
+	skip 'password file permissions are not checked on Windows', 2
+	  if $windows_os;
+
+	my $passfile_insecure = "$td/pgpass_insecure";
+	copy($passfile, $passfile_insecure)
+	  or die "could not copy $passfile to $passfile_insecure: $!";
+	chmod 0644, $passfile_insecure or die "chmod: $!";
+
+	($out, $err) = run_command(
+		[
+			'libpq_testclient', '--passfile', $passfile_insecure,
+			'server.example.com', '5432', 'proddb', 'diego'
+		]);
+	like(
+		$err,
+		qr/has group or world access/,
+		'insecure password file draws a warning');
+	like($err, qr/no password found/, 'insecure password file is ignored');
+}
+
+done_testing();
diff --git a/src/interfaces/libpq/test/libpq_testclient.c b/src/interfaces/libpq/test/libpq_testclient.c
index 20730709ee7..bfdce989e33 100644
--- a/src/interfaces/libpq/test/libpq_testclient.c
+++ b/src/interfaces/libpq/test/libpq_testclient.c
@@ -23,6 +23,38 @@ print_ssl_library(void)
 		printf("%s\n", lib);
 }
 
+/*
+ * Look up a password with PQpassfileLookup().  The arguments are passfile,
+ * hostname, port, dbname and username; an argument of "-" is passed as NULL.
+ */
+static int
+test_passfile_lookup(int argc, char *argv[])
+{
+	const char *args[5];
+	char	   *password;
+
+	if (argc < 7)
+	{
+		fprintf(stderr, "usage: libpq_testclient --passfile PASSFILE HOSTNAME PORT DBNAME USERNAME\n");
+		return 1;
+	}
+
+	for (int i = 0; i < 5; i++)
+		args[i] = strcmp(argv[i + 2], "-") == 0 ? NULL : argv[i + 2];
+
+	password = PQpassfileLookup(args[1], args[2], args[3], args[4], args[0]);
+
+	if (!password)
+	{
+		fprintf(stderr, "no password found\n");
+		return 1;
+	}
+
+	printf("%s\n", password);
+	PQfreemem(password);
+	return 0;
+}
+
 int
 main(int argc, char *argv[])
 {
@@ -31,7 +63,9 @@ main(int argc, char *argv[])
 		print_ssl_library();
 		return 0;
 	}
+	else if ((argc > 1) && !strcmp(argv[1], "--passfile"))
+		return test_passfile_lookup(argc, argv);
 
-	printf("currently only --ssl is supported\n");
+	printf("currently only --ssl and --passfile are supported\n");
 	return 1;
 }

base-commit: 6a857156827b6a938bd02b9bf7281e7b71dfd088
-- 
2.43.0

