Message-ID: From: "vlsi (@vlsi)" To: "pgjdbc/pgjdbc" Date: Sun, 14 Jun 2026 20:03:54 +0000 Subject: [pgjdbc/pgjdbc] PR #4167: feat: add classLoaderStrategy for thread-context classloader fallback List-Id: X-GitHub-Additions: 556 X-GitHub-Author-Id: 213894 X-GitHub-Author-Login: vlsi X-GitHub-Base: master X-GitHub-Changed-Files: 21 X-GitHub-Commits: 1 X-GitHub-Deletions: 43 X-GitHub-Head-Branch: claude/youthful-poitras-f0b4a3 X-GitHub-Head-SHA: f26e4017a411697ca9b45216513409eb19d11e6f X-GitHub-Issue: 4167 X-GitHub-Labels: enhancement X-GitHub-Merge-SHA: 298dc3622d9eb881b67e3e3ab41e4686d0c6f201 X-GitHub-Merged-By: vlsi X-GitHub-Repo: pgjdbc/pgjdbc X-GitHub-State: merged X-GitHub-Type: pull_request X-GitHub-Url: https://github.com/pgjdbc/pgjdbc/pull/4167 Content-Type: text/plain; charset=utf-8 ## Why The driver loaded user-supplied classes (`socketFactory`, `sslfactory`, `authenticationPluginClassName`, `datatype.*` maps, `xmlFactoryFactory`) only through its own classloader. In a non-flat class path — Quarkus, OSGi, application servers — the class is often visible only through the thread context classloader, so loading failed with `ClassNotFoundException`. Fixes [#2112](https://github.com/pgjdbc/pgjdbc/issues/2112). I verified the fix against the original Quarkus reproducer ([mikhail-chystsiakou/pgjdbc-15782](https://github.com/mikhail-chystsiakou/pgjdbc-15782)) with a local `42.7.12-SNAPSHOT`: the `ClassNotFoundException: com.google.cloud.sql.postgres.SocketFactory` is gone and the factory now loads from the Quarkus runtime classloader — with no configuration change. ## What - New `classLoaderStrategy` connection property backed by `org.postgresql.util.ClassLoaderStrategy`: - `driver-first` (default) — driver classloader, then fall back to the thread context classloader; - `driver` — driver classloader only (the behaviour before this change); - `context-first` — thread context classloader first, then the driver classloader. - All user-class loading routed through `ClassUtils.forName(name, expectedType, strategy, driverClassLoader)`, which keeps `initialize=false` and the `asSubclass` type check from the recent security fix. - `java.lang.Class#forName(java.lang.String)` is now banned via forbidden-apis. The remaining internal/JDK probes (`SSPIClient`, `SecBufferDesc`, waffle, OSGi `DataSourceFactory`, `MiniJndiContext`) go through `ClassUtils.forName`, and the JDK `MethodHandles` probes use an explicit three-argument `Class.forName`. - `BaseDataSource` triggers the driver's self-registration via `Driver.isRegistered()` instead of reflection, and gains `getClassLoaderStrategy()` / `setClassLoaderStrategy(String)` bean accessors. - A `@Deprecated` `ClassUtils.forName(String, Class, ClassLoader)` overload is kept for binary compatibility (the method shipped in 42.7.9–42.7.11); it preserves the original `null`-classloader-means-bootstrap semantics. ## Behavioural change The default `driver-first` adds a thread-context-classloader fallback that did not exist before. The fallback only fires when the driver's classloader cannot resolve the class, so it is safe for OSGi (where the TCCL is typically undefined). Set `classLoaderStrategy=driver` to restore the strict driver-classloader-only behaviour. ## How to verify - `./gradlew styleCheck` - New unit tests: `ClassLoaderStrategyTest` (parsing and ordering, including `null` filtering) and `ClassUtilsTest` (driver classloader load, `asSubclass` rejection, TCCL fallback proven by classloader identity, deprecated `null`-overload). - End-to-end: build the snapshot, point the Quarkus reproducer at it, run `./gradlew quarkusDev`, and confirm the `SocketFactory` `ClassNotFoundException` is gone. --- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing](https://github.com/pgjdbc/pgjdbc/blob/master/CONTRIBUTING.md) document? * [x] Have you checked to ensure there aren't other open [Pull Requests](../../pulls) for the same update/change? ### New Feature Submissions: 1. [x] Does your submission pass tests? (unit and static checks; DB- and OSGi-runtime tests run in CI) 2. [x] Does `./gradlew styleCheck` pass? 3. [ ] Have you added your new test classes to an existing test suite in alphabetical order? — N/A: the new tests are standalone `*Test` classes; the project is moving away from test suites. ### Changes to Existing Features: * [x] Does this break existing behaviour? The default now falls back to the thread context classloader (see *Behavioural change* above). `classLoaderStrategy=driver` restores the prior behaviour. * [x] Have you added an explanation of what your changes do and why you'd like us to include them? * [x] Have you written new tests for your core changes, as applicable? * [x] Have you successfully run tests with your changes locally? 🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2cb3f2e0..bcf26928ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added * feat: invalidate the prepared-statement cache after CREATE/DROP/ALTER so callers no longer trip on "cached plan must not change result type" without opting into `autosave=ALWAYS`. Controlled by the new `flushCacheOnDdl` connection property (default `true`); set to `false` for the prior behaviour. * feat: add `connectExecutor` connection property to customize the `Executor` used to run the worker task that performs the connection attempt when `loginTimeout` is in effect. The value is the fully qualified name of a class implementing `java.util.concurrent.Executor`. With a null value, the default, the driver retains the prior behavior of running the connection attempt on a daemon thread named `"PostgreSQL JDBC driver connection thread"`. The executor must run the task on a thread other than the caller's. Running the attempt on a named thread lets applications that monitor driver-created threads identify it. +* feat: add `connectThreadFactory` connection property to customize the `ThreadFactory` used to spawn the worker thread that runs the connection attempt when `loginTimeout` is in effect. The value is the fully qualified name of a class implementing `java.util.concurrent.ThreadFactory`. With a null value, the default, the driver retains the prior behavior of using a daemon thread named `"PostgreSQL JDBC driver connection thread"`. Useful for testing timeout behaviour or for applications that want detailed control of all driver-created threads. +* feat: add `classLoaderStrategy` connection property to control which classloaders the driver searches when loading a class named by a connection property, for example `socketFactory`. The default `driver-first` now falls back to the thread context classloader when the driver's classloader cannot resolve the class, which fixes class loading in non-flat class paths such as Quarkus and OSGi. Set `driver` to keep the previous driver-classloader-only behaviour, or `context-first` to prefer the thread context classloader [Issue #2112](https://github.com/pgjdbc/pgjdbc/issues/2112) ### Changed * refactor: the worker that runs the connection attempt under `loginTimeout` is now a `FutureTask` (`ConnectTask`) instead of the hand-rolled `ConnectThread`. When the caller hits the timeout, the task is now cancelled with `cancel(true)`, which interrupts the worker thread rather than letting it run to completion. This makes the connection attempt interruptible, so `loginTimeout` can stop a slow connection attempt instead of leaking a thread. As before, a connection that the worker still manages to establish after the caller gives up is closed by the worker so that it does not leak. There are no public API changes and this should only lead to faster background resource cleanup for connections that time out. diff --git a/README.md b/README.md index b7ebf9954c..178b97d809 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ In addition to the standard connection parameters the driver supports a number o | loadBalanceHosts | Boolean | false | If disabled hosts are connected in the given order. If enabled hosts are chosen randomly from the set of suitable candidates | | socketFactory | String | null | Specify a socket factory for socket creation | | socketFactoryArg (deprecated) | String | null | Argument forwarded to constructor of SocketFactory class. | +| classLoaderStrategy | String | driver-first | Order in which classloaders are searched when loading a class named by a connection property; values are driver-first (default), driver, context-first | | autosave | String | never | Specifies what the driver should do if a query fails, possible values: always, never, conservative | | cleanupSavepoints | Boolean | false | In Autosave mode the driver sets a SAVEPOINT for every query. It is possible to exhaust the server shared buffers. Setting this to true will release each SAVEPOINT at the cost of an additional round trip. | | preferQueryMode | String | extended | Specifies which mode is used to execute queries to database, possible values: extended, extendedForPrepared, extendedCacheEverything, simple | diff --git a/config/forbidden-apis/forbidden-apis.txt b/config/forbidden-apis/forbidden-apis.txt index 4e4ba1e806..be96ace44b 100644 --- a/config/forbidden-apis/forbidden-apis.txt +++ b/config/forbidden-apis/forbidden-apis.txt @@ -17,3 +17,6 @@ java.lang.Object#notifyAll() @defaultMessage Use FileUtils.newBufferedInputStream() java.io.FileInputStream#(java.io.File) java.io.FileInputStream#(java.lang.String) + +@defaultMessage Use ClassUtils.forName(...) for user classes, or Class.forName(name, initialize, classLoader) with an explicit classloader +java.lang.Class#forName(java.lang.String) diff --git a/docs/content/documentation/use.md b/docs/content/documentation/use.md index 315dff6672..c25523baea 100644 --- a/docs/content/documentation/use.md +++ b/docs/content/documentation/use.md @@ -409,7 +409,7 @@ of suitable candidates. * **`socketFactory (`*String*`)`** *Default `null`*\ The provided value is a class name to use as the `SocketFactory` when establishing a socket connection. This may be used to create unix sockets instead of normal sockets. The class name specified by `socketFactory` must extend -`javax.net.SocketFactory` and be available to the driver's classloader. This class must have a zero-argument constructor, +`javax.net.SocketFactory` and be reachable through one of the classloaders selected by `classLoaderStrategy`. This class must have a zero-argument constructor, a single-argument constructor taking a String argument, or a single-argument constructor taking a Properties argument. The Properties object will contain all the connection parameters. The String argument will have the value of the `socketFactoryArg` connection parameter. @@ -417,6 +417,15 @@ connection parameter. * **`socketFactoryArg (`*String*`)`** : (deprecated)\ This value is an optional argument to the constructor of the socket factory class provided above. +* **`classLoaderStrategy (`*String*`)`** *Default `driver-first`*\ +Order in which the driver searches classloaders when loading a class named by a connection property, for example `socketFactory`. +The driver's own classloader sees only what is on its classpath, so in a non-flat class path (an application server or an OSGi +container) a user-supplied class may be reachable only through the thread context classloader. +`driver-first` tries the driver's classloader and then falls back to the thread context classloader. +`driver` uses the driver's classloader only, which matches the behaviour from before this property existed. +`context-first` tries the thread context classloader first; use it in containers that expect their own classloader to take +precedence even when the driver's classloader could resolve a class of the same name. + * **`reWriteBatchedInserts (`*boolean*`)`** *Default `false`*\ This will change batch inserts from insert into foo (col1, col2, col3) values (1, 2, 3) into insert into foo (col1, col2, col3) values (1, 2, 3), (4, 5, 6) this provides 2-3x performance improvement diff --git a/pgjdbc-osgi-test/src/test/java/org/postgresql/test/osgi/PlainOsgiTest.java b/pgjdbc-osgi-test/src/test/java/org/postgresql/test/osgi/PlainOsgiTest.java index 64cac216d4..05097c5189 100644 --- a/pgjdbc-osgi-test/src/test/java/org/postgresql/test/osgi/PlainOsgiTest.java +++ b/pgjdbc-osgi-test/src/test/java/org/postgresql/test/osgi/PlainOsgiTest.java @@ -8,6 +8,9 @@ import static org.ops4j.pax.exam.CoreOptions.options; import static org.postgresql.test.osgi.DefaultPgjdbcOsgiOptions.defaultPgjdbcOsgiOptions; +import org.postgresql.util.ClassLoaderStrategy; +import org.postgresql.util.ClassUtils; + import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -35,8 +38,9 @@ public Option[] config() { @Test public void driverVersionShouldBePositive() throws Exception { - Class driverClass = Class.forName("org.postgresql.Driver"); - Driver driver = (Driver) driverClass.getConstructor().newInstance(); + Class driverClass = ClassUtils.forName("org.postgresql.Driver", Driver.class, + ClassLoaderStrategy.DRIVER, PlainOsgiTest.class.getClassLoader()); + Driver driver = driverClass.getConstructor().newInstance(); // We use regular assert instead of hamcrest since // org.hamcrest.Matchers not found by org.ops4j.pax.tipi.hamcrest.core diff --git a/pgjdbc/src/main/java/org/postgresql/Driver.java b/pgjdbc/src/main/java/org/postgresql/Driver.java index a394fedd55..b57d5e2e26 100644 --- a/pgjdbc/src/main/java/org/postgresql/Driver.java +++ b/pgjdbc/src/main/java/org/postgresql/Driver.java @@ -130,7 +130,8 @@ public Properties run() throws IOException { private static T doPrivileged(PrivilegedExceptionAction action) throws Throwable { try { - Class accessControllerClass = Class.forName("java.security.AccessController"); + Class accessControllerClass = Class.forName("java.security.AccessController", true, + Driver.class.getClassLoader()); Method doPrivileged = accessControllerClass.getMethod("doPrivileged", PrivilegedExceptionAction.class); //noinspection unchecked diff --git a/pgjdbc/src/main/java/org/postgresql/PGProperty.java b/pgjdbc/src/main/java/org/postgresql/PGProperty.java index bb5a9e51b6..1240df9db2 100644 --- a/pgjdbc/src/main/java/org/postgresql/PGProperty.java +++ b/pgjdbc/src/main/java/org/postgresql/PGProperty.java @@ -159,6 +159,17 @@ public enum PGProperty { false, new String[] {"disable", "prefer", "require"}), + /** + * Order in which the driver searches classloaders when loading a class named by a connection + * property. See {@link org.postgresql.util.ClassLoaderStrategy} for the meaning of each value. + */ + CLASS_LOADER_STRATEGY( + "classLoaderStrategy", + "driver-first", + "Order in which the driver searches classloaders when loading a class named by a connection property.", + false, + new String[]{"driver", "driver-first", "context-first"}), + /** * Determine whether SAVEPOINTS used in AUTOSAVE will be released per query or not */ diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java b/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java index a40c3ad0b0..9bbadcaf4e 100644 --- a/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java +++ b/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java @@ -34,6 +34,8 @@ import org.postgresql.plugin.AuthenticationRequestType; import org.postgresql.ssl.MakeSSL; import org.postgresql.sspi.ISSPIClient; +import org.postgresql.util.ClassLoaderStrategy; +import org.postgresql.util.ClassUtils; import org.postgresql.util.GT; import org.postgresql.util.HostSpec; import org.postgresql.util.MD5Digest; @@ -120,8 +122,8 @@ private static ISSPIClient createSSPI(PGStream pgStream, @Nullable String spnServiceClass, boolean enableNegotiate) { try { - @SuppressWarnings("unchecked") - Class c = (Class) Class.forName("org.postgresql.sspi.SSPIClient"); + Class c = ClassUtils.forName("org.postgresql.sspi.SSPIClient", + ISSPIClient.class, ClassLoaderStrategy.DRIVER, ConnectionFactoryImpl.class.getClassLoader()); return c.getDeclaredConstructor(PGStream.class, String.class, boolean.class) .newInstance(pgStream, spnServiceClass, enableNegotiate); } catch (Exception e) { diff --git a/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java b/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java index 4a5dd4ce14..6520b8c04c 100644 --- a/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java +++ b/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java @@ -60,20 +60,15 @@ public abstract class BaseDataSource implements CommonDataSource, Referenceable private Properties properties = new Properties(); /* - * Ensure the driver is loaded as JDBC Driver might be invisible to Java's ServiceLoader. - * Usually, {@code Class.forName(...)} is not required as {@link DriverManager} detects JDBC drivers - * via {@code META-INF/services/java.sql.Driver} entries. However there might be cases when the driver - * is located at the application level classloader, thus it might be required to perform manual - * registration of the driver. + * Ensure the pgjdbc driver is registered with the {@link DriverManager}: {@code getConnection} + * relies on it. Usually explicit loading is not required as the {@link DriverManager} detects JDBC + * drivers via {@code META-INF/services/java.sql.Driver} entries. However there might be cases when + * the driver sits on the application level classloader where the ServiceLoader lookup misses it. + * Invoking a static method forces the {@link Driver} class to initialise, and its static + * initialiser self-registers with the {@link DriverManager}. */ static { - try { - Class.forName("org.postgresql.Driver"); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "BaseDataSource is unable to load org.postgresql.Driver. Please check if you have proper PostgreSQL JDBC Driver jar on the classpath", - e); - } + Driver.isRegistered(); } /** @@ -1320,6 +1315,23 @@ public void setSocketFactoryArg(@Nullable String socketFactoryArg) { PGProperty.SOCKET_FACTORY_ARG.set(properties, socketFactoryArg); } + /** + * @return the order in which classloaders are searched when loading user-supplied classes + * @see PGProperty#CLASS_LOADER_STRATEGY + */ + public @Nullable String getClassLoaderStrategy() { + return PGProperty.CLASS_LOADER_STRATEGY.getOrDefault(properties); + } + + /** + * @param classLoaderStrategy the order in which classloaders are searched when loading + * user-supplied classes + * @see PGProperty#CLASS_LOADER_STRATEGY + */ + public void setClassLoaderStrategy(@Nullable String classLoaderStrategy) { + PGProperty.CLASS_LOADER_STRATEGY.set(properties, classLoaderStrategy); + } + /** * @param replication set to 'database' for logical replication or 'true' for physical replication * @see PGProperty#REPLICATION diff --git a/pgjdbc/src/main/java/org/postgresql/gss/MakeGSS.java b/pgjdbc/src/main/java/org/postgresql/gss/MakeGSS.java index 076d016d7f..c58fe49b71 100644 --- a/pgjdbc/src/main/java/org/postgresql/gss/MakeGSS.java +++ b/pgjdbc/src/main/java/org/postgresql/gss/MakeGSS.java @@ -54,9 +54,10 @@ public class MakeGSS { MethodHandle subjectGetSubject = null; try { - Class accessControllerClass = Class.forName("java.security.AccessController"); + ClassLoader classLoader = MakeGSS.class.getClassLoader(); + Class accessControllerClass = Class.forName("java.security.AccessController", true, classLoader); Class accessControlContextClass = - Class.forName("java.security.AccessControlContext"); + Class.forName("java.security.AccessControlContext", true, classLoader); accessControllerGetContext = MethodHandles.lookup() .findStatic(accessControllerClass, "getContext", MethodType.methodType(accessControlContextClass)); diff --git a/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java b/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java index c6351adacc..a45877b61c 100644 --- a/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java +++ b/pgjdbc/src/main/java/org/postgresql/jdbc/PgConnection.java @@ -39,6 +39,7 @@ import org.postgresql.largeobject.LargeObjectManager; import org.postgresql.replication.PGReplicationConnection; import org.postgresql.replication.PGReplicationConnectionImpl; +import org.postgresql.util.ClassLoaderStrategy; import org.postgresql.util.ClassUtils; import org.postgresql.util.DriverInfo; import org.postgresql.util.GT; @@ -116,7 +117,8 @@ public class PgConnection implements BaseConnection { MethodHandle systemGetSecurityManagerHandle = null; MethodHandle securityManagerCheckPermission = null; try { - Class securityManagerClass = Class.forName("java.lang.SecurityManager"); + Class securityManagerClass = Class.forName("java.lang.SecurityManager", true, + PgConnection.class.getClassLoader()); systemGetSecurityManagerHandle = MethodHandles.lookup().findStatic(System.class, "getSecurityManager", MethodType.methodType(securityManagerClass)); @@ -229,6 +231,7 @@ private enum ReadOnlyBehavior { private final @Nullable String xmlFactoryFactoryClass; private @Nullable PGXmlFactoryFactory xmlFactoryFactory; + private final ClassLoaderStrategy classLoaderStrategy; private final LazyCleaner.Cleanable cleanable; /* this is actually the database we are connected to */ private @Nullable String catalog; @@ -275,6 +278,8 @@ public PgConnection(HostSpec[] hostSpecs, this.creatingURL = url; + this.classLoaderStrategy = ClassLoaderStrategy.of(info); + this.readOnlyBehavior = getReadOnlyBehavior(PGProperty.READ_ONLY_MODE.getOrDefault(info)); setDefaultFetchSize(PGProperty.DEFAULT_ROW_FETCH_SIZE.getInt(info)); @@ -825,7 +830,7 @@ public TypeInfo getTypeInfo() { @Deprecated public void addDataType(String type, String name) { try { - addDataType(type, ClassUtils.forName(name, PGobject.class, getClass().getClassLoader())); + addDataType(type, ClassUtils.forName(name, PGobject.class, classLoaderStrategy, getClass().getClassLoader())); } catch (Exception e) { throw new RuntimeException("Cannot register new type " + type, e); } @@ -872,7 +877,7 @@ private void initObjectTypes(Properties info) throws SQLException { Class klass; try { - klass = ClassUtils.forName(className, PGobject.class, getClass().getClassLoader()); + klass = ClassUtils.forName(className, PGobject.class, classLoaderStrategy, getClass().getClassLoader()); } catch (ClassNotFoundException cnfe) { throw new PSQLException( GT.tr("Unable to load the class {0} responsible for the datatype {1}", @@ -2002,7 +2007,7 @@ public PGXmlFactoryFactory getXmlFactoryFactory() throws SQLException { } else { Class clazz; try { - clazz = ClassUtils.forName(xmlFactoryFactoryClass, PGXmlFactoryFactory.class, getClass().getClassLoader()); + clazz = ClassUtils.forName(xmlFactoryFactoryClass, PGXmlFactoryFactory.class, classLoaderStrategy, getClass().getClassLoader()); } catch (ClassNotFoundException ex) { throw new PSQLException( GT.tr("Could not instantiate xmlFactoryFactory: {0}", xmlFactoryFactoryClass), diff --git a/pgjdbc/src/main/java/org/postgresql/osgi/PGBundleActivator.java b/pgjdbc/src/main/java/org/postgresql/osgi/PGBundleActivator.java index be1b5f360f..620d1ac6be 100644 --- a/pgjdbc/src/main/java/org/postgresql/osgi/PGBundleActivator.java +++ b/pgjdbc/src/main/java/org/postgresql/osgi/PGBundleActivator.java @@ -35,7 +35,11 @@ public void start(BundleContext context) throws Exception { private static boolean dataSourceFactoryExists() { try { - Class.forName("org.osgi.service.jdbc.DataSourceFactory"); + // Probe by name only: referencing DataSourceFactory.class here would resolve the optional + // org.osgi.service.jdbc package and throw NoClassDefFoundError during bundle activation when + // it is absent, instead of cleanly reporting that the service is unavailable. + Class.forName("org.osgi.service.jdbc.DataSourceFactory", false, + PGBundleActivator.class.getClassLoader()); return true; } catch (ClassNotFoundException ignored) { // DataSourceFactory does not exist => no reason to register the service diff --git a/pgjdbc/src/main/java/org/postgresql/sspi/SSPIClient.java b/pgjdbc/src/main/java/org/postgresql/sspi/SSPIClient.java index f003f7c1ea..67ab5e2027 100644 --- a/pgjdbc/src/main/java/org/postgresql/sspi/SSPIClient.java +++ b/pgjdbc/src/main/java/org/postgresql/sspi/SSPIClient.java @@ -10,6 +10,8 @@ import org.postgresql.core.PGStream; import org.postgresql.core.PgMessageType; +import org.postgresql.util.ClassLoaderStrategy; +import org.postgresql.util.ClassUtils; import org.postgresql.util.GT; import org.postgresql.util.HostSpec; import org.postgresql.util.PSQLException; @@ -56,8 +58,8 @@ public class SSPIClient implements ISSPIClient { static { Class klass; try { - klass = Class.forName("com.sun.jna.platform.win32.SspiUtil$ManagedSecBufferDesc") - .asSubclass(SecBufferDesc.class); + klass = ClassUtils.forName("com.sun.jna.platform.win32.SspiUtil$ManagedSecBufferDesc", + SecBufferDesc.class, ClassLoaderStrategy.DRIVER, SSPIClient.class.getClassLoader()); } catch (ReflectiveOperationException ex) { klass = SecBufferDesc.class; } @@ -104,20 +106,34 @@ public SSPIClient(PGStream pgStream, String spnServiceClass, boolean enableNegot public boolean isSSPISupported() { try { /* - * SSPI is windows-only. Attempt to use JNA to identify the platform. If Waffle is missing we - * won't have JNA and this will throw a NoClassDefFoundError. + * SSPI is windows-only. Attempt to use JNA to identify the platform. If JNA is missing this + * throws NoClassDefFoundError, which we report as "SSPI unavailable". */ if (!Platform.isWindows()) { LOGGER.log(Level.FINE, "SSPI not supported: non-Windows host"); return false; } - /* Waffle must be on the CLASSPATH */ - Class.forName("waffle.windows.auth.impl.WindowsSecurityContextImpl"); - return true; } catch (NoClassDefFoundError ex) { LOGGER.log(Level.WARNING, "SSPI unavailable (no Waffle/JNA libraries?)", ex); return false; - } catch (ClassNotFoundException ex) { + } + return isWaffleAvailable(SSPIClient.class.getClassLoader()); + } + + /** + * Tests whether Waffle is reachable through the given classloader. The probe is by name only, so + * a missing optional Waffle package is reported as unavailable rather than linked eagerly: + * referencing a Waffle type here would throw {@link NoClassDefFoundError} before the probe could + * run. + * + * @param classLoader the classloader to probe + * @return {@code true} if Waffle is on the classpath + */ + static boolean isWaffleAvailable(@Nullable ClassLoader classLoader) { + try { + Class.forName("waffle.windows.auth.impl.WindowsSecurityContextImpl", false, classLoader); + return true; + } catch (ClassNotFoundException | NoClassDefFoundError ex) { LOGGER.log(Level.WARNING, "SSPI unavailable (no Waffle/JNA libraries?)", ex); return false; } diff --git a/pgjdbc/src/main/java/org/postgresql/util/ClassLoaderStrategy.java b/pgjdbc/src/main/java/org/postgresql/util/ClassLoaderStrategy.java new file mode 100644 index 0000000000..f9c23a1539 --- /dev/null +++ b/pgjdbc/src/main/java/org/postgresql/util/ClassLoaderStrategy.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.util; + +import org.postgresql.PGProperty; + +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +/** + * Order in which the driver searches classloaders when loading a class named by a connection + * property. The driver's own classloader can see only what is on its classpath, so in a non-flat + * class path (for example an application server or an OSGi container) a user-supplied class may be + * visible only through the {@link Thread#getContextClassLoader() thread context classloader}. + * + *

The class is NOT public API, so it is subject to change.

+ */ +public enum ClassLoaderStrategy { + + /** + * Use the driver's classloader only. This was the behaviour before the strategy became + * configurable and is the safest choice when the thread context classloader is undefined. + */ + DRIVER("driver"), + + /** + * Try the driver's classloader first, then fall back to the thread context classloader. The + * fallback heals environments where the class is reachable only through the context classloader, + * while keeping the driver's classloader authoritative. + */ + DRIVER_FIRST("driver-first"), + + /** + * Try the thread context classloader first, then fall back to the driver's classloader. Use this + * in containers that manage class loading and expect their context classloader to win, even when + * the driver's classloader could resolve a class of the same name. + */ + CONTEXT_FIRST("context-first"); + + private static final ClassLoaderStrategy[] VALUES = values(); + + public final String value; + + ClassLoaderStrategy(String value) { + this.value = value; + } + + /** + * Resolves the strategy from the {@link PGProperty#CLASS_LOADER_STRATEGY} connection property. + * + * @param info connection properties + * @return the configured strategy, or {@link #DRIVER_FIRST} when the property is absent + * @throws PSQLException if the property holds an unknown value + */ + public static ClassLoaderStrategy of(Properties info) throws PSQLException { + String value = PGProperty.CLASS_LOADER_STRATEGY.getOrDefault(info); + if (value == null) { + return DRIVER_FIRST; + } + for (ClassLoaderStrategy strategy : VALUES) { + if (strategy.value.equalsIgnoreCase(value)) { + return strategy; + } + } + throw new PSQLException(GT.tr("Invalid classLoaderStrategy value: {0}", value), + PSQLState.INVALID_PARAMETER_VALUE); + } + + /** + * Returns the non-null classloaders to try, in priority order. A null input (for instance a null + * context classloader) is dropped, so the list contains only classloaders the caller can use. + * + * @param driverClassLoader the driver's own classloader + * @param contextClassLoader the thread context classloader + * @return an ordered list of non-null classloaders to try + */ + public List classLoaders(@Nullable ClassLoader driverClassLoader, + @Nullable ClassLoader contextClassLoader) { + switch (this) { + case DRIVER: + return singletonOrEmpty(driverClassLoader); + case DRIVER_FIRST: + return nonNullList(driverClassLoader, contextClassLoader); + case CONTEXT_FIRST: + return nonNullList(contextClassLoader, driverClassLoader); + default: + throw new IllegalStateException("Unexpected classLoaderStrategy: " + this); + } + } + + private static List singletonOrEmpty(@Nullable ClassLoader classLoader) { + return classLoader == null ? Collections.emptyList() : Collections.singletonList(classLoader); + } + + private static List nonNullList(@Nullable ClassLoader first, + @Nullable ClassLoader second) { + if (first == null) { + return singletonOrEmpty(second); + } + if (second == null) { + return Collections.singletonList(first); + } + return Arrays.asList(first, second); + } +} diff --git a/pgjdbc/src/main/java/org/postgresql/util/ClassUtils.java b/pgjdbc/src/main/java/org/postgresql/util/ClassUtils.java index e5500f5a44..386d660cf2 100644 --- a/pgjdbc/src/main/java/org/postgresql/util/ClassUtils.java +++ b/pgjdbc/src/main/java/org/postgresql/util/ClassUtils.java @@ -17,18 +17,62 @@ private ClassUtils() { } /** - * Safely loads a class using the three-parameter Class.forName with initialize=false - * and validates it's assignable to the expected type. + * Loads a class named by a connection property and validates that it is assignable to the + * expected type. The class is loaded with {@code initialize=false} so that its static + * initialiser does not run until the type check has passed. + * + *

The classloaders to try, and their order, come from {@code strategy}. The first classloader + * that resolves the name wins; a {@link ClassNotFoundException} from one classloader falls through + * to the next. A class that resolves but is not a subtype of {@code expectedClass} fails fast with + * a {@link ClassCastException} rather than falling through.

* * @param className the name of the class to load * @param expectedClass the expected superclass or interface - * @param classLoader the class loader to use + * @param strategy the order in which to try the classloaders + * @param driverClassLoader the driver's own classloader * @param the expected type * @return the loaded class as a subclass of the expected type - * @throws ClassNotFoundException if the class cannot be found + * @throws ClassNotFoundException if none of the classloaders can find the class */ - public static Class forName(String className, Class expectedClass, @Nullable ClassLoader classLoader) + public static Class forName(String className, Class expectedClass, + ClassLoaderStrategy strategy, @Nullable ClassLoader driverClassLoader) throws ClassNotFoundException { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + ClassNotFoundException firstFailure = null; + for (ClassLoader classLoader : strategy.classLoaders(driverClassLoader, contextClassLoader)) { + try { + return Class.forName(className, false, classLoader).asSubclass(expectedClass); + } catch (ClassNotFoundException e) { + if (firstFailure == null) { + firstFailure = e; + } else { + firstFailure.addSuppressed(e); + } + } + } + if (firstFailure != null) { + throw firstFailure; + } + throw new ClassNotFoundException(className); + } + + /** + * Loads a class by name from the given classloader only, validating that it is assignable to the + * expected type. A {@code null} classLoader means the bootstrap classloader, matching + * {@link Class#forName(String, boolean, ClassLoader)}. + * + * @param className the name of the class to load + * @param expectedClass the expected superclass or interface + * @param classLoader the classloader to use + * @param the expected type + * @return the loaded class as a subclass of the expected type + * @throws ClassNotFoundException if the class cannot be found + * @deprecated use {@link #forName(String, Class, ClassLoaderStrategy, ClassLoader)}, which also + * lets the caller fall back to the thread context classloader + */ + @Deprecated + public static Class forName(String className, Class expectedClass, + @Nullable ClassLoader classLoader) throws ClassNotFoundException { return Class.forName(className, false, classLoader).asSubclass(expectedClass); } } diff --git a/pgjdbc/src/main/java/org/postgresql/util/ObjectFactory.java b/pgjdbc/src/main/java/org/postgresql/util/ObjectFactory.java index 1dbac31297..81a58ad0cb 100644 --- a/pgjdbc/src/main/java/org/postgresql/util/ObjectFactory.java +++ b/pgjdbc/src/main/java/org/postgresql/util/ObjectFactory.java @@ -38,16 +38,18 @@ public class ObjectFactory { * @throws InstantiationException if something goes wrong * @throws IllegalAccessException if something goes wrong * @throws InvocationTargetException if something goes wrong + * @throws PSQLException if the classLoaderStrategy property holds an unknown value */ public static T instantiate(Class expectedClass, String classname, Properties info, boolean tryString, @Nullable String stringarg) throws ClassNotFoundException, SecurityException, NoSuchMethodException, IllegalArgumentException, InstantiationException, IllegalAccessException, - InvocationTargetException { + InvocationTargetException, PSQLException { @Nullable Object[] args = {info}; Constructor ctor = null; - Class cls = ClassUtils.forName(classname, expectedClass, ObjectFactory.class.getClassLoader()); + Class cls = ClassUtils.forName(classname, expectedClass, + ClassLoaderStrategy.of(info), ObjectFactory.class.getClassLoader()); try { ctor = cls.getConstructor(Properties.class); } catch (NoSuchMethodException ignored) { diff --git a/pgjdbc/src/test/java/org/postgresql/jdbc/DeepBatchedInsertStatementTest.java b/pgjdbc/src/test/java/org/postgresql/jdbc/DeepBatchedInsertStatementTest.java index f7dff2a8d7..44082a6cc4 100644 --- a/pgjdbc/src/test/java/org/postgresql/jdbc/DeepBatchedInsertStatementTest.java +++ b/pgjdbc/src/test/java/org/postgresql/jdbc/DeepBatchedInsertStatementTest.java @@ -312,7 +312,9 @@ private static int getBatchSize(BatchedQuery[] bqds) { */ private static byte[] getEncodedStatementName(BatchedQuery bqd) throws Exception { - Class clazz = Class.forName("org.postgresql.core.v3.SimpleQuery"); + // getEncodedStatementName is declared in the package-private SimpleQuery, BatchedQuery's + // superclass, so it cannot be named as SimpleQuery.class from this package. + Class clazz = BatchedQuery.class.getSuperclass(); Method mESN = clazz.getDeclaredMethod("getEncodedStatementName"); mESN.setAccessible(true); return (byte[]) mESN.invoke(bqd); diff --git a/pgjdbc/src/test/java/org/postgresql/test/sspi/SSPIClientWaffleTest.java b/pgjdbc/src/test/java/org/postgresql/test/sspi/SSPIClientWaffleTest.java new file mode 100644 index 0000000000..5b47fbdc99 --- /dev/null +++ b/pgjdbc/src/test/java/org/postgresql/test/sspi/SSPIClientWaffleTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.test.sspi; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.postgresql.sspi.SSPIClient; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +/** + * Verifies that {@code SSPIClient} degrades gracefully when Waffle is missing from the classpath, + * rather than failing with a linkage error. The probe must stay name-based, so this test runs on any + * platform (the Windows-only end-to-end path lives in {@code SSPITest}). + */ +class SSPIClientWaffleTest { + + private static final String UNAVAILABLE_MESSAGE = "SSPI unavailable (no Waffle/JNA libraries?)"; + + /** + * A classloader that hides the {@code waffle.*} packages, simulating a runtime without waffle-jna + * while keeping every other class (including JNA) reachable through the parent. + */ + private static final class WaffleHidingClassLoader extends ClassLoader { + WaffleHidingClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("waffle.")) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name, resolve); + } + } + + private static boolean isWaffleAvailable(ClassLoader probeLoader) throws Exception { + Method method = SSPIClient.class.getDeclaredMethod("isWaffleAvailable", ClassLoader.class); + method.setAccessible(true); + return (boolean) method.invoke(null, probeLoader); + } + + @Test + void reportsAvailableWhenWaffleIsOnClasspath() throws Exception { + assertTrue(isWaffleAvailable(getClass().getClassLoader())); + } + + @Test + void reportsUnavailableAndWarnsWhenWaffleIsMissing() throws Exception { + Logger logger = Logger.getLogger("org.postgresql.sspi.SSPIClient"); + List records = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + handler.setLevel(Level.ALL); + logger.addHandler(handler); + try { + assertFalse(isWaffleAvailable(new WaffleHidingClassLoader(getClass().getClassLoader()))); + boolean warned = records.stream().anyMatch(record -> record.getLevel() == Level.WARNING + && String.valueOf(record.getMessage()).contains(UNAVAILABLE_MESSAGE)); + assertTrue(warned, "expected a WARNING containing: " + UNAVAILABLE_MESSAGE); + } finally { + logger.removeHandler(handler); + } + } +} diff --git a/pgjdbc/src/test/java/org/postgresql/test/util/MiniJndiContext.java b/pgjdbc/src/test/java/org/postgresql/test/util/MiniJndiContext.java index cdb947a538..54a6331dc2 100644 --- a/pgjdbc/src/test/java/org/postgresql/test/util/MiniJndiContext.java +++ b/pgjdbc/src/test/java/org/postgresql/test/util/MiniJndiContext.java @@ -5,6 +5,9 @@ package org.postgresql.test.util; +import org.postgresql.util.ClassLoaderStrategy; +import org.postgresql.util.ClassUtils; + import java.io.IOException; import java.io.Serializable; import java.rmi.MarshalledObject; @@ -49,8 +52,9 @@ public Object lookup(String name) throws NamingException { if (o instanceof Reference) { Reference ref = (Reference) o; try { - Class factoryClass = Class.forName(ref.getFactoryClassName()); - ObjectFactory fac = (ObjectFactory) factoryClass.newInstance(); + Class factoryClass = ClassUtils.forName(ref.getFactoryClassName(), + ObjectFactory.class, ClassLoaderStrategy.DRIVER, MiniJndiContext.class.getClassLoader()); + ObjectFactory fac = factoryClass.newInstance(); return fac.getObjectInstance(ref, null, this, null); } catch (Exception e) { throw new NamingException("Unable to dereference to object: " + e); diff --git a/pgjdbc/src/test/java/org/postgresql/util/ClassLoaderStrategyTest.java b/pgjdbc/src/test/java/org/postgresql/util/ClassLoaderStrategyTest.java new file mode 100644 index 0000000000..7952efbf2a --- /dev/null +++ b/pgjdbc/src/test/java/org/postgresql/util/ClassLoaderStrategyTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.postgresql.PGProperty; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Properties; + +class ClassLoaderStrategyTest { + + @Test + void defaultsToDriverFirstWhenAbsent() throws Exception { + assertEquals(ClassLoaderStrategy.DRIVER_FIRST, ClassLoaderStrategy.of(new Properties())); + } + + @Test + void parsesEachValueIgnoringCase() throws Exception { + Properties info = new Properties(); + PGProperty.CLASS_LOADER_STRATEGY.set(info, "DRIVER"); + assertEquals(ClassLoaderStrategy.DRIVER, ClassLoaderStrategy.of(info)); + PGProperty.CLASS_LOADER_STRATEGY.set(info, "driver-first"); + assertEquals(ClassLoaderStrategy.DRIVER_FIRST, ClassLoaderStrategy.of(info)); + PGProperty.CLASS_LOADER_STRATEGY.set(info, "Context-First"); + assertEquals(ClassLoaderStrategy.CONTEXT_FIRST, ClassLoaderStrategy.of(info)); + } + + @Test + void rejectsUnknownValue() { + Properties info = new Properties(); + PGProperty.CLASS_LOADER_STRATEGY.set(info, "nonsense"); + assertThrows(PSQLException.class, () -> ClassLoaderStrategy.of(info)); + } + + @Test + void ordersClassLoadersPerStrategy() { + ClassLoader driver = new ClassLoader() { + }; + ClassLoader context = new ClassLoader() { + }; + assertEquals(Collections.singletonList(driver), + ClassLoaderStrategy.DRIVER.classLoaders(driver, context)); + assertEquals(Arrays.asList(driver, context), + ClassLoaderStrategy.DRIVER_FIRST.classLoaders(driver, context)); + assertEquals(Arrays.asList(context, driver), + ClassLoaderStrategy.CONTEXT_FIRST.classLoaders(driver, context)); + } + + @Test + void dropsNullClassLoaders() { + ClassLoader driver = new ClassLoader() { + }; + assertEquals(Collections.singletonList(driver), + ClassLoaderStrategy.DRIVER_FIRST.classLoaders(driver, null)); + assertEquals(Collections.singletonList(driver), + ClassLoaderStrategy.CONTEXT_FIRST.classLoaders(null, driver)); + assertEquals(Collections.emptyList(), + ClassLoaderStrategy.DRIVER.classLoaders(null, null)); + } +} diff --git a/pgjdbc/src/test/java/org/postgresql/util/ClassUtilsTest.java b/pgjdbc/src/test/java/org/postgresql/util/ClassUtilsTest.java new file mode 100644 index 0000000000..36f0752100 --- /dev/null +++ b/pgjdbc/src/test/java/org/postgresql/util/ClassUtilsTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.util; + +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.postgresql.geometric.PGpoint; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +class ClassUtilsTest { + + @Test + void loadsClassWithDriverClassLoader() throws Exception { + Class cls = ClassUtils.forName(PGobject.class.getName(), PGobject.class, + ClassLoaderStrategy.DRIVER, getClass().getClassLoader()); + assertSame(PGobject.class, cls); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedOverloadTreatsNullClassLoaderAsBootstrap() throws Exception { + // The released forName(String, Class, ClassLoader) overload must keep null == bootstrap loader. + Class cls = + ClassUtils.forName("java.lang.String", CharSequence.class, null); + assertSame(String.class, cls); + } + + @Test + void rejectsClassThatIsNotASubtype() { + assertThrows(ClassCastException.class, () -> ClassUtils.forName("java.lang.String", + PGobject.class, ClassLoaderStrategy.DRIVER, getClass().getClassLoader())); + } + + @Test + void fallsBackToContextClassLoaderWhenDriverCannotSee() throws Exception { + String className = PGpoint.class.getName(); + // A classloader that defines PGpoint itself, so a class it resolves reports it as the loader. + ClassLoader contextLoader = new SingleClassDefiningClassLoader(getClass().getClassLoader(), + className, bytecodeOf(PGpoint.class)); + // A classloader whose parent is the bootstrap loader cannot see driver classes. + ClassLoader blind = new ClassLoader(null) { + }; + ClassLoader original = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(contextLoader); + + // driver-first: the blind classloader misses, so the class comes from the context classloader. + Class cls = ClassUtils.forName(className, PGobject.class, + ClassLoaderStrategy.DRIVER_FIRST, blind); + assertSame(contextLoader, cls.getClassLoader(), + "class must be loaded from the thread context classloader"); + assertNotSame(PGpoint.class, cls, + "the context classloader defines its own copy, distinct from the application's"); + + // driver: there is no fallback, so the blind classloader fails outright. + assertThrows(ClassNotFoundException.class, () -> ClassUtils.forName(className, + PGobject.class, ClassLoaderStrategy.DRIVER, blind)); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + + private static byte[] bytecodeOf(Class clazz) throws IOException { + try (InputStream in = clazz.getResourceAsStream(clazz.getSimpleName() + ".class")) { + if (in == null) { + throw new IOException("Cannot find bytecode for " + clazz.getName()); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + } + + /** + * Defines one named class from the given bytecode (child-first); every other class delegates to + * the parent. A class it defines reports this loader as its {@link Class#getClassLoader()}. + */ + private static final class SingleClassDefiningClassLoader extends ClassLoader { + private final String className; + private final byte[] bytecode; + + SingleClassDefiningClassLoader(ClassLoader parent, String className, byte[] bytecode) { + super(parent); + this.className = className; + this.bytecode = bytecode; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (className.equals(name)) { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + loaded = defineClass(name, bytecode, 0, bytecode.length); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + return super.loadClass(name, resolve); + } + } +}