package net.minestom.server.extras.selfmodification; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; import java.io.StringWriter; import java.lang.reflect.InvocationTargetException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.CodeSigner; import java.security.CodeSource; import java.security.ProtectionDomain; import java.security.cert.Certificate; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.ApiStatus.AvailableSince; import org.jetbrains.annotations.ApiStatus.Experimental; import org.jetbrains.annotations.ApiStatus.Internal; import org.jetbrains.annotations.ApiStatus.ScheduledForRemoval; import org.jetbrains.annotations.CheckReturnValue; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.util.CheckClassAdapter; import org.objectweb.asm.util.TraceClassVisitor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.stianloader.sll.transform.CodeTransformer; import de.geolykt.starloader.launcher.Utils; import de.geolykt.starloader.transformers.ASMTransformer; import de.geolykt.starloader.transformers.RawClassData; import de.geolykt.starloader.transformers.TransformableClassloader; import de.geolykt.starloader.util.JavaInterop; import de.geolykt.starloader.util.OrderedCollection; /** * Class Loader that can modify class bytecode when they are loaded. */ public class MinestomRootClassLoader extends HierarchyClassLoader implements TransformableClassloader { @Internal public static final boolean DEBUG = Boolean.getBoolean("classloader.debug"); private static final boolean DUMP = MinestomRootClassLoader.DEBUG || Boolean.getBoolean("classloader.dump"); private static MinestomRootClassLoader INSTANCE; @NotNull @Internal @AvailableSince(value = "4.0.0-a20240730") private static final ThreadLocal LOG_CLASSLOADING_FAILURES = Objects.requireNonNull(ThreadLocal.withInitial(() -> true)); private static final Logger LOGGER = LoggerFactory.getLogger(MinestomRootClassLoader.class); /** * Used to let ASM find out common super types, without actually committing to loading them * Otherwise ASM would accidentally load classes we might want to modify. */ private final URLClassLoader asmClassLoader; @NotNull private final Map classCodeSourceURIs = new ConcurrentHashMap<>(); @NotNull private final Collection modifiers = new OrderedCollection<>(); /** * Classes that cannot be loaded/modified by this classloader. * Will go through parent class loader */ private final Set protectedClasses = ConcurrentHashMap.newKeySet(); public final Set protectedPackages = ConcurrentHashMap.newKeySet(); @Deprecated @ScheduledForRemoval(inVersion = "5.0.0") private de.geolykt.starloader.deobf.access.AccessTransformInfo widener = new de.geolykt.starloader.deobf.access.AccessTransformInfo(); { this.protectedClasses.add("de.geolykt.starloader.Starloader"); this.protectedClasses.add("de.geolykt.starloader.UnlikelyEventException"); this.protectedClasses.add("org.spongepowered.asm.mixin.injection.callback.CallbackInfo"); this.protectedClasses.add("org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable"); this.protectedClasses.add("org.spongepowered.asm.mixin.injection.callback.CancellationException"); this.protectedPackages.add("org.objectweb.asm"); this.protectedPackages.add("org.slf4j"); this.protectedPackages.add("org.json"); this.protectedPackages.add("net.minestom.server.extras.selfmodification"); // We do not want to load this package ourselves this.protectedPackages.add("de.geolykt.starloader.transformers"); this.protectedPackages.add("de.geolykt.starloader.launcher"); this.protectedPackages.add("de.geolykt.starloader.deobf.access"); this.protectedPackages.add("de.geolykt.starloader.mod"); this.protectedPackages.add("de.geolykt.starloader.util"); this.protectedPackages.add("ch.qos.logback"); this.protectedPackages.add("org.stianloader.micromixin.transform"); } private MinestomRootClassLoader(ClassLoader parent) { super("Starloader Root ClassLoader", new URL[0], parent); this.asmClassLoader = this.newChild(); } public static MinestomRootClassLoader getInstance() { if (MinestomRootClassLoader.INSTANCE == null) { synchronized (MinestomRootClassLoader.class) { if (MinestomRootClassLoader.INSTANCE == null) { MinestomRootClassLoader.INSTANCE = new MinestomRootClassLoader(MinestomRootClassLoader.class.getClassLoader()); } } } return MinestomRootClassLoader.INSTANCE; } @Override @NotNull public Class loadClass(@NotNull String name, boolean resolve) throws ClassNotFoundException { Class loadedClass = this.findLoadedClass(Objects.requireNonNull(name, "name must not be null")); if (loadedClass != null) { return loadedClass; } boolean skippedPlatformCL = false; ClassLoader platformClassLoader = JavaInterop.getPlatformClassLoader(); try { // we do not load system classes by ourselves if (platformClassLoader != null) { Class systemClass = platformClassLoader.loadClass(name); if (systemClass.getClassLoader() != platformClassLoader) { skippedPlatformCL = true; throw new ClassNotFoundException("When loading the class with a platform classloader returned by a Java 9 " + (JavaInterop.isJava9() ? "capable " : "incapable") + " JavaInterop implementation, the class was loaded by a different classloader. Presuming the class to be on the boot module layer - ignoring it."); } else { MinestomRootClassLoader.LOGGER.trace("Loading system class: {}", systemClass); return systemClass; } } throw new ClassNotFoundException("Java 9 " + (JavaInterop.isJava9() ? "capable " : "incapable") + " JavaInterop implementation refused to return the platform classloader."); } catch (ClassNotFoundException cnfe1) { try { if (this.isProtected(name)) { MinestomRootClassLoader.LOGGER.trace("Protected: {}", name); return super.loadClass(name, resolve); } return this.define(name, resolve); } catch (Throwable ex1) { if (ex1 instanceof ThreadDeath) { throw (ThreadDeath) ex1; } MinestomRootClassLoader.LOGGER.trace("Failed to load class \"{}\", resorting to parent loader. Code modifications forbidden.", name, ex1); // fail to load class, let parent load // this forbids code modification, but at least it will load try { return super.loadClass(name, resolve); } catch (ClassNotFoundException cnfe2) { // let platform classloader load if not done so already if (skippedPlatformCL) { try { return Objects.requireNonNull(platformClassLoader).loadClass(name); } catch (ClassNotFoundException ex2) { cnfe2.addSuppressed(ex2); } } cnfe2.addSuppressed(cnfe1); cnfe2.addSuppressed(ex1); throw cnfe2; } } } } @Contract(pure = true) @AvailableSince(value = "4.0.0-a20241104") // Note: protected since 4.0.0-a20240512, was private before that. public boolean isProtected(String name) { if (!this.protectedClasses.contains(name)) { for (String start : this.protectedPackages) { if (name.startsWith(start)) { return true; } } return false; } return true; } @Override @Contract(pure = true) @AvailableSince(value = "4.0.0-a20240730") public boolean isThreadLoggingClassloadingFailures() { return MinestomRootClassLoader.LOG_CLASSLOADING_FAILURES.get(); } @NotNull private Class define(String name, boolean resolve) throws IOException, ClassNotFoundException { try { RawClassData rawClass; try { rawClass = this.loadClassBytes(name, true); } catch (Throwable t) { throw new ClassNotFoundException("Unable to load bytes", t); } Class defined; byte[] bytes = rawClass.getBytes(); URL jarURL = rawClass.getSource(); if (jarURL == null) { defined = this.defineClass(name, bytes, 0, bytes.length); } else { String path = jarURL.getPath(); int seperatorIndex = path.lastIndexOf('!'); if (seperatorIndex != -1) { jarURL = new URL(path.substring(0, seperatorIndex)); } defined = this.defineClass(name, bytes, 0, bytes.length, new CodeSource(jarURL, (CodeSigner[]) null)); } MinestomRootClassLoader.LOGGER.trace("Loaded with code modifiers: {}", name); if (resolve) { this.resolveClass(defined); } return defined; } catch (LinkageError e) { // Well we did hit the right classloader (so no need to check children), but it did not produce the right output throw new ClassNotFoundException("Invalid bytecode for class " + name, e); } catch (ClassNotFoundException e) { // could not load inside this classloader, attempt with children Class defined = null; for (MinestomExtensionClassLoader subloader : children) { try { defined = subloader.loadClassAsChild(name, resolve); MinestomRootClassLoader.LOGGER.trace("Loaded from child {}: {}", subloader, name); return defined; } catch (ClassNotFoundException e1) { // not found inside this child, move on to next e.addSuppressed(e1); } } throw e; } } /** * Loads and possibly transforms class bytecode corresponding to the given binary name. * * @param name * @param transform * @return The transformed bytes attached with the URL the bytes were loaded from. * @throws IOException * @throws ClassNotFoundException * @since 3.1.0 */ public RawClassData loadClassBytes(String name, boolean transform) throws IOException, ClassNotFoundException { if (name == null) { throw new ClassNotFoundException("Name may not be null."); } String path = name.replace(".", "/") + ".class"; URL url = this.findResource(path); InputStream input; if (url == null) { input = this.getResourceAsStream(name); } else { input = url.openStream(); } if (input == null) { throw new ClassNotFoundException("Could not find resource " + path); } byte @NotNull[] originalBytes = JavaInterop.readAllBytes(input); input.close(); byte @NotNull[] transformedBytes; if (transform) { transformedBytes = this.transformBytes(originalBytes, name, Utils.toCodeSourceURI(url, name)); } else { transformedBytes = originalBytes; } if (MinestomRootClassLoader.DUMP) { Path parent = Paths.get("classes", path).getParent(); if (parent != null) { Files.createDirectories(parent); } Files.write(Paths.get("classes", path), transformedBytes); } return new RawClassData(url, transformedBytes); } @Deprecated public byte[] loadBytesWithChildren(String name, boolean transform) throws IOException, ClassNotFoundException { if (name == null) { throw new ClassNotFoundException(); } String path = name.replace(".", "/") + ".class"; InputStream input = this.getResourceAsStreamWithChildren(path); if (input == null) { throw new ClassNotFoundException("Could not find resource " + path); } byte[] originalBytes = JavaInterop.readAllBytes(input); input.close(); if (transform) { return this.transformBytes(originalBytes, name, null); } return originalBytes; } synchronized byte @NotNull[] transformBytes(byte @NotNull[] classBytecode, @NotNull String qualifiedName, @Nullable URI codeSourceURI) { if (!this.isProtected(qualifiedName)) { ClassReader reader = new ClassReader(classBytecode); ClassNode node = new ClassNode(); boolean modified = false; reader.accept(node, 0); if (codeSourceURI != null) { this.classCodeSourceURIs.putIfAbsent(node.name, codeSourceURI); } try { @SuppressWarnings("deprecation") boolean hack = this.widener.apply(node, true); modified = hack; synchronized (this.modifiers) { Iterator transformers = this.modifiers.iterator(); while (transformers.hasNext()) { ASMTransformer transformer = transformers.next(); String internalName = node.name; if (internalName == null) { throw new NullPointerException(); } if (MinestomRootClassLoader.DEBUG) { MinestomRootClassLoader.LOGGER.info("{} could be able to transform {}", transformer.getClass().getSimpleName(), internalName); } if (transformer instanceof CodeTransformer ? ((CodeTransformer) transformer).isValidTarget(internalName, codeSourceURI) && ((CodeTransformer) transformer).transformClass(node, codeSourceURI) : transformer.isValidTarget(internalName) && transformer.accept(node)) { if (MinestomRootClassLoader.DEBUG) { MinestomRootClassLoader.LOGGER.info("{} was transformed by a {}", internalName, transformer.getClass().getSimpleName()); } if (!transformer.isValid()) { transformers.remove(); } modified = true; } } } } catch (Throwable t) { // Apparently errors would get absorbed otherwise. if (this.isThreadLoggingClassloadingFailures()) { MinestomRootClassLoader.LOGGER.error("Error within ASM transforming process. CLASS {} WILL NOT BE MODIFIED - THIS MAY BE LETHAL.", qualifiedName, t); } throw new RuntimeException("Error within ASM transforming process for class " + qualifiedName, t); } try { if (modified) { ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES) { @Override protected ClassLoader getClassLoader() { return MinestomRootClassLoader.this.asmClassLoader; } }; node.accept(writer); classBytecode = Objects.requireNonNull(writer.toByteArray()); } } catch (Throwable t) { try { StringWriter disassembledClass = new StringWriter(); TraceClassVisitor traceVisitor = new TraceClassVisitor(new PrintWriter(disassembledClass)); CheckClassAdapter checkAdapter = new CheckClassAdapter(Opcodes.ASM9, traceVisitor, true) { @Override public void visitInnerClass(String name, String outerName, String innerName, int access) { super.visitInnerClass(name, outerName, innerName, access & ~Opcodes.ACC_SUPER); } }; node.accept(checkAdapter); throw new RuntimeException("The class seems to be intact, but ASM does not like it anyways. In order to help on your debugging journey, take this:\n" + disassembledClass.toString()); } catch (Throwable t0) { if (t0 instanceof ThreadDeath) { throw (ThreadDeath) t0; } else if (t0 instanceof OutOfMemoryError) { throw (OutOfMemoryError) t0; } t.addSuppressed(t0); } if (this.isThreadLoggingClassloadingFailures()) { MinestomRootClassLoader.LOGGER.error("Unable to write ASM Classnode to bytecode for class '{}' (bork transformer?)", qualifiedName, t); } if (t instanceof ThreadDeath) { throw (ThreadDeath) t; } else if (t instanceof OutOfMemoryError) { throw (OutOfMemoryError) t; } throw new RuntimeException("Unable to write ASM Classnode to bytecode for class " + qualifiedName, t); } } return classBytecode; } // Overridden to increase access (from protected to public) @Override public Class findClass(String name) throws ClassNotFoundException { return super.findClass(name); } @NotNull @Internal public URLClassLoader newChild(@NotNull URL... urls) { return Objects.requireNonNull(URLClassLoader.newInstance(urls, this)); } @Internal public void loadModifier(ClassLoader modifierLoader, @NotNull String codeModifierClass) { try { Class modifierClass = modifierLoader.loadClass(codeModifierClass); if (ASMTransformer.class.isAssignableFrom(modifierClass)) { addTransformer((ASMTransformer) modifierClass.getDeclaredConstructor().newInstance()); } } catch (ClassNotFoundException | InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e) { e.printStackTrace(); } } /** * Adds a transformer to the transformer pool. * * @param transformer The transformer to add * @since 2.1.0 * @deprecated */ @Deprecated @ScheduledForRemoval(inVersion = "5.0.0") public synchronized void addTransformer(ASMTransformer transformer) { this.addASMTransformer(Objects.requireNonNull(transformer)); } @Override public void addURL(URL url) { super.addURL(url); } /** * Obtains a clone of the list of ASM Transformers currently in use. * * @return The ASM transformers in use. * @since 2.1.0 * @deprecated Use {@link TransformableClassloader#getASMTransformers()} instead. */ @Deprecated @ScheduledForRemoval(inVersion = "5.0.0") public synchronized List getTransformers() { synchronized (this.modifiers) { return new ArrayList<>(this.modifiers); } } @Deprecated @ScheduledForRemoval(inVersion = "5.0.0") public void readAccessWidener(@NotNull InputStream in) throws IOException { try (de.geolykt.starloader.deobf.access.AccessWidenerReader accessReader = new de.geolykt.starloader.deobf.access.AccessWidenerReader(widener, in, true)) { accessReader.readHeader(); while (accessReader.readLn()) { // Continue reading } } } @Override @Contract(pure = false, value = "_ -> this") @AvailableSince(value = "4.0.0-a20240730") public MinestomRootClassLoader setThreadLoggingClassloadingFailures(boolean logFailures) { MinestomRootClassLoader.LOG_CLASSLOADING_FAILURES.set(logFailures); return this; } @Override @Contract(pure = false, mutates = "this") @AvailableSince(value = "4.0.0-a20231223") public void addASMTransformer(@NotNull ASMTransformer transformer) { synchronized (this.modifiers) { if (MinestomRootClassLoader.DEBUG) { MinestomRootClassLoader.LOGGER.info("Adding transformer {}", transformer.getClass().getName()); } this.modifiers.add(transformer); if (MinestomRootClassLoader.DEBUG) { MinestomRootClassLoader.LOGGER.info("Currently registered transformers: "); for (ASMTransformer x : this.modifiers) { MinestomRootClassLoader.LOGGER.info(" - {}", x.getClass().getName()); } } } } @SuppressWarnings("null") @Override @NotNull @Unmodifiable @Contract(pure = true, value = "-> new") @AvailableSince(value = "4.0.0-a20231223") public Collection<@NotNull ASMTransformer> getASMTransformers() { synchronized (this.modifiers) { return Collections.unmodifiableCollection(new ArrayList<>(this.modifiers)); } } /** * Obtain the code source URI of the class, as per {@link CodeSource#getLocation()}. * If the location is unknown, or if the class was not defined by this classloader, * nor by any of it's children, then the returned value may be null. * *

This method is mainly intended to be used for debugging purposes, or in more * concrete terms, to aid tools used for debugging, such as IDEs or profilers. Perhaps however * you will find a different use for this method, who knows? * *

This method is experimental. It was added for a rather narrow usecase, which * might vanish or prove to be irrelevant in the future. Handle with care, and be more * keen on sharing feedback with the SLL developers when using this method. * *

If multiple classes declare multiple classes with the same name, then the * result is undefined. This may especially impact shaded libraries. If needed, * it may be beneficial to instead obtain the source through the * {@link Class#getProtectionDomain() protection domain} of a class via {@link ProtectionDomain#getCodeSource()} * and then {@link CodeSource#getLocation()}. That approach however would involve reflection * and would be slower and may have different usecases compared to * this method as it implies that the classloader is already precisely known. * * @param internalName The internal name of the class, as per {@link Type#getInternalName()}. * @return The {@link URI} where the given class is located in, following the * paradigms of {@link CodeSource#getLocation()}. For classes located inside JARs, the * URI of the jar will be used. For classes within directories, the URI of the directory * is used (i.e. file://bin/ for file://bin/com/example/Main.class). * If the location of the class file is unknown or cannot be represented as an {@link URI}, * null is used. */ @Nullable @Contract(pure = true) @AvailableSince("4.0.0-a20250819") @Experimental public URI getClassCodeSourceURI(@NotNull String internalName) { return this.classCodeSourceURIs.get(internalName); } @SuppressWarnings("null") @Override @NotNull @Contract(pure = false) @CheckReturnValue @AvailableSince(value = "4.0.0-a20231223") public Class transformAndDefineClass(@NotNull String className, @NotNull RawClassData data) { if (MinestomRootClassLoader.DEBUG) { MinestomRootClassLoader.LOGGER.info("Forcefully defining class '{}'", className); } URL jarURL = data.getSource(); URI jarURI; try { jarURI = jarURL == null ? null : jarURL.toURI(); } catch (URISyntaxException e) { LoggerFactory.getLogger(MinestomRootClassLoader.class).debug("Cannot convert URL {} to a URI.", jarURL, e); jarURI = null; } byte[] transformed = this.transformBytes(data.getBytes(), className, jarURI); if (MinestomRootClassLoader.DUMP) { try { Path parent = Paths.get("classes", className.replace('.', '/')).getParent(); if (parent != null) { Files.createDirectories(parent); } Files.write(Paths.get("classes", className.replace('.', '/') + ".class"), transformed); } catch (IOException e) { MinestomRootClassLoader.LOGGER.info("Unable to dump forcefully defined class '{}'", className, e); } } if (jarURL == null) { return super.defineClass(className, transformed, 0, transformed.length); } else { String path = jarURL.getPath(); int seperatorIndex = path.lastIndexOf('!'); if (seperatorIndex != -1) { try { jarURL = new URL(path.substring(0, seperatorIndex)); } catch (MalformedURLException e) { MinestomRootClassLoader.LOGGER.warn("Bumped into a MalformedURLException while forcefully defining a class", e); } } return super.defineClass(className, transformed, 0, transformed.length, new CodeSource(jarURL, (Certificate[]) null)); } } }