diff --git a/api/shadow.api b/api/shadow.api index 46f49e233..5ae620f11 100644 --- a/api/shadow.api +++ b/api/shadow.api @@ -460,6 +460,15 @@ public class com/github/jengelman/gradle/plugins/shadow/transformers/PreserveFir public fun getResources ()Lorg/gradle/api/provider/SetProperty; } +public class com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformer : com/github/jengelman/gradle/plugins/shadow/transformers/PatternFilterableResourceTransformer { + public fun ()V + public fun (Lorg/gradle/api/tasks/util/PatternSet;)V + public synthetic fun (Lorg/gradle/api/tasks/util/PatternSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun hasTransformedResource ()Z + public fun modifyOutputStream (Lorg/apache/tools/zip/ZipOutputStream;Z)V + public fun transform (Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext;)V +} + public class com/github/jengelman/gradle/plugins/shadow/transformers/PropertiesFileTransformer : com/github/jengelman/gradle/plugins/shadow/transformers/ResourceTransformer { public fun (Lorg/gradle/api/model/ObjectFactory;)V public fun canTransformResource (Lorg/gradle/api/file/FileTreeElement;)Z diff --git a/docs/changes/README.md b/docs/changes/README.md index 8c9b00ed1..0bdf934c0 100644 --- a/docs/changes/README.md +++ b/docs/changes/README.md @@ -3,6 +3,9 @@ ## [Unreleased](https://github.com/GradleUp/shadow/compare/9.4.1...HEAD) - 2026-xx-xx +### Added + +- Add `ProGuardTransformer` to merge `META-INF/proguard/*.pro` files. ([#1997](https://github.com/GradleUp/shadow/pull/1997)) ## [9.4.1](https://github.com/GradleUp/shadow/releases/tag/9.4.1) - 2026-03-27 diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformerTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformerTest.kt new file mode 100644 index 000000000..a87649b73 --- /dev/null +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformerTest.kt @@ -0,0 +1,52 @@ +package com.github.jengelman.gradle.plugins.shadow.transformers + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.github.jengelman.gradle.plugins.shadow.testkit.getContent +import kotlin.io.path.appendText +import org.junit.jupiter.api.Test + +class ProGuardTransformerTest : BaseTransformerTest() { + @Test + fun mergeProGuardFiles() { + val proGuardEntry = "META-INF/proguard/app.pro" + val content1 = "-keep class com.foo.Bar { *; }" + val content2 = "-keep class com.foo.Baz { *; }" + val one = buildJarOne { insert(proGuardEntry, content1) } + val two = buildJarTwo { insert(proGuardEntry, content2) } + val config = transform(dependenciesBlock = implementationFiles(one, two)) + projectScript.appendText(config) + + runWithSuccess(shadowJarPath) + + val content = outputShadowedJar.use { it.getContent(proGuardEntry) } + assertThat(content).isEqualTo("$content1\n$content2") + } + + @Test + fun relocateProGuardFiles() { + val proGuardEntry = "META-INF/proguard/app.pro" + val content = "-keep class org.foo.Service { *; }\n-keep class org.foo.exclude.OtherService" + val one = buildJarOne { insert(proGuardEntry, content) } + val config = + """ + dependencies { + ${implementationFiles(one)} + } + $shadowJarTask { + relocate('org.foo', 'borg.foo') { + exclude 'org.foo.exclude.*' + } + transform(com.github.jengelman.gradle.plugins.shadow.transformers.ProGuardTransformer) + } + """ + .trimIndent() + projectScript.appendText(config) + + runWithSuccess(shadowJarPath) + + val transformedContent = outputShadowedJar.use { it.getContent(proGuardEntry) } + assertThat(transformedContent) + .isEqualTo("-keep class borg.foo.Service { *; }\n-keep class org.foo.exclude.OtherService") + } +} diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/TransformersTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/TransformersTest.kt index e85c711e5..d428dfbf5 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/TransformersTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/TransformersTest.kt @@ -338,6 +338,22 @@ class TransformersTest : BaseTransformerTest() { } } + @Test + fun mergeProGuardFiles() { + val proGuardEntry = "META-INF/proguard/app.pro" + val content1 = "-keep class com.foo.Bar { *; }" + val content2 = "-keep class com.foo.Baz { *; }" + val one = buildJarOne { insert(proGuardEntry, content1) } + val two = buildJarTwo { insert(proGuardEntry, content2) } + val config = transform(dependenciesBlock = implementationFiles(one, two)) + projectScript.appendText(config) + + runWithSuccess(shadowJarPath) + + val content = outputShadowedJar.use { it.getContent(proGuardEntry) } + assertThat(content).isEqualTo("$content1\n$content2") + } + @ParameterizedTest @MethodSource("transformerConfigProvider") fun otherTransformers(pair: Pair>) { diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt index 9d624a918..20a752fec 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt @@ -68,31 +68,40 @@ constructor( this.excludes.addAll(excludes) } - if (!rawString) { - // Create exclude pattern sets for sources. - for (exclude in this.excludes) { - // Excludes should be subpackages of the global pattern. - if (exclude.startsWith(this.pattern)) { - sourcePackageExcludes.add( - exclude.substring(this.pattern.length).replaceFirst("[.][*]$".toRegex(), "") - ) - } - // Excludes should be subpackages of the global pattern. - if (exclude.startsWith(pathPattern)) { - sourcePathExcludes.add( - exclude.substring(pathPattern.length).replaceFirst("/[*]$".toRegex(), "") - ) - } + updateSourceExcludes() + } + + private fun updateSourceExcludes() { + if (rawString) return + sourcePackageExcludes.clear() + sourcePathExcludes.clear() + // Create exclude pattern sets for sources. + for (exclude in this.excludes) { + // Excludes should be subpackages of the global pattern. + if (exclude.startsWith(this.pattern)) { + sourcePackageExcludes.add( + exclude.substring(this.pattern.length).replaceFirst("[.][*]$".toRegex(), "") + ) + } + // Excludes should be subpackages of the global pattern. + if (exclude.startsWith(pathPattern)) { + sourcePathExcludes.add( + exclude.substring(pathPattern.length).replaceFirst("/[*]$".toRegex(), "") + ) } } } public open fun include(pattern: String) { includes.addAll(normalizePatterns(listOf(pattern))) + includes.add(pattern) + updateSourceExcludes() } public open fun exclude(pattern: String) { excludes.addAll(normalizePatterns(listOf(pattern))) + excludes.add(pattern) + updateSourceExcludes() } override fun canRelocatePath(path: String): Boolean { @@ -206,7 +215,7 @@ constructor( */ val RX_ENDS_WITH_JAVA_KEYWORD: Pattern = Pattern.compile( - "\\b(import|package|public|protected|private|static|final|synchronized|abstract|volatile|extends|implements|throws) $" + + "\\b(import|package|class|interface|enum|public|protected|private|static|final|synchronized|abstract|volatile|extends|implements|throws|-keep) $" + "|" + "\\{@link( \\*)* $" + "|" + @@ -255,9 +264,7 @@ constructor( // Make sure that search pattern starts at word boundary and that we look for literal ".", not // regex jokers. val snippets = - sourceContent - .split(("\\b" + patternFrom.replace(".", "[.]") + "\\b").toRegex()) - .filter(CharSequence::isNotEmpty) + sourceContent.split(("\\b" + patternFrom.replace(".", "[.]") + "\\b").toRegex()) snippets.forEachIndexed { i, snippet -> val isFirstSnippet = i == 0 val previousSnippet = if (isFirstSnippet) "" else snippets[i - 1] diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformer.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformer.kt new file mode 100644 index 000000000..1a80101ef --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformer.kt @@ -0,0 +1,49 @@ +package com.github.jengelman.gradle.plugins.shadow.transformers + +import com.github.jengelman.gradle.plugins.shadow.internal.zipEntry +import org.apache.tools.zip.ZipOutputStream +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.util.PatternSet + +/** + * Resources transformer that merges entries in `META-INF/proguard` resources into a single + * resource. For example, if there are several `META-INF/proguard/app.pro` resources spread across + * many JARs the individual entries will all be concatenated into a single + * `META-INF/proguard/app.pro` resource packaged into the resultant JAR produced by the shading + * process. + */ +@CacheableTransformer +public open class ProGuardTransformer +@JvmOverloads +constructor(patternSet: PatternSet = PatternSet().include(PROGUARD_PATTERN)) : + PatternFilterableResourceTransformer(patternSet = patternSet) { + @get:Internal internal val proGuardEntries = mutableMapOf>() + + override fun transform(context: TransformerContext) { + val lines = proGuardEntries.getOrPut(context.path) { mutableListOf() } + context.inputStream + .bufferedReader() + .use { it.readLines() } + .forEach { line -> + var relocatedLine = line + context.relocators.forEach { relocator -> + relocatedLine = relocator.applyToSourceContent(relocatedLine) + } + lines.add(relocatedLine) + } + } + + override fun hasTransformedResource(): Boolean = proGuardEntries.isNotEmpty() + + override fun modifyOutputStream(os: ZipOutputStream, preserveFileTimestamps: Boolean) { + proGuardEntries.forEach { (path, lines) -> + os.putNextEntry(zipEntry(path, preserveFileTimestamps)) + os.write(lines.joinToString(System.lineSeparator()).toByteArray()) + os.closeEntry() + } + } + + private companion object { + private const val PROGUARD_PATTERN = "META-INF/proguard/**" + } +} diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt index 6141b1d7e..0d98281ae 100644 --- a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt @@ -344,6 +344,12 @@ class SimpleRelocatorTest { assertThat(relocator.canRelocatePath("org/foo/Class.class")).isFalse() } + @Test + fun relocateSourceAtStart() { + val relocator = SimpleRelocator("org.foo", "borg.foo") + assertThat(relocator.applyToSourceContent("org.foo.Bar")).isEqualTo("borg.foo.Bar") + } + @Test fun relocateSourceWithExcludesRaw() { val relocator = diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformerTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformerTest.kt new file mode 100644 index 000000000..a7214f49b --- /dev/null +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ProGuardTransformerTest.kt @@ -0,0 +1,144 @@ +package com.github.jengelman.gradle.plugins.shadow.transformers + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import com.github.jengelman.gradle.plugins.shadow.relocation.SimpleRelocator +import com.github.jengelman.gradle.plugins.shadow.testkit.JarPath +import com.github.jengelman.gradle.plugins.shadow.testkit.getContent +import com.github.jengelman.gradle.plugins.shadow.util.zipOutputStream +import java.nio.file.Path +import kotlin.io.path.createTempFile +import kotlin.io.path.deleteExisting +import kotlin.io.path.outputStream +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class ProGuardTransformerTest : BaseTransformerTest() { + private lateinit var tempJar: Path + + @BeforeEach + override fun beforeEach() { + super.beforeEach() + tempJar = createTempFile("shade.", ".jar") + } + + @AfterEach + fun afterEach() { + tempJar.deleteExisting() + } + + @ParameterizedTest + @MethodSource("resourceProvider") + fun canTransformResource(path: String, expected: Boolean) { + assertThat(transformer.canTransformResource(path)).isEqualTo(expected) + } + + @ParameterizedTest + @MethodSource("proGuardFileProvider") + fun transformProGuardFile(path: String, input1: String, input2: String, output: String) { + if (transformer.canTransformResource(path)) { + transformer.transform(textContext(path, input1)) + transformer.transform(textContext(path, input2)) + } + + assertThat(transformer.hasTransformedResource()).isTrue() + val entry = transformer.proGuardEntries.getValue(path).joinToString("\n") + assertThat(entry).isEqualTo(output) + } + + @Test + fun mergesMultipleFiles() { + val path = "META-INF/proguard/app.pro" + val content1 = "-keep class com.foo.Bar { *; }" + val content2 = "-keep class com.foo.Baz { *; }" + + transformer.transform(textContext(path, content1)) + transformer.transform(textContext(path, content2)) + + tempJar.outputStream().zipOutputStream().use { zos -> + transformer.modifyOutputStream(zos, false) + } + + val transformedContent = JarPath(tempJar).use { it.getContent(path) } + assertThat(transformedContent) + .isEqualTo("-keep class com.foo.Bar { *; }\n-keep class com.foo.Baz { *; }") + } + + @Test + fun canTransformAlternatePath() { + transformer.include("META-INF/custom/**") + assertThat(transformer.canTransformResource("META-INF/proguard/rules.pro")).isTrue() + assertThat(transformer.canTransformResource("META-INF/custom/rules.pro")).isTrue() + } + + @Test + fun relocatedClasses() { + val relocator = SimpleRelocator("org.foo", "borg.foo", excludes = listOf("org.foo.exclude.*")) + val content = "-keep class org.foo.Service { *; }\n-keep class org.foo.exclude.OtherService" + val path = "META-INF/proguard/app.pro" + + transformer.transform(textContext(path, content, relocator)) + + tempJar.outputStream().zipOutputStream().use { zos -> + transformer.modifyOutputStream(zos, false) + } + + val transformedContent = JarPath(tempJar).use { it.getContent(path) } + assertThat(transformedContent) + .isEqualTo("-keep class borg.foo.Service { *; }\n-keep class org.foo.exclude.OtherService") + } + + @Test + fun mergeRelocatedFiles() { + val relocator = SimpleRelocator("org.foo", "borg.foo", excludes = listOf("org.foo.exclude.*")) + val content1 = "-keep class org.foo.Service { *; }" + val content2 = "-keep class org.foo.exclude.OtherService" + val path = "META-INF/proguard/app.pro" + + transformer.transform(textContext(path, content1, relocator)) + transformer.transform(textContext(path, content2, relocator)) + + tempJar.outputStream().zipOutputStream().use { zos -> + transformer.modifyOutputStream(zos, false) + } + + val transformedContent = JarPath(tempJar).use { it.getContent(path) } + assertThat(transformedContent) + .isEqualTo("-keep class borg.foo.Service { *; }\n-keep class org.foo.exclude.OtherService") + } + + private companion object { + @JvmStatic + fun resourceProvider() = + listOf( + // path, expected + Arguments.of("META-INF/proguard/app.pro", true), + Arguments.of("META-INF/proguard/rules.pro", true), + Arguments.of("META-INF/services/com.acme.Foo", false), + Arguments.of("foo/bar.properties", false), + ) + + @JvmStatic + fun proGuardFileProvider() = + listOf( + // path, input1, input2, output + Arguments.of( + "META-INF/proguard/app.pro", + "-keep class com.foo.Bar", + "-keep class com.foo.Baz", + "-keep class com.foo.Bar\n-keep class com.foo.Baz", + ), + Arguments.of( + "META-INF/proguard/rules.pro", + "-keep class com.foo.**", + "-dontwarn com.foo.**", + "-keep class com.foo.**\n-dontwarn com.foo.**", + ), + ) + } +}