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]); + } +}