diff --git a/rewrite-java/src/main/java/org/openrewrite/java/JavaUnrestrictedClassLoader.java b/rewrite-java/src/main/java/org/openrewrite/java/JavaUnrestrictedClassLoader.java index 9535195abac..04550aa720d 100644 --- a/rewrite-java/src/main/java/org/openrewrite/java/JavaUnrestrictedClassLoader.java +++ b/rewrite-java/src/main/java/org/openrewrite/java/JavaUnrestrictedClassLoader.java @@ -20,6 +20,11 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; @@ -49,6 +54,7 @@ public class JavaUnrestrictedClassLoader extends URLClassLoader { } final List modules; + private final Set exportedPackages = Collections.synchronizedSet(new HashSet<>()); public JavaUnrestrictedClassLoader(ClassLoader parentClassloader) { this(parentClassloader, getLombok()); @@ -112,7 +118,12 @@ public Class loadClass(String name) throws ClassNotFoundException { // app classloader to load jdk.compiler classes before this classloader does. // Fall back to parent delegation for the already-loaded class. try { - return super.loadClass(name); + Class fallback = super.loadClass(name); + // The fallback class is in a named module (e.g. jdk.compiler) while + // classes we already defined are in our unnamed module. Add an export + // from the named module to our unnamed module so they can interoperate. + addExportIfNeeded(fallback); + return fallback; } catch (ClassNotFoundException cnfe) { throw e; } @@ -192,6 +203,76 @@ public E nextElement() { return super.getResource(name); } + /** + * When a class falls back to parent delegation (due to LinkageError), it may end up in a + * named module (e.g. jdk.compiler) while classes we already defined are in our unnamed module. + * This method adds an export from the named module to our unnamed module so classes across the + * module boundary can access each other. + *

+ * All Module API access is done via reflection because this class compiles with --release 8. + */ + private void addExportIfNeeded(Class clazz) { + try { + Class moduleClass = Class.forName("java.lang.Module"); + + Method getModule = Class.class.getMethod("getModule"); + Object classModule = getModule.invoke(clazz); + + Method isNamed = moduleClass.getMethod("isNamed"); + if (!(boolean) isNamed.invoke(classModule)) { + return; + } + + String className = clazz.getName(); + int lastDot = className.lastIndexOf('.'); + if (lastDot < 0) { + return; + } + String packageName = className.substring(0, lastDot); + + Method getUnnamedModule = ClassLoader.class.getMethod("getUnnamedModule"); + Object unnamedModule = getUnnamedModule.invoke(this); + + Method isExported = moduleClass.getMethod("isExported", String.class, moduleClass); + if ((boolean) isExported.invoke(classModule, packageName, unnamedModule)) { + return; + } + if (!exportedPackages.add(packageName)) { + return; + } + + forceAddExport(moduleClass, classModule, packageName, unnamedModule); + } catch (Throwable t) { + // Module API not available (Java 8) or unable to add export; + // downstream code will likely fail with IllegalAccessError + } + } + + /** + * Uses sun.misc.Unsafe to obtain a trusted MethodHandles.Lookup, then invokes + * Module.implAddExports to add an export from the source module to the target module. + * This bypasses the module system's caller check that would otherwise prevent code in the + * unnamed module from adding exports to a named module. + */ + private static void forceAddExport(Class moduleClass, Object source, String packageName, Object target) throws Throwable { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe"); + theUnsafe.setAccessible(true); + Object unsafe = theUnsafe.get(null); + + Field implLookupField = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP"); + Method staticFieldOffset = unsafeClass.getMethod("staticFieldOffset", Field.class); + long offset = (long) staticFieldOffset.invoke(unsafe, implLookupField); + Method getObject = unsafeClass.getMethod("getObject", Object.class, long.class); + MethodHandles.Lookup trustedLookup = + (MethodHandles.Lookup) getObject.invoke(unsafe, MethodHandles.Lookup.class, offset); + + MethodHandle implAddExports = trustedLookup.findVirtual( + moduleClass, "implAddExports", + MethodType.methodType(void.class, String.class, moduleClass)); + implAddExports.invoke(source, packageName, target); + } + private @Nullable Class loadIsolatedClass(String className) { if (!className.startsWith("org.openrewrite.java.isolated")) { return null; diff --git a/rewrite-java/src/test/java/org/openrewrite/java/JavaUnrestrictedClassLoaderTest.java b/rewrite-java/src/test/java/org/openrewrite/java/JavaUnrestrictedClassLoaderTest.java new file mode 100644 index 00000000000..db0b9cdb34c --- /dev/null +++ b/rewrite-java/src/test/java/org/openrewrite/java/JavaUnrestrictedClassLoaderTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.java; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class JavaUnrestrictedClassLoaderTest { + + private static boolean hasModuleSystem() { + try { + Class.forName("java.lang.Module"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + /** + * Verifies that when a class from a named module (jdk.compiler) is passed to + * addExportIfNeeded, the module export is added so that classes in the + * classloader's unnamed module can access classes in that package. + *

+ * This tests the fix for the JDK 25 IllegalAccessError where a module split + * (some classes in unnamed module, some in jdk.compiler) caused access failures. + */ + @Test + void addExportIfNeededExportsPackageFromNamedModule() throws Throwable { + assumeTrue(hasModuleSystem(), "Requires JDK 9+ module system"); + + JavaUnrestrictedClassLoader loader = new JavaUnrestrictedClassLoader( + getClass().getClassLoader()); + + try { + Class moduleClass = Class.forName("java.lang.Module"); + Method getModule = Class.class.getMethod("getModule"); + Method getUnnamedModule = ClassLoader.class.getMethod("getUnnamedModule"); + Method isExported = moduleClass.getMethod("isExported", String.class, moduleClass); + + // Load a class that lives in jdk.compiler module via the system classloader. + // Class.forName succeeds for non-exported packages; the class object is in + // jdk.compiler module regardless of export status. + Class tokensClass = Class.forName( + "com.sun.tools.javac.parser.Tokens", + false, + ClassLoader.getSystemClassLoader()); + + Object jdkCompilerModule = getModule.invoke(tokensClass); + Object unnamedModule = getUnnamedModule.invoke(loader); + String packageName = "com.sun.tools.javac.parser"; + + // Call addExportIfNeeded via reflection + Method addExportIfNeeded = JavaUnrestrictedClassLoader.class + .getDeclaredMethod("addExportIfNeeded", Class.class); + addExportIfNeeded.setAccessible(true); + addExportIfNeeded.invoke(loader, tokensClass); + + // After the call, jdk.compiler should export the package to our unnamed module + boolean exported = (boolean) isExported.invoke( + jdkCompilerModule, packageName, unnamedModule); + assertThat(exported) + .as("jdk.compiler should export com.sun.tools.javac.parser to the classloader's unnamed module") + .isTrue(); + } finally { + loader.close(); + } + } + + /** + * Verifies that addExportIfNeeded is a no-op for classes in the unnamed module + * (i.e., classes defined by our own classloader via defineClass). + */ + @Test + void addExportIfNeededSkipsUnnamedModuleClasses() throws Throwable { + assumeTrue(hasModuleSystem(), "Requires JDK 9+ module system"); + + JavaUnrestrictedClassLoader loader = new JavaUnrestrictedClassLoader( + getClass().getClassLoader()); + + try { + // Load a class through our classloader's defineClass path (unnamed module) + Class contextClass = loader.loadClass("com.sun.tools.javac.util.Context"); + + Method getModule = Class.class.getMethod("getModule"); + Object module = getModule.invoke(contextClass); + + Class moduleClass = Class.forName("java.lang.Module"); + Method isNamed = moduleClass.getMethod("isNamed"); + + // The class should be in our unnamed module (loaded via defineClass) + assertThat((boolean) isNamed.invoke(module)) + .as("Class loaded via defineClass should be in unnamed module") + .isFalse(); + + // addExportIfNeeded should silently return without error + Method addExportIfNeeded = JavaUnrestrictedClassLoader.class + .getDeclaredMethod("addExportIfNeeded", Class.class); + addExportIfNeeded.setAccessible(true); + addExportIfNeeded.invoke(loader, contextClass); + // No exception = success + } finally { + loader.close(); + } + } + + /** + * Verifies that addExportIfNeeded is idempotent - calling it multiple times + * for classes in the same package does not fail. + */ + @Test + void addExportIfNeededIsIdempotent() throws Throwable { + assumeTrue(hasModuleSystem(), "Requires JDK 9+ module system"); + + JavaUnrestrictedClassLoader loader = new JavaUnrestrictedClassLoader( + getClass().getClassLoader()); + + try { + Class tokensClass = Class.forName( + "com.sun.tools.javac.parser.Tokens", + false, + ClassLoader.getSystemClassLoader()); + Class tokenKindClass = Class.forName( + "com.sun.tools.javac.parser.Tokens$TokenKind", + false, + ClassLoader.getSystemClassLoader()); + + Method addExportIfNeeded = JavaUnrestrictedClassLoader.class + .getDeclaredMethod("addExportIfNeeded", Class.class); + addExportIfNeeded.setAccessible(true); + + // Call multiple times for classes in the same package - should not throw + addExportIfNeeded.invoke(loader, tokensClass); + addExportIfNeeded.invoke(loader, tokenKindClass); + addExportIfNeeded.invoke(loader, tokensClass); + } finally { + loader.close(); + } + } +}