Skip to content

Commit 931d275

Browse files
mcebanupgradeclaude
andcommitted
Fix JDK 25 IllegalAccessError from module split in JavaUnrestrictedClassLoader
When running via Maven plugin on JDK 25, the ClassRealm classloader establishes loader constraints that prevent defineClass for certain jdk.compiler classes. The existing LinkageError fallback (from openrewrite#6736) delegates to the parent, but this creates a module split: some classes end up in the unnamed module while others land in jdk.compiler. Since jdk.compiler does not export its internal packages to unnamed modules, cross-references between split classes fail with IllegalAccessError. Fix by dynamically adding a module export from the named module to the classloader's unnamed module when falling back to parent delegation. Uses sun.misc.Unsafe to obtain a trusted MethodHandles.Lookup that can invoke Module.implAddExports, bypassing the module system's caller check. All Module API access is done via reflection for Java 8 source compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 998e8c3 commit 931d275

2 files changed

Lines changed: 238 additions & 1 deletion

File tree

rewrite-java/src/main/java/org/openrewrite/java/JavaUnrestrictedClassLoader.java

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
import java.io.ByteArrayOutputStream;
2121
import java.io.IOException;
2222
import java.io.InputStream;
23+
import java.lang.invoke.MethodHandle;
24+
import java.lang.invoke.MethodHandles;
25+
import java.lang.invoke.MethodType;
26+
import java.lang.reflect.Field;
27+
import java.lang.reflect.Method;
2328
import java.net.MalformedURLException;
2429
import java.net.URI;
2530
import java.net.URL;
@@ -49,6 +54,7 @@ public class JavaUnrestrictedClassLoader extends URLClassLoader {
4954
}
5055

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

5359
public JavaUnrestrictedClassLoader(ClassLoader parentClassloader) {
5460
this(parentClassloader, getLombok());
@@ -112,7 +118,12 @@ public Class<?> loadClass(String name) throws ClassNotFoundException {
112118
// app classloader to load jdk.compiler classes before this classloader does.
113119
// Fall back to parent delegation for the already-loaded class.
114120
try {
115-
return super.loadClass(name);
121+
Class<?> fallback = super.loadClass(name);
122+
// The fallback class is in a named module (e.g. jdk.compiler) while
123+
// classes we already defined are in our unnamed module. Add an export
124+
// from the named module to our unnamed module so they can interoperate.
125+
addExportIfNeeded(fallback);
126+
return fallback;
116127
} catch (ClassNotFoundException cnfe) {
117128
throw e;
118129
}
@@ -192,6 +203,76 @@ public E nextElement() {
192203
return super.getResource(name);
193204
}
194205

206+
/**
207+
* When a class falls back to parent delegation (due to LinkageError), it may end up in a
208+
* named module (e.g. jdk.compiler) while classes we already defined are in our unnamed module.
209+
* This method adds an export from the named module to our unnamed module so classes across the
210+
* module boundary can access each other.
211+
* <p>
212+
* All Module API access is done via reflection because this class compiles with --release 8.
213+
*/
214+
private void addExportIfNeeded(Class<?> clazz) {
215+
try {
216+
Class<?> moduleClass = Class.forName("java.lang.Module");
217+
218+
Method getModule = Class.class.getMethod("getModule");
219+
Object classModule = getModule.invoke(clazz);
220+
221+
Method isNamed = moduleClass.getMethod("isNamed");
222+
if (!(boolean) isNamed.invoke(classModule)) {
223+
return;
224+
}
225+
226+
String className = clazz.getName();
227+
int lastDot = className.lastIndexOf('.');
228+
if (lastDot < 0) {
229+
return;
230+
}
231+
String packageName = className.substring(0, lastDot);
232+
233+
Method getUnnamedModule = ClassLoader.class.getMethod("getUnnamedModule");
234+
Object unnamedModule = getUnnamedModule.invoke(this);
235+
236+
Method isExported = moduleClass.getMethod("isExported", String.class, moduleClass);
237+
if ((boolean) isExported.invoke(classModule, packageName, unnamedModule)) {
238+
return;
239+
}
240+
if (!exportedPackages.add(packageName)) {
241+
return;
242+
}
243+
244+
forceAddExport(moduleClass, classModule, packageName, unnamedModule);
245+
} catch (Throwable t) {
246+
// Module API not available (Java 8) or unable to add export;
247+
// downstream code will likely fail with IllegalAccessError
248+
}
249+
}
250+
251+
/**
252+
* Uses sun.misc.Unsafe to obtain a trusted MethodHandles.Lookup, then invokes
253+
* Module.implAddExports to add an export from the source module to the target module.
254+
* This bypasses the module system's caller check that would otherwise prevent code in the
255+
* unnamed module from adding exports to a named module.
256+
*/
257+
private static void forceAddExport(Class<?> moduleClass, Object source, String packageName, Object target) throws Throwable {
258+
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
259+
Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe");
260+
theUnsafe.setAccessible(true);
261+
Object unsafe = theUnsafe.get(null);
262+
263+
Field implLookupField = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
264+
Method staticFieldOffset = unsafeClass.getMethod("staticFieldOffset", Field.class);
265+
long offset = (long) staticFieldOffset.invoke(unsafe, implLookupField);
266+
Method getObject = unsafeClass.getMethod("getObject", Object.class, long.class);
267+
MethodHandles.Lookup trustedLookup =
268+
(MethodHandles.Lookup) getObject.invoke(unsafe, MethodHandles.Lookup.class, offset);
269+
270+
MethodHandle implAddExports = trustedLookup.findVirtual(
271+
moduleClass, "implAddExports",
272+
MethodType.methodType(void.class, String.class, moduleClass));
273+
implAddExports.invoke(source, packageName, target);
274+
}
275+
195276
private @Nullable Class<?> loadIsolatedClass(String className) {
196277
if (!className.startsWith("org.openrewrite.java.isolated")) {
197278
return null;
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.java;
17+
18+
import org.junit.jupiter.api.Test;
19+
20+
import java.lang.reflect.Method;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
24+
25+
class JavaUnrestrictedClassLoaderTest {
26+
27+
private static boolean hasModuleSystem() {
28+
try {
29+
Class.forName("java.lang.Module");
30+
return true;
31+
} catch (ClassNotFoundException e) {
32+
return false;
33+
}
34+
}
35+
36+
/**
37+
* Verifies that when a class from a named module (jdk.compiler) is passed to
38+
* addExportIfNeeded, the module export is added so that classes in the
39+
* classloader's unnamed module can access classes in that package.
40+
* <p>
41+
* This tests the fix for the JDK 25 IllegalAccessError where a module split
42+
* (some classes in unnamed module, some in jdk.compiler) caused access failures.
43+
*/
44+
@Test
45+
void addExportIfNeededExportsPackageFromNamedModule() throws Throwable {
46+
assumeTrue(hasModuleSystem(), "Requires JDK 9+ module system");
47+
48+
JavaUnrestrictedClassLoader loader = new JavaUnrestrictedClassLoader(
49+
getClass().getClassLoader());
50+
51+
try {
52+
Class<?> moduleClass = Class.forName("java.lang.Module");
53+
Method getModule = Class.class.getMethod("getModule");
54+
Method getUnnamedModule = ClassLoader.class.getMethod("getUnnamedModule");
55+
Method isExported = moduleClass.getMethod("isExported", String.class, moduleClass);
56+
57+
// Load a class that lives in jdk.compiler module via the system classloader.
58+
// Class.forName succeeds for non-exported packages; the class object is in
59+
// jdk.compiler module regardless of export status.
60+
Class<?> tokensClass = Class.forName(
61+
"com.sun.tools.javac.parser.Tokens",
62+
false,
63+
ClassLoader.getSystemClassLoader());
64+
65+
Object jdkCompilerModule = getModule.invoke(tokensClass);
66+
Object unnamedModule = getUnnamedModule.invoke(loader);
67+
String packageName = "com.sun.tools.javac.parser";
68+
69+
// Call addExportIfNeeded via reflection
70+
Method addExportIfNeeded = JavaUnrestrictedClassLoader.class
71+
.getDeclaredMethod("addExportIfNeeded", Class.class);
72+
addExportIfNeeded.setAccessible(true);
73+
addExportIfNeeded.invoke(loader, tokensClass);
74+
75+
// After the call, jdk.compiler should export the package to our unnamed module
76+
boolean exported = (boolean) isExported.invoke(
77+
jdkCompilerModule, packageName, unnamedModule);
78+
assertThat(exported)
79+
.as("jdk.compiler should export com.sun.tools.javac.parser to the classloader's unnamed module")
80+
.isTrue();
81+
} finally {
82+
loader.close();
83+
}
84+
}
85+
86+
/**
87+
* Verifies that addExportIfNeeded is a no-op for classes in the unnamed module
88+
* (i.e., classes defined by our own classloader via defineClass).
89+
*/
90+
@Test
91+
void addExportIfNeededSkipsUnnamedModuleClasses() throws Throwable {
92+
assumeTrue(hasModuleSystem(), "Requires JDK 9+ module system");
93+
94+
JavaUnrestrictedClassLoader loader = new JavaUnrestrictedClassLoader(
95+
getClass().getClassLoader());
96+
97+
try {
98+
// Load a class through our classloader's defineClass path (unnamed module)
99+
Class<?> contextClass = loader.loadClass("com.sun.tools.javac.util.Context");
100+
101+
Method getModule = Class.class.getMethod("getModule");
102+
Object module = getModule.invoke(contextClass);
103+
104+
Class<?> moduleClass = Class.forName("java.lang.Module");
105+
Method isNamed = moduleClass.getMethod("isNamed");
106+
107+
// The class should be in our unnamed module (loaded via defineClass)
108+
assertThat((boolean) isNamed.invoke(module))
109+
.as("Class loaded via defineClass should be in unnamed module")
110+
.isFalse();
111+
112+
// addExportIfNeeded should silently return without error
113+
Method addExportIfNeeded = JavaUnrestrictedClassLoader.class
114+
.getDeclaredMethod("addExportIfNeeded", Class.class);
115+
addExportIfNeeded.setAccessible(true);
116+
addExportIfNeeded.invoke(loader, contextClass);
117+
// No exception = success
118+
} finally {
119+
loader.close();
120+
}
121+
}
122+
123+
/**
124+
* Verifies that addExportIfNeeded is idempotent - calling it multiple times
125+
* for classes in the same package does not fail.
126+
*/
127+
@Test
128+
void addExportIfNeededIsIdempotent() throws Throwable {
129+
assumeTrue(hasModuleSystem(), "Requires JDK 9+ module system");
130+
131+
JavaUnrestrictedClassLoader loader = new JavaUnrestrictedClassLoader(
132+
getClass().getClassLoader());
133+
134+
try {
135+
Class<?> tokensClass = Class.forName(
136+
"com.sun.tools.javac.parser.Tokens",
137+
false,
138+
ClassLoader.getSystemClassLoader());
139+
Class<?> tokenKindClass = Class.forName(
140+
"com.sun.tools.javac.parser.Tokens$TokenKind",
141+
false,
142+
ClassLoader.getSystemClassLoader());
143+
144+
Method addExportIfNeeded = JavaUnrestrictedClassLoader.class
145+
.getDeclaredMethod("addExportIfNeeded", Class.class);
146+
addExportIfNeeded.setAccessible(true);
147+
148+
// Call multiple times for classes in the same package - should not throw
149+
addExportIfNeeded.invoke(loader, tokensClass);
150+
addExportIfNeeded.invoke(loader, tokenKindClass);
151+
addExportIfNeeded.invoke(loader, tokensClass);
152+
} finally {
153+
loader.close();
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)