Repository: GradleUp/shadow (kotlin)

Issue:
Fail build if multiple ZIP entries with the same path are added
### Description

This should be helpful for cases like

- #439 
- https://github.com/GradleUp/shadow/pull/1548#issuecomment-3135409789
- https://github.com/GrimAnticheat/Grim/pull/2229#issuecomment-3135033903

Pull request:
Closes #1551.

---

- [x] [CHANGELOG](https://github.com/GradleUp/shadow/blob/main/docs/changes/README.md)'s "Unreleased" section has been updated, if applicable.

Patch:
diff --git a/api/shadow.api b/api/shadow.api
index 2dce33b7a..379e15eb4 100644
--- a/api/shadow.api
+++ b/api/shadow.api
@@ -185,7 +185,7 @@ public final class com/github/jengelman/gradle/plugins/shadow/tasks/InheritManif
 
 public class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction : org/gradle/api/internal/file/copy/CopyAction {
 	public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction$Companion;
-	public fun <init> (Ljava/io/File;Lkotlin/jvm/functions/Function1;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;ZLjava/lang/String;)V
+	public fun <init> (Ljava/io/File;Lkotlin/jvm/functions/Function1;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;ZZLjava/lang/String;)V
 	public fun execute (Lorg/gradle/api/internal/file/copy/CopyActionProcessingStream;)Lorg/gradle/api/tasks/WorkResult;
 }
 
@@ -205,6 +205,7 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar
 	public fun getDependencyFilter ()Lorg/gradle/api/provider/Property;
 	public fun getEnableAutoRelocation ()Lorg/gradle/api/provider/Property;
 	public fun getExcludes ()Ljava/util/Set;
+	public fun getFailOnDuplicateEntries ()Lorg/gradle/api/provider/Property;
 	public fun getIncludedDependencies ()Lorg/gradle/api/file/ConfigurableFileCollection;
 	public fun getIncludes ()Ljava/util/Set;
 	public fun getManifest ()Lcom/github/jengelman/gradle/plugins/shadow/tasks/InheritManifest;
diff --git a/docs/changes/README.md b/docs/changes/README.md
index a489f29fc..fe7a56ebc 100644
--- a/docs/changes/README.md
+++ b/docs/changes/README.md
@@ -8,18 +8,26 @@
 - Add `PreserveFirstFoundResourceTransformer`. ([#1548](https://github.com/GradleUp/shadow/pull/1548))  
   This is useful when you set `shadowJar.duplicatesStrategy = DuplicatesStrategy.INCLUDE` (the default behavior) and
   want to ensure that only the first found resource is included in the final JAR.
+- Fail build if the ZIP entries in the shadowed JAR are duplicate. ([#1552](https://github.com/GradleUp/shadow/pull/1552))  
+  This feature is controlled by the `shadowJar.failOnDuplicateEntries` property, which is `false` by default.  
+  Related to setting `duplicatesStrategy = DuplicatesStrategy.FAIL` but there are some differences:
+    - It only checks the entries in the shadowed jar, not the input files.
+    - It works with setting `duplicatesStrategy` to any value.
+    - It provides a more strict check before the JAR is created.
 
 ### Changed
 
 - **BREAKING CHANGE:** Rename `ShadowJar`'s `enableRelocation` to `enableAutoRelocation`. ([#1541](https://github.com/GradleUp/shadow/pull/1541))  
   The Command Line options are also updated:
   ```
-  --enable-auto-relocation      Enables auto relocation of packages in the dependencies.
-  --no-enable-auto-relocation   Disables option --enable-auto-relocation.
-  --minimize-jar                Minimizes the jar by removing unused classes.
-  --no-minimize-jar             Disables option --minimize-jar.
-  --relocation-prefix           Prefix used for auto relocation of packages in the dependencies.
-  --rerun                       Causes the task to be re-run even if up-to-date.
+  --enable-auto-relocation          Enables auto relocation of packages in the dependencies.
+  --no-enable-auto-relocation       Disables option --enable-auto-relocation.
+  --fail-on-duplicate-entries       Fails build if the ZIP entries in the shadowed JAR are duplicate.
+  --no-fail-on-duplicate-entries    Disables option --fail-on-duplicate-entries.
+  --minimize-jar                    Minimizes the jar by removing unused classes.
+  --no-minimize-jar                 Disables option --minimize-jar.
+  --relocation-prefix               Prefix used for auto relocation of packages in the dependencies.
+  --rerun                           Causes the task to be re-run even if up-to-date.
   ```
 
 ## [9.0.0-rc2](https://github.com/GradleUp/shadow/releases/tag/9.0.0-rc2) - 2025-07-23
@@ -95,7 +103,7 @@
 - **BREAKING CHANGE:** Mark `RelocatorRemapper` as `internal`. ([#1227](https://github.com/GradleUp/shadow/pull/1227))
 - **BREAKING CHANGE:** Bump min Java requirement to 11. ([#1242](https://github.com/GradleUp/shadow/pull/1242))
 - **BREAKING CHANGE:** Move tracking unused classes logic out of `ShadowCopyAction`. ([#1257](https://github.com/GradleUp/shadow/pull/1257))
-- Reduce duplicated `SimpleRelocator` to improve performance. ([#1271](https://github.com/GradleUp/shadow/pull/1271))
+- Reduce duplicate `SimpleRelocator` to improve performance. ([#1271](https://github.com/GradleUp/shadow/pull/1271))
 - **BREAKING CHANGE:** Move `DependencyFilter` into `tasks` package. ([#1272](https://github.com/GradleUp/shadow/pull/1272))
 - **BREAKING CHANGE:** Change the default `duplicatesStrategy` from `EXCLUDE` to `INCLUDE`. ([#1233](https://github.com/GradleUp/shadow/pull/1233))
     - `ShadowJar` recognized `DuplicatesStrategy.EXCLUDE` as the default, but the other strategies didn't work properly.
@@ -282,7 +290,7 @@
 ### Changed
 
 - **BREAKING CHANGE:** Move tracking unused classes logic out of `ShadowCopyAction`. ([#1257](https://github.com/GradleUp/shadow/pull/1257))
-- Reduce duplicated `SimpleRelocator` to improve performance. ([#1271](https://github.com/GradleUp/shadow/pull/1271))
+- Reduce duplicate `SimpleRelocator` to improve performance. ([#1271](https://github.com/GradleUp/shadow/pull/1271))
 - **BREAKING CHANGE:** Move `DependencyFilter` into `tasks` package. ([#1272](https://github.com/GradleUp/shadow/pull/1272))
 - **BREAKING CHANGE:** Change the default `duplicatesStrategy` from `EXCLUDE` to `INCLUDE`. ([#1233](https://github.com/GradleUp/shadow/pull/1233))
     - `ShadowJar` recognized `DuplicatesStrategy.EXCLUDE` as the default, but the other strategies didn't work properly.
diff --git a/docs/configuration/merging/README.md b/docs/configuration/merging/README.md
index 11105853d..c79c20cfe 100644
--- a/docs/configuration/merging/README.md
+++ b/docs/configuration/merging/README.md
@@ -396,7 +396,7 @@ override it like:
 Different strategies will lead to different results for `foo/bar` files in the JARs to be merged:
 
 - `EXCLUDE`: The **first** `foo/bar` file will be included in the final JAR.
-- `FAIL`: **Fail** the build with a `DuplicateFileCopyingException` if there are duplicated `foo/bar` files.
+- `FAIL`: **Fail** the build with a `DuplicateFileCopyingException` if there are duplicate `foo/bar` files.
 - `INCLUDE`: The **last** `foo/bar` file will be included in the final JAR (the default behavior).
 - `INHERIT`: **Fail** the build with an exception like
   `Entry .* is a duplicate but no duplicate handling strategy has been set`.
@@ -424,11 +424,11 @@ Different strategies will lead to different results for `foo/bar` files in the J
     ```
 
 The [`ServiceFileTransformer`][ServiceFileTransformer] will not work as expected because the `duplicatesStrategy` will
-exclude the duplicated service files beforehand. However, this behavior might be what you expected for duplicated
+exclude the duplicate service files beforehand. However, this behavior might be what you expected for duplicate
 `foo/bar` files, preventing them from being included.
 Want `ResourceTransformer`s and `duplicatesStrategy` to work together? There is a way to achieve this, leave the
 `duplicatesStrategy` as `INCLUDE` and declare a custom [`ResourceTransformer`][ResourceTransformer] to handle the
-duplicated files.
+duplicate files.
 If you just want to keep the current behavior and preserve the first found resource, there is a simple built-in one to
 handle this called [`PreserveFirstFoundResourceTransformer`][PreserveFirstFoundResourceTransformer].
 
diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md
index b03d72c83..9048a4bf1 100644
--- a/docs/getting-started/README.md
+++ b/docs/getting-started/README.md
@@ -148,12 +148,14 @@ build script. Passing property values on the command line is particularly helpfu
 Here are the options that can be passed to the `shadowJar`:
 
 ```
---enable-auto-relocation      Enables auto relocation of packages in the dependencies.
---no-enable-auto-relocation   Disables option --enable-auto-relocation.
---minimize-jar                Minimizes the jar by removing unused classes.
---no-minimize-jar             Disables option --minimize-jar.
---relocation-prefix           Prefix used for auto relocation of packages in the dependencies.
---rerun                       Causes the task to be re-run even if up-to-date.
+--enable-auto-relocation          Enables auto relocation of packages in the dependencies.
+--no-enable-auto-relocation       Disables option --enable-auto-relocation.
+--fail-on-duplicate-entries       Fails build if the ZIP entries in the shadowed JAR are duplicate.
+--no-fail-on-duplicate-entries    Disables option --fail-on-duplicate-entries.
+--minimize-jar                    Minimizes the jar by removing unused classes.
+--no-minimize-jar                 Disables option --minimize-jar.
+--relocation-prefix               Prefix used for auto relocation of packages in the dependencies.
+--rerun                           Causes the task to be re-run even if up-to-date.
 ```
 
 Also, you can view more information about the [`ShadowJar`][ShadowJar] task by running the following command:
diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt
index c498e9e87..90b022bd2 100644
--- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt
+++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt
@@ -38,6 +38,7 @@ public open class ShadowCopyAction(
   private val relocators: Set<Relocator>,
   private val unusedClasses: Set<String>,
   private val preserveFileTimestamps: Boolean,
+  private val failOnDuplicateEntries: Boolean,
   private val encoding: String?,
 ) : CopyAction {
   private val visitedDirs = mutableMapOf<String, FileCopyDetails>()
@@ -53,8 +54,8 @@ public open class ShadowCopyAction(
       zipOutStream.use { zos ->
         stream.process(StreamAction(zos))
         processTransformers(zos)
-        // This must be called as the last step to ensure that directories are added after all files.
-        addDirs(zos)
+        addDirs(zos) // This must be called after adding all file entries to avoid duplicate directories being added.
+        checkDuplicateEntries(zos)
       }
     } catch (e: Exception) {
       if (e is Zip64RequiredException || e.cause is Zip64RequiredException) {
@@ -89,8 +90,7 @@ public open class ShadowCopyAction(
 
   private fun addDirs(zos: ZipOutputStream) {
     @Suppress("UNCHECKED_CAST")
-    val entries = zos::class.java.getDeclaredField("entries").apply { isAccessible = true }
-      .get(zos).cast<List<ZipEntry>>().map { it.name }
+    val entries = zos.entries.map { it.name }
     val added = entries.toMutableSet()
     val currentTimeMillis = System.currentTimeMillis()
 
@@ -118,6 +118,22 @@ public open class ShadowCopyAction(
     }
   }
 
+  private fun checkDuplicateEntries(zos: ZipOutputStream) {
+    val entries = zos.entries.map { it.name }
+    val duplicates = entries.groupingBy { it }.eachCount().filter { it.value > 1 }
+    if (duplicates.isNotEmpty()) {
+      val dupEntries = duplicates.entries.joinToString(separator = "\n") {
+        "${it.key} (${it.value} times)"
+      }
+      val message = "Duplicate entries found in the shadowed JAR: \n$dupEntries"
+      if (failOnDuplicateEntries) {
+        throw GradleException(message)
+      } else {
+        logger.info(message)
+      }
+    }
+  }
+
   private inner class StreamAction(
     private val zipOutStr: ZipOutputStream,
   ) : CopyActionProcessingStreamAction {
@@ -237,5 +253,11 @@ public open class ShadowCopyAction(
      * 1980-02-01 00:00:00 (318182400000).
      */
     public val CONSTANT_TIME_FOR_ZIP_ENTRIES: Long = GregorianCalendar(1980, 1, 1, 0, 0, 0).timeInMillis
+
+    private val ZipOutputStream.entries: List<ZipEntry>
+      get() {
+        return this::class.java.getDeclaredField("entries").apply { isAccessible = true }
+          .get(this).cast<List<ZipEntry>>()
+      }
   }
 }
diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt
index bc09cc56a..2d94a175d 100644
--- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt
+++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt
@@ -156,6 +156,20 @@ public abstract class ShadowJar :
   @get:Option(option = "relocation-prefix", description = "Prefix used for auto relocation of packages in the dependencies.")
   public open val relocationPrefix: Property<String> = objectFactory.property(ShadowBasePlugin.SHADOW)
 
+  /**
+   * Fails build if the ZIP entries in the shadowed JAR are duplicate.
+   *
+   * This is related to setting [duplicatesStrategy] to [DuplicatesStrategy.FAIL] but there are some differences:
+   * - It only checks the entries in the shadowed jar, not the input files.
+   * - It works with setting [duplicatesStrategy] to any value.
+   * - It provides a more strict check before the JAR is created.
+   *
+   * Defaults to `false`.
+   */
+  @get:Input
+  @get:Option(option = "fail-on-duplicate-entries", description = "Fails build if the ZIP entries in the shadowed JAR are duplicate.")
+  public open val failOnDuplicateEntries: Property<Boolean> = objectFactory.property(false)
+
   @Internal
   override fun getManifest(): InheritManifest = super.getManifest() as InheritManifest
 
@@ -362,12 +376,13 @@ public abstract class ShadowJar :
       emptySet()
     }
     return ShadowCopyAction(
-      archiveFile.get().asFile,
-      zosProvider,
-      transformers.get(),
-      relocators.get() + packageRelocators,
-      unusedClasses,
-      isPreserveFileTimestamps,
+      zipFile = archiveFile.get().asFile,
+      zosProvider = zosProvider,
+      transformers = transformers.get(),
+      relocators = relocators.get() + packageRelocators,
+      unusedClasses = unusedClasses,
+      preserveFileTimestamps = isPreserveFileTimestamps,
+      failOnDuplicateEntries = failOnDuplicateEntries.get(),
       metadataCharset,
     )
   }


Test patch:
diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginTest.kt
index d9b2b8a71..195b3cd2d 100644
--- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginTest.kt
+++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginTest.kt
@@ -95,6 +95,7 @@ class JavaPluginTest : BasePluginTest() {
         isNotEmpty()
         containsOnly(project.runtimeConfiguration)
       }
+      assertThat(failOnDuplicateEntries.get()).isFalse()
     }
 
     assertThat(shadowConfig.artifacts.files).contains(shadowTask.archiveFile.get().asFile)
@@ -107,6 +108,8 @@ class JavaPluginTest : BasePluginTest() {
     assertThat(result.output).contains(
       "--enable-auto-relocation     Enables auto relocation of packages in the dependencies.",
       "--no-enable-auto-relocation     Disables option --enable-auto-relocation.",
+      "--fail-on-duplicate-entries     Fails build if the ZIP entries in the shadowed JAR are duplicate.",
+      "--no-fail-on-duplicate-entries     Disables option --fail-on-duplicate-entries",
       "--minimize-jar     Minimizes the jar by removing unused classes.",
       "--no-minimize-jar     Disables option --minimize-jar.",
       "--relocation-prefix     Prefix used for auto relocation of packages in the dependencies.",
@@ -828,6 +831,57 @@ class JavaPluginTest : BasePluginTest() {
     }
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = [false, true])
+  fun failBuildIfDuplicateEntries(enable: Boolean) {
+    path("src/main/resources/a.properties").writeText("invalid a")
+    projectScriptPath.appendText(
+      """
+        dependencies {
+          ${implementationFiles(artifactAJar)}
+        }
+        $shadowJar {
+          failOnDuplicateEntries = $enable
+        }
+      """.trimIndent(),
+    )
+
+    val result = if (enable) {
+      runWithFailure(shadowJarTask)
+    } else {
+      run(shadowJarTask, "--info")
+    }
+
+    assertThat(result.output).contains(
+      "Duplicate entries found in the shadowed JAR:",
+      "a.properties (2 times)",
+    )
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = [false, true])
+  fun failBuildIfDuplicateEntriesByCliOption(enable: Boolean) {
+    path("src/main/resources/a.properties").writeText("invalid a")
+    projectScriptPath.appendText(
+      """
+        dependencies {
+          ${implementationFiles(artifactAJar)}
+        }
+      """.trimIndent(),
+    )
+
+    val result = if (enable) {
+      runWithFailure(shadowJarTask, "--fail-on-duplicate-entries")
+    } else {
+      run(shadowJarTask, "--info")
+    }
+
+    assertThat(result.output).contains(
+      "Duplicate entries found in the shadowed JAR:",
+      "a.properties (2 times)",
+    )
+  }
+
   private fun dependencies(configuration: String, vararg flags: String): String {
     return run("dependencies", "--configuration", configuration, *flags).output
   }
diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt
index 8a14c08be..2fa92e2f6 100644
--- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt
+++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt
@@ -544,7 +544,7 @@ class RelocationTest : BasePluginTest() {
 
   @ParameterizedTest
   @MethodSource("relocationCliOptionProvider")
-  fun enableAutoRelocationByCliOption(enableAutoRelocation: Boolean, relocationPrefix: String) {
+  fun enableAutoRelocationByCliOption(enable: Boolean, relocationPrefix: String) {
     val mainClassEntry = writeClass()
     projectScriptPath.appendText(
       """
@@ -557,14 +557,14 @@ class RelocationTest : BasePluginTest() {
       .filterNot { it.startsWith("$relocationPrefix/META-INF/") }
       .toTypedArray()
 
-    if (enableAutoRelocation) {
+    if (enable) {
       run(shadowJarTask, "--enable-auto-relocation", "--relocation-prefix=$relocationPrefix")
     } else {
       run(shadowJarTask, "--relocation-prefix=$relocationPrefix")
     }
 
     assertThat(outputShadowJar).useAll {
-      if (enableAutoRelocation) {
+      if (enable) {
         containsOnly(
           "my/",
           "$relocationPrefix/",