From c40e1cf1527da1f781a581cc4a9684181d072c64 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 7 Aug 2026 16:39:34 -0600 Subject: [PATCH] Scary CI Builds. --- .github/workflows/ci.yml | 4 +- build.gradle | 51 ++++- .../src/main/java/BukkitArtifactVerifier.java | 183 ++++++++++++++++++ buildSrc/src/main/java/ClassReferences.java | 114 +++++++++++ .../test/java/BukkitArtifactVerifierTest.java | 176 +++++++++++++++++ core/build.gradle | 49 ++++- 6 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 buildSrc/src/main/java/BukkitArtifactVerifier.java create mode 100644 buildSrc/src/main/java/ClassReferences.java create mode 100644 buildSrc/src/test/java/BukkitArtifactVerifierTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c285824..a085db701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,9 @@ jobs: run: ./gradlew -p adapters/forge test -PuseLocalVolmLib=false --console=plain --stacktrace - name: Run modded shared tests (NeoForge) run: ./gradlew -p adapters/neoforge test -PuseLocalVolmLib=false --console=plain --stacktrace - - name: Test modded artifact verifier + - name: Test artifact verifiers run: ./gradlew -p buildSrc test --console=plain --stacktrace + - name: Verify Bukkit artifact + run: ./gradlew verifyBukkitArtifact --no-parallel -PuseLocalVolmLib=false --console=plain --stacktrace - name: Verify modded artifacts run: ./gradlew verifyModdedArtifacts --no-parallel -PuseLocalVolmLib=false --console=plain --stacktrace diff --git a/build.gradle b/build.gradle index b5ba32622..7f82c85ad 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,8 @@ project(':core') { version = rootProject.version } String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.2') +// The Bukkit jar runs Paper 26.1.2 through 26.2 (compile-low pins); the loader jars stay 26.2-only. +String bukkitMinecraftRange = providers.gradleProperty('bukkitMinecraftRange').getOrElse('26.1.2-26.2') String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.3') String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.0.4') String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.12-beta') @@ -67,7 +69,7 @@ Closure loaderDisplayVersion = { String loaderVersion -> Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } -String bukkitArtifactName = irisArtifactName('CraftBukkit', minecraftVersion) +String bukkitArtifactName = irisArtifactName('CraftBukkit', bukkitMinecraftRange) String fabricArtifactName = irisArtifactName('Fabric', "${minecraftVersion}+${loaderDisplayVersion(fabricLoaderVersion)}") String forgeArtifactName = irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}") String neoForgeArtifactName = irisArtifactName('NeoForge', "${minecraftVersion}+${loaderDisplayVersion(neoForgeVersion)}") @@ -477,6 +479,53 @@ tasks.register('verifyModdedArtifacts') { dependsOn('verifyFabricArtifact', 'verifyForgeArtifact', 'verifyNeoforgeArtifact') } +// Entries the Bukkit artifact only ever reaches by name - NMS binding lookup, the SIMD kernels, the +// instrumentation agent, the JarScanner-driven command and service discovery, the Matter codec - plus +// the two plugin descriptors and the slimjar manifests. Nothing in the reference graph points at +// them, so a shadowJar exclude that drops one is silent until a server hits that path. +List requiredBukkitArtifactEntries = [ + 'plugin.yml', + 'paper-plugin.yml', + 'slimjar.dat', + 'slimjar-resolutions.dat', + 'agent.jar', + 'art/arcane/iris/Iris.class', + 'art/arcane/iris/IrisBootstrap.class', + 'art/arcane/iris/core/nms/INMS.class', + 'art/arcane/iris/core/nms/v26_2_R1/NMSBinding.class', + 'art/arcane/iris/core/nms/v1X/NMSBinding1X.class', + 'art/arcane/iris/engine/platform/PlatformChunkGenerator.class', + 'art/arcane/iris/core/lifecycle/WorldLifecycleStaging.class', + 'art/arcane/iris/util/simd/VectorSimdKernels.class', + 'art/arcane/iris/util/simd/VectorNoiseKernels2D.class', + 'art/arcane/iris/util/project/agent/Agent.class', + 'art/arcane/iris/util/common/misc/getHardware.class', + 'art/arcane/iris/util/caffeine/cache/Caffeine.class', + 'art/arcane/volmlib/util/io/JarScanner.class', + 'art/arcane/volmlib/util/director/runtime/DirectorRuntimeEngine.class', + 'art/arcane/volmlib/util/mantle/io/Lz4IOWorkerCodecSupport.class', + 'art/arcane/volmlib/util/matter/Matter.class' +] + +tasks.register('verifyBukkitArtifact') { + group = 'verification' + dependsOn('jar') + File artifact = layout.buildDirectory.file("libs/${bukkitArtifactName}").get().asFile + inputs.file(artifact) + doLast { + BukkitArtifactVerifier.verify(artifact, requiredBukkitArtifactEntries, 17, 400, 16) + logger.lifecycle("Verified ${artifact.name} packaging and class reference graph") + } +} + +tasks.named('buildBukkit').configure { + dependsOn('verifyBukkitArtifact') +} + +tasks.named('iris').configure { + dependsOn('verifyBukkitArtifact') +} + tasks.named('buildFabric').configure { dependsOn('verifyFabricArtifact') } diff --git a/buildSrc/src/main/java/BukkitArtifactVerifier.java b/buildSrc/src/main/java/BukkitArtifactVerifier.java new file mode 100644 index 000000000..bede3b3a6 --- /dev/null +++ b/buildSrc/src/main/java/BukkitArtifactVerifier.java @@ -0,0 +1,183 @@ +import org.gradle.api.GradleException; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Packaging gate for the CraftBukkit artifact. Every shadowJar exclude is silent at build time, so + * this re-derives the class reference graph over the shipped jar and fails on anything the excludes + * left dangling, plus asserts the entries that are only ever reached by name. + */ +public final class BukkitArtifactVerifier { + // Packages the artifact is expected to ship in full. A CONSTANT_Class entry naming a missing + // class under one of these is a NoClassDefFoundError waiting for the right code path. + private static final List SHIPPED_PREFIXES = List.of( + "art/arcane/iris/", + "art/arcane/volmlib/", + "com/google/gson/", + "com/googlecode/concurrentlinkedhashmap/" + ); + // Relocation targets for the libraries slimjar downloads and relocates at runtime. Compiled + // references to them are correct and the classes are correctly absent from the jar. + private static final List RUNTIME_DOWNLOADED_PREFIXES = List.of( + "art/arcane/iris/util/paralithic/", + "art/arcane/iris/util/paper/", + "art/arcane/iris/util/kyori/", + "art/arcane/iris/util/metrics/", + "art/arcane/iris/util/sentry/", + "art/arcane/iris/util/maven/", + "art/arcane/iris/util/plexus/", + "art/arcane/iris/util/sisu/", + "art/arcane/iris/util/aether/", + "art/arcane/iris/util/guice/", + "art/arcane/iris/util/dom4j/", + "art/arcane/iris/util/jaxen/" + ); + private static final String CAFFEINE_CACHE_PACKAGE = "art/arcane/iris/util/caffeine/cache/"; + private static final String MATTER_SLICE_PACKAGE = "art/arcane/volmlib/util/matter/slices/"; + private static final String LANGUAGE_DIRECTORY = "languages/"; + + private BukkitArtifactVerifier() { + } + + public static void verify(File artifact, List requiredEntries, int minimumLocales, + int minimumCaffeineFactories, int minimumMatterSlices) { + if (!artifact.isFile()) { + throw new GradleException("Missing Bukkit Iris artifact: " + artifact.getAbsolutePath()); + } + + try (JarFile jar = new JarFile(artifact)) { + for (String requiredEntry : requiredEntries) { + if (jar.getJarEntry(requiredEntry) == null) { + throw new GradleException(artifact.getName() + " is missing " + requiredEntry + + ". If this run also requested a task whose name contains \"test\", :core:processResources" + + " was disabled for it and the artifact is incomplete by construction - build the jar in" + + " its own invocation."); + } + } + + Set shippedClasses = new LinkedHashSet<>(); + int locales = 0; + int caffeineFactories = 0; + int matterSlices = 0; + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.isDirectory()) { + continue; + } + + String name = entry.getName(); + if (name.startsWith(LANGUAGE_DIRECTORY) && name.endsWith(".json")) { + locales++; + } + if (!name.endsWith(".class")) { + continue; + } + + String internalName = name.substring(0, name.length() - ".class".length()); + shippedClasses.add(internalName); + if (isGeneratedCaffeineFactory(internalName)) { + caffeineFactories++; + } + if (internalName.startsWith(MATTER_SLICE_PACKAGE)) { + matterSlices++; + } + } + + if (locales < minimumLocales) { + throw new GradleException(artifact.getName() + " ships " + locales + " locale files; expected at least " + + minimumLocales); + } + // Caffeine picks its cache and node implementation with MethodHandles.Lookup.findClass on a + // name built from the builder's feature flags. Nothing references these statically, so only a + // population check can tell that an exclude or minimize() ate them. + if (caffeineFactories < minimumCaffeineFactories) { + throw new GradleException(artifact.getName() + " ships " + caffeineFactories + + " generated Caffeine cache classes; expected at least " + minimumCaffeineFactories + + ". Caffeine resolves these by name and cannot survive static pruning"); + } + // Matter.read() resolves slice types from the canonical name stored in the payload. + if (matterSlices < minimumMatterSlices) { + throw new GradleException(artifact.getName() + " ships " + matterSlices + + " Matter slice types; expected at least " + minimumMatterSlices + + ". Matter payloads name these types directly"); + } + + TreeMap dangling = new TreeMap<>(); + entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.isDirectory() || !entry.getName().endsWith(".class")) { + continue; + } + + for (String reference : ClassReferences.read(readEntryBytes(jar, entry))) { + if (shippedClasses.contains(reference) + || !startsWithAny(reference, SHIPPED_PREFIXES) + || startsWithAny(reference, RUNTIME_DOWNLOADED_PREFIXES)) { + continue; + } + dangling.putIfAbsent(reference, entry.getName()); + } + } + + if (!dangling.isEmpty()) { + StringBuilder message = new StringBuilder(artifact.getName()) + .append(" references classes it does not ship. A shadowJar exclude removed a class that is") + .append(" still in use:"); + dangling.entrySet().stream().limit(12).forEach(missing -> message.append("\n ") + .append(missing.getKey()) + .append(" (referenced by ") + .append(missing.getValue()) + .append(')')); + if (dangling.size() > 12) { + message.append("\n ... and ").append(dangling.size() - 12).append(" more"); + } + throw new GradleException(message.toString()); + } + } catch (IOException e) { + throw new GradleException("Unable to verify Bukkit Iris artifact " + artifact.getAbsolutePath(), e); + } + } + + private static boolean isGeneratedCaffeineFactory(String internalName) { + if (!internalName.startsWith(CAFFEINE_CACHE_PACKAGE)) { + return false; + } + + String simpleName = internalName.substring(CAFFEINE_CACHE_PACKAGE.length()); + if (simpleName.isEmpty() || simpleName.indexOf('/') >= 0) { + return false; + } + for (int i = 0; i < simpleName.length(); i++) { + if (simpleName.charAt(i) < 'A' || simpleName.charAt(i) > 'Z') { + return false; + } + } + return true; + } + + private static byte[] readEntryBytes(JarFile jar, JarEntry entry) throws IOException { + try (InputStream input = jar.getInputStream(entry)) { + return input.readAllBytes(); + } + } + + private static boolean startsWithAny(String value, List prefixes) { + for (String prefix : prefixes) { + if (value.startsWith(prefix)) { + return true; + } + } + return false; + } +} diff --git a/buildSrc/src/main/java/ClassReferences.java b/buildSrc/src/main/java/ClassReferences.java new file mode 100644 index 000000000..75b3e1a18 --- /dev/null +++ b/buildSrc/src/main/java/ClassReferences.java @@ -0,0 +1,114 @@ +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Reads the {@code CONSTANT_Class} entries of a class file. That is exactly the set of types the + * class links against - supertypes, field and method owners, casts, catches, constant class + * literals. Annotation types live in the annotation attributes as plain UTF-8 descriptors and are + * deliberately not reported: the JVM skips an annotation whose type is absent. + */ +public final class ClassReferences { + private static final int CONSTANT_UTF8 = 1; + private static final int CONSTANT_INTEGER = 3; + private static final int CONSTANT_FLOAT = 4; + private static final int CONSTANT_LONG = 5; + private static final int CONSTANT_DOUBLE = 6; + private static final int CONSTANT_CLASS = 7; + private static final int CONSTANT_STRING = 8; + private static final int CONSTANT_FIELDREF = 9; + private static final int CONSTANT_METHODREF = 10; + private static final int CONSTANT_INTERFACE_METHODREF = 11; + private static final int CONSTANT_NAME_AND_TYPE = 12; + private static final int CONSTANT_METHOD_HANDLE = 15; + private static final int CONSTANT_METHOD_TYPE = 16; + private static final int CONSTANT_DYNAMIC = 17; + private static final int CONSTANT_INVOKE_DYNAMIC = 18; + private static final int CONSTANT_MODULE = 19; + private static final int CONSTANT_PACKAGE = 20; + private static final int CLASS_FILE_MAGIC = 0xCAFEBABE; + + private ClassReferences() { + } + + public static Set read(byte[] classFile) { + Set references = new LinkedHashSet<>(); + if (classFile.length < 10 || readInt(classFile, 0) != CLASS_FILE_MAGIC) { + return references; + } + + int constantCount = readUnsignedShort(classFile, 8); + String[] utf8 = new String[constantCount]; + List classNameIndexes = new ArrayList<>(); + int offset = 10; + int index = 1; + while (index < constantCount) { + int tag = classFile[offset] & 0xFF; + offset++; + switch (tag) { + case CONSTANT_UTF8 -> { + int length = readUnsignedShort(classFile, offset); + offset += 2; + utf8[index] = new String(classFile, offset, length, StandardCharsets.UTF_8); + offset += length; + } + case CONSTANT_CLASS -> { + classNameIndexes.add(readUnsignedShort(classFile, offset)); + offset += 2; + } + case CONSTANT_STRING, CONSTANT_METHOD_TYPE, CONSTANT_MODULE, CONSTANT_PACKAGE -> offset += 2; + case CONSTANT_METHOD_HANDLE -> offset += 3; + case CONSTANT_INTEGER, CONSTANT_FLOAT, CONSTANT_FIELDREF, CONSTANT_METHODREF, + CONSTANT_INTERFACE_METHODREF, CONSTANT_NAME_AND_TYPE, CONSTANT_DYNAMIC, + CONSTANT_INVOKE_DYNAMIC -> offset += 4; + case CONSTANT_LONG, CONSTANT_DOUBLE -> { + offset += 8; + index++; + } + default -> { + return references; + } + } + index++; + } + + for (int nameIndex : classNameIndexes) { + if (nameIndex <= 0 || nameIndex >= constantCount) { + continue; + } + String name = normalize(utf8[nameIndex]); + if (name != null) { + references.add(name); + } + } + return references; + } + + private static String normalize(String rawName) { + if (rawName == null || rawName.isEmpty()) { + return null; + } + + String name = rawName; + while (name.startsWith("[")) { + name = name.substring(1); + } + if (name.startsWith("L") && name.endsWith(";")) { + name = name.substring(1, name.length() - 1); + } + return name.length() > 1 ? name : null; + } + + private static int readUnsignedShort(byte[] data, int offset) { + return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF); + } + + private static int readInt(byte[] data, int offset) { + return ((data[offset] & 0xFF) << 24) + | ((data[offset + 1] & 0xFF) << 16) + | ((data[offset + 2] & 0xFF) << 8) + | (data[offset + 3] & 0xFF); + } +} diff --git a/buildSrc/src/test/java/BukkitArtifactVerifierTest.java b/buildSrc/src/test/java/BukkitArtifactVerifierTest.java new file mode 100644 index 000000000..21366f603 --- /dev/null +++ b/buildSrc/src/test/java/BukkitArtifactVerifierTest.java @@ -0,0 +1,176 @@ +import org.gradle.api.GradleException; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class BukkitArtifactVerifierTest { + private static final String PLUGIN_DESCRIPTOR = "plugin.yml"; + private static final String NMS_BINDING = "art/arcane/iris/core/nms/v26_2_R1/NMSBinding"; + private static final List REQUIRED_ENTRIES = List.of(PLUGIN_DESCRIPTOR, NMS_BINDING + ".class"); + private static final int LOCALES = 2; + private static final int CAFFEINE_FACTORIES = 2; + private static final int MATTER_SLICES = 1; + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsCompleteArtifact() throws Exception { + File artifact = createArtifact(validEntries()); + + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, MATTER_SLICES); + } + + @Test + public void rejectsMissingRequiredEntry() throws Exception { + Map entries = validEntries(); + entries.remove(PLUGIN_DESCRIPTOR); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, + MATTER_SLICES)); + assertTrue(failure.getMessage().contains(PLUGIN_DESCRIPTOR)); + } + + @Test + public void rejectsExcludedClassThatIsStillReferenced() throws Exception { + Map entries = validEntries(); + entries.remove("art/arcane/volmlib/util/noise/CNG.class"); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, + MATTER_SLICES)); + assertTrue(failure.getMessage().contains("art/arcane/volmlib/util/noise/CNG")); + } + + @Test + public void acceptsReferenceToRuntimeDownloadedLibrary() throws Exception { + Map entries = validEntries(); + entries.put("art/arcane/iris/Consumer.class", + classReferencing("art/arcane/iris/Consumer", "art/arcane/iris/util/kyori/adventure/text/Component")); + File artifact = createArtifact(entries); + + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, MATTER_SLICES); + } + + @Test + public void rejectsStrippedCaffeineFactories() throws Exception { + Map entries = validEntries(); + entries.remove("art/arcane/iris/util/caffeine/cache/SSMS.class"); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, + MATTER_SLICES)); + assertTrue(failure.getMessage().contains("generated Caffeine cache classes")); + } + + @Test + public void rejectsDroppedLocale() throws Exception { + Map entries = validEntries(); + entries.remove("languages/de_DE.json"); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, + MATTER_SLICES)); + assertTrue(failure.getMessage().contains("locale files")); + } + + @Test + public void rejectsDroppedMatterSlice() throws Exception { + Map entries = validEntries(); + entries.remove("art/arcane/volmlib/util/matter/slices/BlockMatter.class"); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, + MATTER_SLICES)); + assertTrue(failure.getMessage().contains("Matter slice types")); + } + + @Test + public void ignoresAnnotationOnlyReferences() throws Exception { + Map entries = validEntries(); + entries.put("art/arcane/iris/Annotated.class", + classAnnotatedWith("art/arcane/iris/Annotated", "com/google/errorprone/annotations/CanIgnoreReturnValue")); + File artifact = createArtifact(entries); + + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, CAFFEINE_FACTORIES, MATTER_SLICES); + } + + private Map validEntries() { + Map entries = new LinkedHashMap<>(); + entries.put(PLUGIN_DESCRIPTOR, "name: Iris\n".getBytes(StandardCharsets.UTF_8)); + entries.put("languages/de_DE.json", "{}".getBytes(StandardCharsets.UTF_8)); + entries.put("languages/fr_FR.json", "{}".getBytes(StandardCharsets.UTF_8)); + entries.put(NMS_BINDING + ".class", + classReferencing(NMS_BINDING, "art/arcane/volmlib/util/noise/CNG")); + entries.put("art/arcane/volmlib/util/noise/CNG.class", emptyClass("art/arcane/volmlib/util/noise/CNG")); + entries.put("art/arcane/volmlib/util/matter/slices/BlockMatter.class", + emptyClass("art/arcane/volmlib/util/matter/slices/BlockMatter")); + entries.put("art/arcane/iris/util/caffeine/cache/SSMS.class", + emptyClass("art/arcane/iris/util/caffeine/cache/SSMS")); + entries.put("art/arcane/iris/util/caffeine/cache/SSLMS.class", + emptyClass("art/arcane/iris/util/caffeine/cache/SSLMS")); + return entries; + } + + private static byte[] emptyClass(String internalName) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + writer.visitEnd(); + return writer.toByteArray(); + } + + private static byte[] classReferencing(String internalName, String referenced) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "use", "()V", null, null); + method.visitCode(); + method.visitTypeInsn(Opcodes.NEW, referenced); + method.visitInsn(Opcodes.POP); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(1, 0); + method.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + + private static byte[] classAnnotatedWith(String internalName, String annotation) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + writer.visitAnnotation('L' + annotation + ';', true).visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + + private File createArtifact(Map entries) throws Exception { + File artifact = temporaryFolder.newFile("Iris-bukkit-" + System.nanoTime() + ".jar"); + try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(artifact))) { + for (Map.Entry entry : entries.entrySet()) { + jar.putNextEntry(new JarEntry(entry.getKey())); + jar.write(entry.getValue()); + jar.closeEntry(); + } + } + return artifact; + } +} diff --git a/core/build.gradle b/core/build.gradle index beef4e835..8fc13222c 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -255,12 +255,59 @@ if (runningTestTasks) { } } +// VolmLib packages that Iris forked into art.arcane.iris.util.project.* or replaced outright. Every +// class in each of these packages is unreachable from every other class shipped in the Bukkit jar, +// and none of them is a reflective target: Matter.read() resolves only matter/slices, JarScanner +// only walks Iris' own packages, and the remaining VolmLib Class.forName calls name Bukkit/NMS or +// runtime-downloaded types. Package granularity is deliberate - a partially used package stays whole. +// verifyBukkitArtifact re-derives the reference graph over the shipped jar and fails on any dangling +// reference, so an exclude that stops being dead is a build failure rather than a runtime crash. +List supersededVolmLibPackages = [ + 'art/arcane/volmlib/util/noise/**', + 'art/arcane/volmlib/util/stream/ProceduralStream.class', + 'art/arcane/volmlib/util/stream/BasicStream.class', + 'art/arcane/volmlib/util/stream/arithmetic/**', + 'art/arcane/volmlib/util/stream/convert/**', + 'art/arcane/volmlib/util/stream/interpolation/**', + 'art/arcane/volmlib/util/stream/sources/**', + 'art/arcane/volmlib/util/stream/utility/**', + 'art/arcane/volmlib/util/uniques/**', + 'art/arcane/volmlib/util/bukkit/json/**', + 'art/arcane/volmlib/util/bukkit/registry/**', + 'art/arcane/volmlib/util/director/visual/**', + 'art/arcane/volmlib/util/value/**', + 'art/arcane/volmlib/util/api/**', + 'art/arcane/volmlib/util/entity/**' +] + +// Annotation-only artifacts pulled in transitively by Gson and Caffeine. Their types appear solely +// in annotation attributes, which the JVM skips silently when the type is absent, so nothing loads +// or links against them at runtime. +List annotationOnlyArtifacts = [ + 'com/google/errorprone/**', + 'org/jspecify/**' +] + +// Publisher metadata for the shaded dependencies. Nothing reads these at runtime. +List dependencyBuildMetadata = [ + 'META-INF/maven/**', + 'META-INF/proguard/**', + 'META-INF/versions/*/OSGI-INF/**' +] + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar).configure { dependsOn(embeddedAgentJar) mergeServiceFiles() - //minimize() + // minimize() stays off. Caffeine reaches its 520 generated cache and node classes through + // MethodHandles.Lookup.findClass on a computed name, and the VolmLib command, GUI and hotload + // surfaces are only referenced from :adapters:bukkit:plugin, which is merged into the artifact + // after this task runs. Both would be stripped as unreachable. Excluding them from minimization + // leaves it reaching ~25KB of Gson internals, which is not worth a mode that fails silently. relocate('io.github.slimjar', "${lib}.slimjar") exclude('modules/loader-agent.isolated-jar') + exclude(supersededVolmLibPackages) + exclude(annotationOnlyArtifacts) + exclude(dependencyBuildMetadata) from(embeddedAgentJar.map { it.archiveFile }) { rename { String ignored -> 'agent.jar' } }