diff --git a/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java b/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java index f6bd145a..7b3d03a5 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/ModDevPlugin.java @@ -894,6 +894,8 @@ private static void configureEclipseModel(Project project, return; } + eclipseModel.getJdt().setJavaRuntimeName(ECLIPSE_DEFAULT_JRE); + // Make sure our post-sync task runs on Eclipse eclipseModel.synchronizationTasks(ideSyncTask); diff --git a/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java b/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java index 0ec89de4..5340c967 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java @@ -1,17 +1,27 @@ package net.neoforged.moddevgradle.internal.utils; +import org.gradle.api.GradleException; import org.jetbrains.annotations.ApiStatus; +import java.io.File; +import java.io.FileInputStream; import java.io.FilterOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.lang.module.ModuleDescriptor; import java.nio.charset.Charset; import java.nio.file.AccessDeniedException; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.util.HexFormat; import java.util.List; +import java.util.Optional; +import java.util.jar.JarFile; +import java.util.zip.ZipFile; @ApiStatus.Internal public final class FileUtils { @@ -23,6 +33,47 @@ public final class FileUtils { private FileUtils() { } + /** + * Finds an explicitly defined Java module name in the given Jar file. + */ + public static Optional getExplicitJavaModuleName(File file) throws IOException { + try (var jf = new JarFile(file, false, ZipFile.OPEN_READ, JarFile.runtimeVersion())) { + var moduleInfoEntry = jf.getJarEntry("module-info.class"); + if (moduleInfoEntry != null) { + try (var in = jf.getInputStream(moduleInfoEntry)) { + return Optional.of(ModuleDescriptor.read(in).name()); + } + } + + var manifest = jf.getManifest(); + if (manifest == null) { + return Optional.empty(); + } + + var automaticModuleName = manifest.getMainAttributes().getValue("Automatic-Module-Name"); + if (automaticModuleName == null) { + return Optional.empty(); + } + + return Optional.of(automaticModuleName); + } catch (Exception e) { + throw new IOException("Failed to determine the Java module name of " + file + ": " + e, e); + } + + } + + public static String hashFile(File file, String algorithm) { + try { + MessageDigest digest = MessageDigest.getInstance(algorithm); + try (var input = new DigestInputStream(new FileInputStream(file), digest)) { + input.transferTo(OutputStream.nullOutputStream()); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (Exception e) { + throw new GradleException("Failed to hash file " + file, e); + } + } + public static void writeStringSafe(Path destination, String content, Charset charset) throws IOException { if (!charset.newEncoder().canEncode(content)) { throw new IllegalArgumentException("The given character set " + charset diff --git a/src/main/java/net/neoforged/moddevgradle/tasks/JarJar.java b/src/main/java/net/neoforged/moddevgradle/tasks/JarJar.java index ee0c91da..56b48f79 100644 --- a/src/main/java/net/neoforged/moddevgradle/tasks/JarJar.java +++ b/src/main/java/net/neoforged/moddevgradle/tasks/JarJar.java @@ -4,6 +4,7 @@ import net.neoforged.jarjar.metadata.MetadataIOHandler; import net.neoforged.moddevgradle.internal.jarjar.JarJarArtifacts; import net.neoforged.moddevgradle.internal.jarjar.ResolvedJarJarArtifact; +import net.neoforged.moddevgradle.internal.utils.FileUtils; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.artifacts.Configuration; @@ -20,20 +21,13 @@ import org.jetbrains.annotations.ApiStatus; import javax.inject.Inject; -import java.io.File; -import java.io.FileInputStream; import java.io.IOException; -import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.security.DigestInputStream; -import java.security.MessageDigest; import java.util.ArrayList; import java.util.Collection; -import java.util.HexFormat; import java.util.List; -import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -70,30 +64,33 @@ public JarJar(FileSystemOperations fileSystemOperations) { } @TaskAction - protected void run() { + protected void run() throws IOException { List includedJars = new ArrayList<>(getJarJarArtifacts().getResolvedArtifacts().get()); fileSystemOperations.delete(spec -> spec.delete(getOutputDirectory())); - var buildDir = getBuildDirectory().getAsFile().get().toPath(); - var artifactFiles = new ArrayList<>(includedJars.stream().map(ResolvedJarJarArtifact::getFile).toList()); // Now we have to handle pure file collection dependencies that do not have artifact ids for (var file : getInputFiles()) { if (!artifactFiles.contains(file)) { - // This is only intended for libraries built by this project, so this is a shoddy check - if (!file.toPath().startsWith(buildDir)) { - throw new GradleException("Cannot embed file dependencies in the jar if they aren't in this projects build directory."); + // Determine the module-name of the file, which is also what Java will use as the unique key + // when it tries to load the file. No two files can have the same module name, so it seems + // like a fitting key for conflict resolution by JiJ. + var moduleName = FileUtils.getExplicitJavaModuleName(file); + if (moduleName.isEmpty()) { + throw new GradleException("Cannot embed local file dependency " + file + " because it has no explicit Java module name.\n" + + "Please set either 'Automatic-Module-Name' in the Jar manifest, or make it an explicit Java module.\n" + + "This ensures that your file does not conflict with another mods library that has the same or a similar filename."); } // Create a hashcode to use as a version - var hashCode = hashFile(file); + var hashCode = FileUtils.hashFile(file, "MD5"); includedJars.add(new ResolvedJarJarArtifact( file, file.getName(), hashCode, "[" + hashCode + "]", "", - file.getName().toLowerCase(Locale.ROOT) + moduleName.get() )); artifactFiles.add(file); } @@ -105,6 +102,16 @@ protected void run() { spec.into(getOutputDirectory().dir("META-INF/jarjar")); spec.from(artifactFiles.toArray()); for (var includedJar : includedJars) { + // Warn if any included jar is using the cursemaven group. + // We know that cursemaven versions are not comparable, and the same artifact might also be + // available under a "normal" group and artifact from another Maven repository. + // JIJ will not correctly detect the conflicting file at runtime if another mod uses the normal Maven dependency. + // For a description of Curse Maven, see https://www.cursemaven.com/ + if ("curse.maven".equals(includedJar.getGroup())) { + getLogger().warn("Embedding dependency {}:{}:{} from cursemaven using JiJ is likely to cause conflicts at runtime when other mods include the same library from a normal Maven repository.", + includedJar.getGroup(), includedJar.getArtifact(), includedJar.getVersion()); + } + var originalName = includedJar.getFile().getName(); var embeddedName = includedJar.getEmbeddedFilename(); if (!originalName.equals(embeddedName)) { @@ -116,22 +123,10 @@ protected void run() { } } - private static String hashFile(File file) { - try { - MessageDigest digest = MessageDigest.getInstance("MD5"); - try (var input = new DigestInputStream(new FileInputStream(file), digest)) { - input.transferTo(OutputStream.nullOutputStream()); - } - return HexFormat.of().formatHex(digest.digest()); - } catch (Exception e) { - throw new GradleException("Failed to hash file " + file, e); - } - } - @SuppressWarnings("ResultOfMethodCallIgnored") private Path writeMetadata(List includedJars) { - final Path metadataPath = getJarJarMetadataPath(); - final Metadata metadata = createMetadata(includedJars); + var metadataPath = getJarJarMetadataPath(); + var metadata = createMetadata(includedJars); try { metadataPath.toFile().getParentFile().mkdirs(); diff --git a/src/test/java/net/neoforged/moddevgradle/tasks/JarJarTest.java b/src/test/java/net/neoforged/moddevgradle/tasks/JarJarTest.java index 14ef408c..ccf486db 100644 --- a/src/test/java/net/neoforged/moddevgradle/tasks/JarJarTest.java +++ b/src/test/java/net/neoforged/moddevgradle/tasks/JarJarTest.java @@ -5,6 +5,7 @@ import net.neoforged.jarjar.metadata.ContainedVersion; import net.neoforged.jarjar.metadata.Metadata; import net.neoforged.jarjar.metadata.MetadataIOHandler; +import net.neoforged.moddevgradle.internal.utils.FileUtils; import org.apache.maven.artifact.versioning.DefaultArtifactVersion; import org.apache.maven.artifact.versioning.VersionRange; import org.gradle.testkit.runner.BuildResult; @@ -17,6 +18,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import static org.assertj.core.api.Assertions.assertThat; import static org.gradle.testkit.runner.TaskOutcome.NO_SOURCE; @@ -34,6 +37,103 @@ public void testNoSourceWhenNoDependenciesAreDefined() throws IOException { assertEquals(NO_SOURCE, result.task(":jarJar").getOutcome()); } + @Test + void testEmbeddingCurseMavenDependencyProducesWarning() throws IOException { + var result = runWithSource(""" + repositories { + maven { + url "https://www.cursemaven.com" + content { + includeGroup "curse.maven" + } + } + } + dependencies { + jarJar(implementation("curse.maven:jade-324717:5444008")) + } + """); + assertEquals(SUCCESS, result.task(":jarJar").getOutcome()); + assertThat(result.getOutput()).contains("Embedding dependency curse.maven:jade-324717:5444008 from cursemaven using JiJ is likely to cause conflicts at runtime when other mods include the same library from a normal Maven repository."); + } + + @Test + void testCannotEmbedLocalFileWithoutExplicitJavaModuleName() throws IOException { + var localFile = tempDir.resolve("file.jar"); + new JarOutputStream(Files.newOutputStream(localFile), new Manifest()).close(); + + var e = assertThrows(UnexpectedBuildFailure.class, () -> runWithSource(""" + dependencies { + jarJar(files("file.jar")) + } + """)); + assertThat(e).hasMessageFindingMatch("Cannot embed local file dependency .*file.jar because it has no explicit Java module name.\\s*" + + "Please set either 'Automatic-Module-Name' in the Jar manifest, or make it an explicit Java module.\\s*" + + "This ensures that your file does not conflict with another mods library that has the same or a similar filename."); + } + + @Test + void testCanEmbedLocalFileWithAutomaticModuleName() throws Exception { + var localFile = tempDir.resolve("file.jar"); + var manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + manifest.getMainAttributes().putValue("Automatic-Module-Name", "super_duper_module"); + new JarOutputStream(Files.newOutputStream(localFile), manifest).close(); + var md5Hash = FileUtils.hashFile(localFile.toFile(), "MD5"); + + var result = runWithSource(""" + dependencies { + jarJar(files("file.jar")) + } + """); + assertEquals(SUCCESS, result.task(":jarJar").getOutcome()); + assertEquals(new Metadata( + List.of( + new ContainedJarMetadata( + new ContainedJarIdentifier("", "super_duper_module"), + new ContainedVersion(VersionRange.createFromVersionSpec("[" + md5Hash + "]"), new DefaultArtifactVersion(md5Hash)), + "META-INF/jarjar/file.jar", + false + ) + ) + ), readMetadata()); + } + + @Test + void testCanEmbedLocalFileWithModuleInfo() throws Exception { + var moduleInfoJava = tempDir.resolve("src/service/java/module-info.java"); + Files.createDirectories(moduleInfoJava.getParent()); + Files.writeString(moduleInfoJava, "module super_duper_module {}"); + + var result = runWithSource(""" + sourceSets { + service + } + compileServiceJava { + // otherwise testkit needs to run with J21 + options.release = 9 + } + var serviceJar = tasks.register(sourceSets.service.jarTaskName, Jar) { + from sourceSets.service.output + archiveClassifier = "service" + } + dependencies { + jarJar(files(serviceJar)) + } + """); + assertEquals(SUCCESS, result.task(":jarJar").getOutcome()); + var md5Hash = FileUtils.hashFile(moduleInfoJava.toFile(), "MD5"); + assertEquals(new Metadata( + List.of( + new ContainedJarMetadata( + new ContainedJarIdentifier("", "super_duper_module"), + new ContainedVersion(VersionRange.createFromVersionSpec("[" + md5Hash + "]"), new DefaultArtifactVersion(md5Hash)), + "META-INF/jarjar/file.jar", + false + ) + ) + ), readMetadata()); + } + @Test public void testSuccessfulEmbed() throws Exception { var result = runWithSource(""" @@ -176,7 +276,12 @@ public void testUnsupportedDynamicVersion() { } private BuildResult runWithSource(String source) throws IOException { - Files.writeString(tempDir.resolve("settings.gradle"), ""); + Files.writeString(tempDir.resolve("settings.gradle"), """ + plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" + } + rootProject.name = 'jijtest' + """); Files.writeString(tempDir.resolve("build.gradle"), """ plugins { id "net.neoforged.moddev" @@ -189,7 +294,7 @@ private BuildResult runWithSource(String source) throws IOException { return GradleRunner.create() .withPluginClasspath() .withProjectDir(tempDir.toFile()) - .withArguments("jarjar") + .withArguments("jarjar", "--stacktrace") .withDebug(true) .build(); } diff --git a/testproject/build.gradle b/testproject/build.gradle index 4109026f..e8a4965a 100644 --- a/testproject/build.gradle +++ b/testproject/build.gradle @@ -11,7 +11,8 @@ sourceSets { } dependencies { - testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testImplementation(enforcedPlatform("org.junit:junit-bom:5.10.2")) + testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation "net.neoforged:testframework:${project.neoforge_version}"