From 2bdddbb827e83f8e06f3f322349dd9301e135347 Mon Sep 17 00:00:00 2001
From: Gabriele Bartolini <gabriele.bartolini@enterprisedb.com>
Date: Sat, 30 May 2026 00:17:17 +1000
Subject: [PATCH] Strip $libdir from LOAD inside extension scripts

Commit 4f7f7b03758 introduced the extension_control_path GUC, and to
make it work the $libdir/ prefix of an extension's module_pathname is
stripped so that the library search path (dynamic_library_path) is
consulted, allowing the extension's shared library to be found in a
custom location.  Commit f777d773878 (bug #18920) later restricted that
stripping to the function-load path so that a LOAD command issued
directly by a user keeps the literal $libdir/ prefix.

However, some extensions hardcode a "LOAD '$libdir/foo'" in their own
SQL scripts (PostGIS does this in its upgrade scripts, for example).
Such a LOAD runs while creating_extension is set, and because the prefix
is no longer stripped on the LOAD path, the library search path is not
consulted and the extension fails to load when it lives in a directory
reached via extension_control_path / dynamic_library_path.

Strip the $libdir/ prefix in load_file() as well, but only while
creating_extension is true, so a LOAD inside an extension script behaves
like the extension's function loads.  A LOAD issued directly by a user
is left untouched and keeps the bug #18920 behavior.  The stripping is
factored out of load_external_function() into a new helper,
strip_libdir_prefix(), and is performed after the restricted-name
security check so that check still sees the original name.

Add a TAP test exercising both paths.
---
 src/backend/utils/fmgr/dfmgr.c                |  52 ++++++--
 src/test/modules/test_extensions/Makefile     |   3 +
 src/test/modules/test_extensions/meson.build  |   5 +
 .../t/002_load_extension_control_path.pl      | 111 ++++++++++++++++++
 4 files changed, 159 insertions(+), 12 deletions(-)
 create mode 100644 src/test/modules/test_extensions/t/002_load_extension_control_path.pl

diff --git a/src/backend/utils/fmgr/dfmgr.c b/src/backend/utils/fmgr/dfmgr.c
index e636cc81cf8..b4828bbf1a2 100644
--- a/src/backend/utils/fmgr/dfmgr.c
+++ b/src/backend/utils/fmgr/dfmgr.c
@@ -20,6 +20,7 @@
 #include <dlfcn.h>
 #endif							/* !WIN32 */
 
+#include "commands/extension.h"
 #include "fmgr.h"
 #include "lib/stringinfo.h"
 #include "miscadmin.h"
@@ -68,6 +69,7 @@ static DynamicFileList *file_tail = NULL;
 
 char	   *Dynamic_library_path;
 
+static const char *strip_libdir_prefix(const char *filename);
 static void *internal_load_library(const char *libname);
 pg_noreturn static void incompatible_module_error(const char *libname,
 												  const Pg_abi_values *module_magic_data);
@@ -78,6 +80,27 @@ static void check_restricted_library_name(const char *name);
 static const Pg_abi_values magic_data = PG_MODULE_ABI_DATA;
 
 
+/*
+ * Strip a leading '$libdir/' prefix from a hardcoded library name so that the
+ * library search path (dynamic_library_path) is consulted instead of resolving
+ * '$libdir' straight to the package library directory.  This is what allows
+ * extensions located via extension_control_path to be loaded.
+ *
+ * The stripping is done only for simple names (e.g., "$libdir/foo"), not for
+ * nested paths (e.g., "$libdir/foo/bar").  For nested paths,
+ * expand_dynamic_library_name() expands the '$libdir' macro directly, so we
+ * leave them untouched.
+ */
+static const char *
+strip_libdir_prefix(const char *filename)
+{
+	if (strncmp(filename, "$libdir/", 8) == 0 &&
+		first_dir_separator(filename + 8) == NULL)
+		filename += 8;
+
+	return filename;
+}
+
 /*
  * Load the specified dynamic-link library file, and look for a function
  * named funcname in it.
@@ -100,19 +123,12 @@ load_external_function(const char *filename, const char *funcname,
 	void	   *retval;
 
 	/*
-	 * For extensions with hardcoded '$libdir/' library names, we strip the
-	 * prefix to allow the library search path to be used. This is done only
-	 * for simple names (e.g., "$libdir/foo"), not for nested paths (e.g.,
-	 * "$libdir/foo/bar").
-	 *
-	 * For nested paths, 'expand_dynamic_library_name' directly expands the
-	 * '$libdir' macro, so we leave them untouched.
+	 * Extensions typically hardcode a '$libdir/' prefix in their library
+	 * names (via MODULE_PATHNAME in their SQL scripts).  Strip it so that the
+	 * library search path can be used, which is what makes extensions found
+	 * via extension_control_path work.  See strip_libdir_prefix().
 	 */
-	if (strncmp(filename, "$libdir/", 8) == 0)
-	{
-		if (first_dir_separator(filename + 8) == NULL)
-			filename += 8;
-	}
+	filename = strip_libdir_prefix(filename);
 
 	/* Expand the possibly-abbreviated filename to an exact path name */
 	fullname = expand_dynamic_library_name(filename);
@@ -154,6 +170,18 @@ load_file(const char *filename, bool restricted)
 	if (restricted)
 		check_restricted_library_name(filename);
 
+	/*
+	 * When a LOAD comes from within an extension script (e.g., a hardcoded
+	 * "LOAD '$libdir/foo'" in the script), strip the '$libdir/' prefix just as
+	 * we do for an extension's functions, so that extensions found via
+	 * extension_control_path can be loaded.  A LOAD issued directly by a user
+	 * is left untouched, so that an explicit '$libdir/' prefix keeps referring
+	 * to the package library directory.  The strip is done after the security
+	 * check so that the latter still sees the original name.
+	 */
+	if (creating_extension)
+		filename = strip_libdir_prefix(filename);
+
 	/* Expand the possibly-abbreviated filename to an exact path name */
 	fullname = expand_dynamic_library_name(filename);
 
diff --git a/src/test/modules/test_extensions/Makefile b/src/test/modules/test_extensions/Makefile
index d1b0b81e5fd..4ee53d200c9 100644
--- a/src/test/modules/test_extensions/Makefile
+++ b/src/test/modules/test_extensions/Makefile
@@ -33,6 +33,9 @@ DATA = test_ext1--1.0.sql test_ext2--1.0.sql test_ext3--1.0.sql \
 REGRESS = test_extensions test_extdepend
 TAP_TESTS = 1
 
+# required for 002_load_extension_control_path.pl
+export TEST_EXT_LIB = $(abs_top_builddir)/src/test/modules/test_extensions/test_ext$(DLSUFFIX)
+
 # force C locale for output stability
 NO_LOCALE = 1
 
diff --git a/src/test/modules/test_extensions/meson.build b/src/test/modules/test_extensions/meson.build
index 2c7cea189e2..cc755a1b0b8 100644
--- a/src/test/modules/test_extensions/meson.build
+++ b/src/test/modules/test_extensions/meson.build
@@ -71,8 +71,13 @@ tests += {
     'regress_args': ['--no-locale'],
   },
   'tap': {
+    'env': {
+      'TEST_EXT_LIB': test_ext.full_path(),
+    },
     'tests': [
       't/001_extension_control_path.pl',
+      't/002_load_extension_control_path.pl',
     ],
+    'deps': [test_ext],
   },
 }
diff --git a/src/test/modules/test_extensions/t/002_load_extension_control_path.pl b/src/test/modules/test_extensions/t/002_load_extension_control_path.pl
new file mode 100644
index 00000000000..e0584be4c2a
--- /dev/null
+++ b/src/test/modules/test_extensions/t/002_load_extension_control_path.pl
@@ -0,0 +1,111 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test that a LOAD command with a hardcoded '$libdir/' prefix issued from
+# within an extension script honors the library search path, so that
+# extensions located via extension_control_path can be loaded.  This mirrors
+# what e.g. PostGIS does in its upgrade scripts ("LOAD '$libdir/postgis-3'").
+#
+# A LOAD issued directly by a user must keep the literal '$libdir/' prefix and
+# is therefore not affected (see commit f777d773878 / bug #18920).
+
+use strict;
+use warnings FATAL => 'all';
+use File::Copy;
+use File::Path qw(mkpath);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Make sure the test_ext shared library path is provided.
+my $ext_lib_so = $ENV{TEST_EXT_LIB}
+  or die "couldn't get the test_ext shared library path";
+
+my $ext_name = "test_ext";
+
+# Create the custom extension directory layout:
+#   $ext_dir/extension/  -- .control and .sql files
+#   $ext_dir/lib/        -- .so file
+my $ext_dir = PostgreSQL::Test::Utils::tempdir();
+mkpath("$ext_dir/extension");
+mkpath("$ext_dir/lib");
+my $ext_lib = "$ext_dir/lib";
+
+# Copy the .so file into the lib/ subdirectory.
+copy($ext_lib_so, $ext_lib)
+  or die "could not copy '$ext_lib_so' to '$ext_lib': $!";
+
+create_extension_files($ext_name, $ext_dir);
+
+my $node = PostgreSQL::Test::Cluster->new('node');
+$node->init;
+
+# Use the correct separator and escape '\' when running on Windows.
+my $sep = $windows_os ? ";" : ":";
+my $ext_path = $windows_os ? ($ext_dir =~ s/\\/\\\\/gr) : $ext_dir;
+my $ext_lib_path = $windows_os ? ($ext_lib =~ s/\\/\\\\/gr) : $ext_lib;
+
+# Configure extension_control_path so the .control file is found in our
+# extension/ directory, and dynamic_library_path so the .so is found in lib/.
+$node->append_conf(
+	'postgresql.conf', qq(
+extension_control_path = '\$system$sep$ext_path'
+dynamic_library_path = '\$libdir$sep$ext_lib_path'
+));
+
+$node->start;
+
+# CREATE EXTENSION runs the script, which contains a hardcoded
+# "LOAD '\$libdir/test_ext'".  Before the fix this failed because the
+# '\$libdir/' prefix was not stripped for LOAD, so the library search path
+# (and thus the custom lib/ directory) was never consulted.
+$node->safe_psql('postgres', "CREATE EXTENSION $ext_name");
+
+# The function added by the extension exercises the function-load path, which
+# already stripped '$libdir/'.
+my ($code, $stdout, $stderr) = $node->psql('postgres', 'SELECT test_ext()');
+is($code, 0, 'extension function works');
+like($stderr, qr/NOTICE:  running successful/, 'extension function loaded');
+
+# A LOAD issued directly by a user (outside any extension script) must keep
+# the literal '$libdir/' prefix, so it resolves to the package library
+# directory and does not find the library installed in our custom lib/ dir.
+($code, $stdout, $stderr) =
+  $node->psql('postgres', "LOAD '\$libdir/$ext_name'");
+isnt($code, 0, 'direct LOAD with $libdir prefix is not redirected to the path');
+like(
+	$stderr,
+	qr{could not access file "\$libdir/$ext_name"},
+	'direct LOAD keeps the literal $libdir prefix');
+
+$node->stop;
+
+# Write .control and .sql files into $ext_dir/extension/.
+# The script uses a hardcoded "LOAD '$libdir/...'" and a '$libdir/' prefixed
+# module_pathname to reproduce what most extensions do by default.
+sub create_extension_files
+{
+	my ($ext_name, $ext_dir) = @_;
+
+	open my $cf, '>', "$ext_dir/extension/$ext_name.control"
+	  or die "could not create control file: $!";
+	print $cf "comment = 'Test C extension for extension_control_path + LOAD'\n";
+	print $cf "default_version = '1.0'\n";
+	print $cf "module_pathname = '\$libdir/$ext_name'\n";
+	print $cf "relocatable = true\n";
+	close $cf;
+
+	open my $sqlf, '>', "$ext_dir/extension/$ext_name--1.0.sql"
+	  or die "could not create SQL file: $!";
+	print $sqlf "/* $ext_name--1.0.sql */\n";
+	print $sqlf
+	  "-- complain if script is sourced in psql, rather than via CREATE EXTENSION\n";
+	print $sqlf
+	  qq'\\echo Use "CREATE EXTENSION $ext_name" to load this file. \\quit\n';
+	print $sqlf "LOAD '\$libdir/$ext_name';\n";
+	print $sqlf "CREATE FUNCTION test_ext()\n";
+	print $sqlf "RETURNS void AS 'MODULE_PATHNAME'\n";
+	print $sqlf "LANGUAGE C;\n";
+	close $sqlf;
+}
+
+done_testing();
-- 
2.54.0

