From 997b5d53fd0cbfdda12ee87358a98746b34dda29 Mon Sep 17 00:00:00 2001 From: Sascha Bartl Date: Mon, 23 Mar 2026 21:04:30 +0100 Subject: [PATCH] Replace Unsafe classpath lookup in launcher Stop using sun.misc.Unsafe to read the app class loader internals in ClassLoaders.systemClassPaths() and switch to standard java.class.path parsing instead. This removes the terminal deprecation warning on newer JDKs while keeping the resolved classpath output unchanged. Observed warning: WARNING: A terminally deprecated method in sun.misc.Unsafe has been called WARNING: sun.misc.Unsafe::objectFieldOffset has been called by space.vectrix.ignite.util.ClassLoaders (file:/usr/bin/paper/ignite-launcher.jar) WARNING: Please consider reporting this to the maintainers of class space.vectrix.ignite.util.ClassLoaders WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release Add a regression test that compares the new implementation against the previous Unsafe-based lookup on JDK internal class loaders, and wire in the minimal JUnit 5 test configuration required for launcher tests. Verified with a direct JVM comparison of old vs. new output (`same=true`) and a successful `:ignite-launcher:test` run. --- .../ignite.common-conventions.gradle.kts | 4 + gradle/libs.versions.toml | 4 + launcher/build.gradle.kts | 4 + .../vectrix/ignite/util/ClassLoaders.java | 53 +++----- .../vectrix/ignite/util/ClassLoadersTest.java | 127 ++++++++++++++++++ 5 files changed, 160 insertions(+), 32 deletions(-) create mode 100644 launcher/src/test/java/space/vectrix/ignite/util/ClassLoadersTest.java diff --git a/build-logic/src/main/kotlin/ignite.common-conventions.gradle.kts b/build-logic/src/main/kotlin/ignite.common-conventions.gradle.kts index 78a891e..4ce6e80 100644 --- a/build-logic/src/main/kotlin/ignite.common-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/ignite.common-conventions.gradle.kts @@ -11,6 +11,10 @@ dependencies { compileOnlyApi(libs.jetbrains.annotations) } +tasks.withType().configureEach { + useJUnitPlatform() +} + spotless { java { importOrderFile(rootProject.file(".spotless/vectrix.importorder")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20df9fd..10c9e36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,9 +6,13 @@ asm = "9.9.1" checkstyle = "12.3.1" indra = "4.0.0" tinylog = "2.7.0" +junit = "5.13.4" [libraries] jetbrains-annotations = { module = "org.jetbrains:annotations", version = "26.1.0" } +junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } +junit-launcher = { module = "org.junit.platform:junit-platform-launcher" } # logger tinylog-api = { module = "org.tinylog:tinylog-api", version.ref = "tinylog" } diff --git a/launcher/build.gradle.kts b/launcher/build.gradle.kts index 536082e..7819df6 100644 --- a/launcher/build.gradle.kts +++ b/launcher/build.gradle.kts @@ -26,4 +26,8 @@ dependencies { implementation(libs.asm.util) implementation(libs.gson) + + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.launcher) } diff --git a/launcher/src/main/java/space/vectrix/ignite/util/ClassLoaders.java b/launcher/src/main/java/space/vectrix/ignite/util/ClassLoaders.java index 8188012..954077a 100644 --- a/launcher/src/main/java/space/vectrix/ignite/util/ClassLoaders.java +++ b/launcher/src/main/java/space/vectrix/ignite/util/ClassLoaders.java @@ -24,11 +24,14 @@ */ package space.vectrix.ignite.util; -import java.lang.reflect.Field; -import java.lang.reflect.Method; +import java.io.File; +import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; +import java.util.List; import org.jetbrains.annotations.NotNull; import org.tinylog.Logger; @@ -45,47 +48,33 @@ public final class ClassLoaders { * @return the system class path urls * @since 1.0.0 */ - @SuppressWarnings({"restriction", "unchecked"}) public static URL@NotNull [] systemClassPaths() { final ClassLoader classLoader = ClassLoaders.class.getClassLoader(); if(classLoader instanceof URLClassLoader) { return ((URLClassLoader) classLoader).getURLs(); } - if(classLoader.getClass().getName().startsWith("jdk.internal.loader.ClassLoaders$")) { - try { - final Class unsafeClass = Class.forName("sun.misc.Unsafe"); - final Method objectFieldOffsetMethod = unsafeClass.getDeclaredMethod("objectFieldOffset", Field.class); - final Method getObjectMethod = unsafeClass.getDeclaredMethod("getObject", Object.class, long.class); - - final Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); - unsafeField.setAccessible(true); - final Object unsafe = unsafeField.get(null); - - // jdk.internal.loader.ClassLoaders.AppClassLoader.ucp - Field ucpField; - try { - ucpField = classLoader.getClass().getDeclaredField("ucp"); - } catch(final NoSuchFieldException | SecurityException e) { - ucpField = classLoader.getClass().getSuperclass().getDeclaredField("ucp"); - } - - final long ucpFieldOffset = (long) objectFieldOffsetMethod.invoke(unsafe, ucpField); - final Object ucpObject = getObjectMethod.invoke(unsafe, classLoader, ucpFieldOffset); + final String classPath = System.getProperty("java.class.path", ""); + if(classPath.isEmpty()) { + return new URL[0]; + } - // jdk.internal.loader.URLClassPath.path - final Field pathField = ucpField.getType().getDeclaredField("path"); - final long pathFieldOffset = (long) objectFieldOffsetMethod.invoke(unsafe, pathField); - final ArrayList path = (ArrayList) getObjectMethod.invoke(unsafe, ucpObject, pathFieldOffset); + final String separator = System.getProperty("path.separator", File.pathSeparator); + final List urls = new ArrayList<>(); + for(final String entry : classPath.split(java.util.regex.Pattern.quote(separator))) { + if(entry.isEmpty()) { + continue; + } - return path.toArray(new URL[0]); - } catch(final Exception exception) { - Logger.error(exception, "Failed to retrieve system classloader paths!"); - return new URL[0]; + try { + final Path path = Paths.get(entry); + urls.add(path.toUri().toURL()); + } catch(final MalformedURLException exception) { + Logger.warn(exception, "Skipping malformed class path entry: {}", entry); } } - return new URL[0]; + return urls.toArray(new URL[0]); } private ClassLoaders() { diff --git a/launcher/src/test/java/space/vectrix/ignite/util/ClassLoadersTest.java b/launcher/src/test/java/space/vectrix/ignite/util/ClassLoadersTest.java new file mode 100644 index 0000000..9e6b145 --- /dev/null +++ b/launcher/src/test/java/space/vectrix/ignite/util/ClassLoadersTest.java @@ -0,0 +1,127 @@ +/* + * This file is part of Ignite, licensed under the MIT License (MIT). + * + * Copyright (c) vectrix.space + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package space.vectrix.ignite.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ClassLoadersTest { + @Test + void systemClassPathsMatchesJavaClassPathProperty() { + final URL[] expected = this.classPathPropertyUrls(); + final URL[] actual = ClassLoaders.systemClassPaths(); + + assertArrayEquals(expected, actual); + } + + @Test + void systemClassPathsMatchesLegacyUnsafeLookupOnJdkInternalClassLoader() throws Exception { + final ClassLoader classLoader = ClassLoaders.class.getClassLoader(); + if(classLoader instanceof URLClassLoader || !classLoader.getClass().getName().startsWith("jdk.internal.loader.ClassLoaders$")) { + return; + } + + final URL[] expected = this.legacyUnsafeSystemClassPaths(classLoader); + final URL[] actual = ClassLoaders.systemClassPaths(); + + assertArrayEquals(expected, actual); + } + + @Test + void javaClassPathPropertyExpansionIsStable() { + final String classPath = System.getProperty("java.class.path", ""); + final String separator = System.getProperty("path.separator", File.pathSeparator); + + final long nonEmptyEntries = classPath.isEmpty() + ? 0 + : java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(separator)) + .splitAsStream(classPath) + .filter(entry -> !entry.isEmpty()) + .count(); + + assertEquals(nonEmptyEntries, ClassLoaders.systemClassPaths().length); + } + + private URL[] classPathPropertyUrls() { + final String classPath = System.getProperty("java.class.path", ""); + if(classPath.isEmpty()) { + return new URL[0]; + } + + final String separator = System.getProperty("path.separator", File.pathSeparator); + final List urls = new ArrayList<>(); + for(final String entry : classPath.split(java.util.regex.Pattern.quote(separator))) { + if(entry.isEmpty()) { + continue; + } + + final Path path = Paths.get(entry); + try { + urls.add(path.toUri().toURL()); + } catch(final Exception exception) { + throw new AssertionError("Failed to convert class path entry to URL: " + entry, exception); + } + } + + return urls.toArray(new URL[0]); + } + + @SuppressWarnings({"restriction", "unchecked"}) + private URL[] legacyUnsafeSystemClassPaths(final ClassLoader classLoader) throws Exception { + final Class unsafeClass = Class.forName("sun.misc.Unsafe"); + final Method objectFieldOffsetMethod = unsafeClass.getDeclaredMethod("objectFieldOffset", Field.class); + final Method getObjectMethod = unsafeClass.getDeclaredMethod("getObject", Object.class, long.class); + + final Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + final Object unsafe = unsafeField.get(null); + + Field ucpField; + try { + ucpField = classLoader.getClass().getDeclaredField("ucp"); + } catch(final NoSuchFieldException | SecurityException exception) { + ucpField = classLoader.getClass().getSuperclass().getDeclaredField("ucp"); + } + + final long ucpFieldOffset = (long) objectFieldOffsetMethod.invoke(unsafe, ucpField); + final Object ucpObject = getObjectMethod.invoke(unsafe, classLoader, ucpFieldOffset); + + final Field pathField = ucpField.getType().getDeclaredField("path"); + final long pathFieldOffset = (long) objectFieldOffsetMethod.invoke(unsafe, pathField); + final ArrayList path = (ArrayList) getObjectMethod.invoke(unsafe, ucpObject, pathFieldOffset); + return path.toArray(new URL[0]); + } +}