From ccfe12eb17c043c73a1419b2dbd766e20648b8e6 Mon Sep 17 00:00:00 2001 From: shartte Date: Tue, 4 Jan 2022 19:15:21 +0100 Subject: [PATCH] Interface Injection (#496) * Added interface injection via fabric.mod.json. * Added interface injection * Added amending of class signature with injected interface. --- .../loom/api/LoomGradleExtensionAPI.java | 9 + .../InterfaceInjectionProcessor.java | 299 ++++++++++++++++++ .../mappings/MappingsProviderImpl.java | 9 + .../extension/LoomGradleExtensionApiImpl.java | 9 + .../integration/InterfaceInjectionTest.groovy | 51 +++ .../projects/interfaceInjection/build.gradle | 18 ++ .../dummyDependency/fabric.mod.json | 12 + .../src/main/java/ExampleMod.java | 10 + .../src/main/java/InjectedInterface.java | 5 + 9 files changed, 422 insertions(+) create mode 100644 src/main/java/net/fabricmc/loom/configuration/ifaceinject/InterfaceInjectionProcessor.java create mode 100644 src/test/groovy/net/fabricmc/loom/test/integration/InterfaceInjectionTest.groovy create mode 100644 src/test/resources/projects/interfaceInjection/build.gradle create mode 100644 src/test/resources/projects/interfaceInjection/dummyDependency/fabric.mod.json create mode 100644 src/test/resources/projects/interfaceInjection/src/main/java/ExampleMod.java create mode 100644 src/test/resources/projects/interfaceInjection/src/main/java/InjectedInterface.java diff --git a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java index d5d50dd..efec199 100644 --- a/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java +++ b/src/main/java/net/fabricmc/loom/api/LoomGradleExtensionAPI.java @@ -132,4 +132,13 @@ public interface LoomGradleExtensionAPI { * @return the intermediary url template */ Property getIntermediaryUrl(); + + /** + * When true loom will inject interfaces declared in mod manifests into the minecraft jar file. + * This is used to expose interfaces that are implemented on Minecraft classes by mixins at runtime + * in the dev environment. + * + * @return the property controlling interface injection. + */ + Property getEnableInterfaceInjection(); } diff --git a/src/main/java/net/fabricmc/loom/configuration/ifaceinject/InterfaceInjectionProcessor.java b/src/main/java/net/fabricmc/loom/configuration/ifaceinject/InterfaceInjectionProcessor.java new file mode 100644 index 0000000..1666658 --- /dev/null +++ b/src/main/java/net/fabricmc/loom/configuration/ifaceinject/InterfaceInjectionProcessor.java @@ -0,0 +1,299 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2021 FabricMC + * + * 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 net.fabricmc.loom.configuration.ifaceinject; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.gradle.api.Project; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.commons.Remapper; + +import net.fabricmc.loom.LoomGradleExtension; +import net.fabricmc.loom.configuration.RemappedConfigurationEntry; +import net.fabricmc.loom.configuration.processors.JarProcessor; +import net.fabricmc.loom.util.Constants; +import net.fabricmc.loom.util.Pair; +import net.fabricmc.loom.util.TinyRemapperHelper; +import net.fabricmc.loom.util.ZipUtils; +import net.fabricmc.tinyremapper.TinyRemapper; + +public class InterfaceInjectionProcessor implements JarProcessor { + // Filename used to store hash of injected interfaces in processed jar file + private static final String HASH_FILENAME = "injected_interfaces.sha256"; + + private final Map> injectedInterfaces; + private final Project project; + private final LoomGradleExtension extension; + private final byte[] inputHash; + private Map> remappedInjectedInterfaces; + + public InterfaceInjectionProcessor(Project project) { + this.project = project; + this.extension = LoomGradleExtension.get(project); + this.injectedInterfaces = getInjectedInterfaces().stream() + .collect(Collectors.groupingBy(InjectedInterface::className)); + + this.inputHash = hashInjectedInterfaces(); + } + + public boolean isEmpty() { + return injectedInterfaces.isEmpty(); + } + + @Override + public void setup() { + } + + @Override + public void process(File jarFile) { + // Lazily remap from intermediary->named + if (remappedInjectedInterfaces == null) { + TinyRemapper tinyRemapper = createTinyRemapper(); + Remapper remapper = tinyRemapper.getEnvironment().getRemapper(); + + try { + remappedInjectedInterfaces = new HashMap<>(injectedInterfaces.size()); + + for (Map.Entry> entry : injectedInterfaces.entrySet()) { + String namedClassName = remapper.map(entry.getKey()); + remappedInjectedInterfaces.put( + namedClassName, + entry.getValue().stream() + .map(injectedInterface -> + new InjectedInterface( + injectedInterface.modId(), + namedClassName, + remapper.map(injectedInterface.ifaceName()) + )) + .toList() + ); + } + } finally { + tinyRemapper.finish(); + } + } + + project.getLogger().lifecycle("Processing file: " + jarFile.getName()); + + try { + ZipUtils.transform(jarFile.toPath(), getTransformers()); + } catch (IOException e) { + throw new RuntimeException("Failed to apply interface injections to " + jarFile, e); + } + } + + private List>> getTransformers() { + return remappedInjectedInterfaces.keySet().stream() + .map(string -> new Pair<>(string.replaceAll("\\.", "/") + ".class", getTransformer(string))) + .collect(Collectors.toList()); + } + + private ZipUtils.UnsafeUnaryOperator getTransformer(String className) { + return input -> { + ClassReader reader = new ClassReader(input); + ClassWriter writer = new ClassWriter(0); + List ifaces = remappedInjectedInterfaces.get(className); + ClassVisitor classVisitor = new InjectingClassVisitor(Constants.ASM_VERSION, writer, ifaces); + + // Log which mods add which interface to the class + project.getLogger().info("Injecting interfaces into " + className + ": " + + ifaces.stream().map(i -> i.ifaceName() + " [" + i.modId() + "]" + ).collect(Collectors.joining(", "))); + + reader.accept(classVisitor, 0); + return writer.toByteArray(); + }; + } + + @Override + public boolean isInvalid(File file) { + byte[] hash; + + try { + hash = ZipUtils.unpackNullable(file.toPath(), HASH_FILENAME); + } catch (IOException e) { + return true; + } + + if (hash == null) { + return true; + } + + return !Arrays.equals(inputHash, hash); + } + + private List getInjectedInterfaces() { + List result = new ArrayList<>(); + + for (RemappedConfigurationEntry entry : Constants.MOD_COMPILE_ENTRIES) { + // Only apply injected interfaces from mods that are part of the compile classpath + if (!entry.compileClasspath()) { + continue; + } + + Set artifacts = extension.getLazyConfigurationProvider(entry.sourceConfiguration()) + .get() + .resolve(); + + for (File artifact : artifacts) { + result.addAll(InjectedInterface.fromModJar(artifact.toPath())); + } + } + + return result; + } + + private record InjectedInterface(String modId, String className, String ifaceName) { + /** + * Reads the injected interfaces contained in a mod jar, or returns null if there is none. + */ + public static List fromModJar(Path modJarPath) { + byte[] modJsonBytes; + + try { + modJsonBytes = ZipUtils.unpackNullable(modJarPath, "fabric.mod.json"); + } catch (IOException e) { + throw new RuntimeException("Failed to extract fabric.mod.json from " + modJarPath); + } + + if (modJsonBytes == null) { + return Collections.emptyList(); + } + + JsonObject jsonObject = new Gson().fromJson(new String(modJsonBytes, StandardCharsets.UTF_8), JsonObject.class); + + String modId = jsonObject.get("id").getAsString(); + + if (!jsonObject.has("custom")) { + return Collections.emptyList(); + } + + JsonObject custom = jsonObject.getAsJsonObject("custom"); + + if (!custom.has("loom:injected_interfaces")) { + return Collections.emptyList(); + } + + JsonObject addedIfaces = custom.getAsJsonObject("loom:injected_interfaces"); + + List result = new ArrayList<>(); + + for (String className : addedIfaces.keySet()) { + JsonArray ifaceNames = addedIfaces.getAsJsonArray(className); + + for (JsonElement ifaceName : ifaceNames) { + result.add(new InjectedInterface(modId, className, ifaceName.getAsString())); + } + } + + return result; + } + } + + private static class InjectingClassVisitor extends ClassVisitor { + private final List injectedInterfaces; + + InjectingClassVisitor(int asmVersion, ClassWriter writer, List injectedInterfaces) { + super(asmVersion, writer); + this.injectedInterfaces = injectedInterfaces; + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + Set modifiedInterfaces = new LinkedHashSet<>(interfaces.length + injectedInterfaces.size()); + Collections.addAll(modifiedInterfaces, interfaces); + + for (InjectedInterface injectedInterface : injectedInterfaces) { + modifiedInterfaces.add(injectedInterface.ifaceName()); + } + + // See JVMS: https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-4.html#jvms-ClassSignature + if (signature != null) { + var resultingSignature = new StringBuilder(signature); + + for (InjectedInterface injectedInterface : injectedInterfaces) { + String superinterfaceSignature = "L" + injectedInterface.ifaceName() + ";"; + + if (resultingSignature.indexOf(superinterfaceSignature) == -1) { + resultingSignature.append(superinterfaceSignature); + } + } + + signature = resultingSignature.toString(); + } + + super.visit(version, access, name, signature, superName, modifiedInterfaces.toArray(new String[0])); + } + } + + private TinyRemapper createTinyRemapper() { + try { + TinyRemapper tinyRemapper = TinyRemapperHelper.getTinyRemapper(project, "intermediary", "named"); + tinyRemapper.readClassPath(TinyRemapperHelper.getMinecraftDependencies(project)); + tinyRemapper.readClassPath(extension.getMinecraftMappedProvider().getIntermediaryJar().toPath()); + + return tinyRemapper; + } catch (IOException e) { + throw new RuntimeException("Failed to create tiny remapper for intermediary->named", e); + } + } + + private byte[] hashInjectedInterfaces() { + // Hash the interfaces we're about to inject to not have to repeat this everytime + Hasher hasher = Hashing.sha256().newHasher(); + + for (Map.Entry> entry : injectedInterfaces.entrySet()) { + hasher.putString("class:", StandardCharsets.UTF_8); + hasher.putString(entry.getKey(), StandardCharsets.UTF_8); + + for (InjectedInterface ifaceName : entry.getValue()) { + hasher.putString("iface:", StandardCharsets.UTF_8); + hasher.putString(ifaceName.ifaceName(), StandardCharsets.UTF_8); + } + } + + return hasher.hash().asBytes(); + } +} diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/mappings/MappingsProviderImpl.java b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/MappingsProviderImpl.java index 3c84fd9..3bd3b12 100644 --- a/src/main/java/net/fabricmc/loom/configuration/providers/mappings/MappingsProviderImpl.java +++ b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/MappingsProviderImpl.java @@ -56,6 +56,7 @@ import net.fabricmc.loom.api.mappings.layered.MappingsNamespace; import net.fabricmc.loom.configuration.DependencyProvider; import net.fabricmc.loom.configuration.accesswidener.AccessWidenerJarProcessor; import net.fabricmc.loom.configuration.accesswidener.TransitiveAccessWidenerJarProcessor; +import net.fabricmc.loom.configuration.ifaceinject.InterfaceInjectionProcessor; import net.fabricmc.loom.configuration.processors.JarProcessorManager; import net.fabricmc.loom.configuration.processors.MinecraftProcessedProvider; import net.fabricmc.loom.configuration.providers.MinecraftProviderImpl; @@ -159,6 +160,14 @@ public class MappingsProviderImpl extends DependencyProvider implements Mappings } } + if (extension.getEnableInterfaceInjection().get()) { + InterfaceInjectionProcessor jarProcessor = new InterfaceInjectionProcessor(getProject()); + + if (!jarProcessor.isEmpty()) { + extension.getGameJarProcessors().add(jarProcessor); + } + } + extension.getAccessWidenerPath().finalizeValue(); extension.getGameJarProcessors().finalizeValue(); JarProcessorManager processorManager = new JarProcessorManager(extension.getGameJarProcessors().get()); diff --git a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java index 78b1da5..58dbd7e 100644 --- a/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java +++ b/src/main/java/net/fabricmc/loom/extension/LoomGradleExtensionApiImpl.java @@ -63,6 +63,7 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA protected final Property setupRemappedVariants; protected final Property transitiveAccessWideners; protected final Property intermediary; + protected final Property enableInterfaceInjection; private final ModVersionParser versionParser; @@ -88,6 +89,9 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA this.transitiveAccessWideners.finalizeValueOnRead(); this.intermediary = project.getObjects().property(String.class) .convention("https://maven.fabricmc.net/net/fabricmc/intermediary/%1$s/intermediary-%1$s-v2.jar"); + this.enableInterfaceInjection = project.getObjects().property(Boolean.class) + .convention(true); + this.enableInterfaceInjection.finalizeValueOnRead(); this.versionParser = new ModVersionParser(project); @@ -181,6 +185,11 @@ public abstract class LoomGradleExtensionApiImpl implements LoomGradleExtensionA return intermediary; } + @Override + public Property getEnableInterfaceInjection() { + return enableInterfaceInjection; + } + @Override public void disableDeprecatedPomGeneration(MavenPublication publication) { net.fabricmc.loom.configuration.MavenPublication.excludePublication(publication); diff --git a/src/test/groovy/net/fabricmc/loom/test/integration/InterfaceInjectionTest.groovy b/src/test/groovy/net/fabricmc/loom/test/integration/InterfaceInjectionTest.groovy new file mode 100644 index 0000000..8e731f6 --- /dev/null +++ b/src/test/groovy/net/fabricmc/loom/test/integration/InterfaceInjectionTest.groovy @@ -0,0 +1,51 @@ +/* + * This file is part of fabric-loom, licensed under the MIT License (MIT). + * + * Copyright (c) 2016-2021 FabricMC + * + * 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 net.fabricmc.loom.test.integration + +import net.fabricmc.loom.test.util.GradleProjectTestTrait +import net.fabricmc.loom.util.ZipUtils +import spock.lang.Specification +import spock.lang.Unroll + +import static net.fabricmc.loom.test.LoomTestConstants.STANDARD_TEST_VERSIONS +import static org.gradle.testkit.runner.TaskOutcome.SUCCESS + +class InterfaceInjectionTest extends Specification implements GradleProjectTestTrait { + @Unroll + def "interface injection (gradle #version)"() { + setup: + def gradle = gradleProject(project: "interfaceInjection", version: version) + ZipUtils.pack(new File(gradle.projectDir, "dummyDependency").toPath(), new File(gradle.projectDir, "dummy.jar").toPath()) + + when: + def result = gradle.run(task: "build") + + then: + result.task(":build").outcome == SUCCESS + + where: + version << STANDARD_TEST_VERSIONS + } +} diff --git a/src/test/resources/projects/interfaceInjection/build.gradle b/src/test/resources/projects/interfaceInjection/build.gradle new file mode 100644 index 0000000..52f1fc3 --- /dev/null +++ b/src/test/resources/projects/interfaceInjection/build.gradle @@ -0,0 +1,18 @@ +// This is used by a range of tests that append to this file before running the gradle tasks. +// Can be used for tests that require minimal custom setup +plugins { + id 'fabric-loom' + id 'maven-publish' +} + +archivesBaseName = "fabric-example-mod" +version = "1.0.0" +group = "com.example" + +dependencies { + minecraft "com.mojang:minecraft:1.17.1" + mappings "net.fabricmc:yarn:1.17.1+build.59:v2" + modImplementation "net.fabricmc:fabric-loader:0.11.6" + + modImplementation files("dummy.jar") +} \ No newline at end of file diff --git a/src/test/resources/projects/interfaceInjection/dummyDependency/fabric.mod.json b/src/test/resources/projects/interfaceInjection/dummyDependency/fabric.mod.json new file mode 100644 index 0000000..0d61ca3 --- /dev/null +++ b/src/test/resources/projects/interfaceInjection/dummyDependency/fabric.mod.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "id": "dummy", + "version": "1", + "name": "Dummy Mod", + "custom": { + "fabric-api:module-lifecycle": "stable", + "loom:injected_interfaces": { + "net/minecraft/class_2248": ["InjectedInterface"] + } + } +} diff --git a/src/test/resources/projects/interfaceInjection/src/main/java/ExampleMod.java b/src/test/resources/projects/interfaceInjection/src/main/java/ExampleMod.java new file mode 100644 index 0000000..c73a730 --- /dev/null +++ b/src/test/resources/projects/interfaceInjection/src/main/java/ExampleMod.java @@ -0,0 +1,10 @@ +import net.minecraft.block.Blocks; + +import net.fabricmc.api.ModInitializer; + +public class ExampleMod implements ModInitializer { + @Override + public void onInitialize() { + Blocks.AIR.newMethodThatDidNotExist(); + } +} diff --git a/src/test/resources/projects/interfaceInjection/src/main/java/InjectedInterface.java b/src/test/resources/projects/interfaceInjection/src/main/java/InjectedInterface.java new file mode 100644 index 0000000..f7e7a2e --- /dev/null +++ b/src/test/resources/projects/interfaceInjection/src/main/java/InjectedInterface.java @@ -0,0 +1,5 @@ + +public interface InjectedInterface { + default void newMethodThatDidNotExist() { + } +}