Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +54,7 @@ public class JavaUnrestrictedClassLoader extends URLClassLoader {
}

final List<Path> modules;
private final Set<String> exportedPackages = Collections.synchronizedSet(new HashSet<>());

public JavaUnrestrictedClassLoader(ClassLoader parentClassloader) {
this(parentClassloader, getLombok());
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
* <p>
* 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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.
* <p>
* 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();
}
}
}