Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import java.io.File
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
import java.util.jar.JarFile
import kotlin.io.path.moveTo
import org.apache.tools.zip.ZipFile
import org.gradle.api.GradleException
import org.gradle.api.file.FileCollection
import org.gradle.api.logging.Logger
Expand Down Expand Up @@ -255,80 +256,66 @@ internal class R8Minimizer(

// R8 writes a fresh jar, so rewrite it through Shadow's archive settings to preserve
// reproducible ordering, timestamps, compression, zip64, and metadata charset behavior.
private fun normalizeJar(inputJar: File, outputJar: File) {
val entries =
JarFile(inputJar).use { jarFile ->
jarFile
.entries()
internal fun normalizeJar(inputJar: File, outputJar: File) {
// Use org.apache.tools.zip.ZipFile instead of java.util.jar.JarFile to access entry.unixMode
// permissions and ensure uniform Zip structure handling.
ZipFile(inputJar).use { zipFile ->
val entries =
zipFile.entries
.asSequence()
.filter { !it.isDirectory }
.map { entry ->
R8JarEntry(
name = entry.name,
time = entry.time,
bytes = jarFile.getInputStream(entry).use { it.readBytes() },
unixMode = entry.unixMode,
)
}
.toList()
}
val orderedEntries = if (reproducibleFileOrder) entries.sortedBy { it.name } else entries
createZipOutputStream(outputJar, entryCompression, zip64).use { zos ->
if (metadataCharset != null) {
zos.setEncoding(metadataCharset)
}
val added = mutableSetOf<String>()

orderedEntries.forEach { entry ->
entry.name.parentDirectoryEntries().forEach { entryName ->
if (!added.add(entryName)) return@forEach
zos.writeEntry(
name = entryName,
preserveLastModified = preserveFileTimestamps,
unixMode = UnixMode.directory(),
)
}
if (added.add(entry.name)) {
zos.writeEntry(
name = entry.name,
preserveLastModified = preserveFileTimestamps,
lastModified = entry.time,
unixMode = UnixMode.file(),
) {
write(entry.bytes)
val orderedEntries = if (reproducibleFileOrder) entries.sortedBy { it.name } else entries

createZipOutputStream(
destination = outputJar,
entryCompression = entryCompression,
zip64 = zip64,
encoding = metadataCharset,
)
.use { zos ->
val added = mutableSetOf<String>()

orderedEntries.forEach { entry ->
entry.name.parentDirectoryEntries().forEach { entryName ->
if (!added.add(entryName)) return@forEach
zos.writeEntry(
name = entryName,
preserveLastModified = preserveFileTimestamps,
unixMode = UnixMode.directory(),
)
}
if (added.add(entry.name)) {
val zipEntry = zipFile.getEntry(entry.name)
val unixMode =
if (entry.unixMode != 0) UnixMode.raw(entry.unixMode) else UnixMode.file()
zos.writeEntry(
name = entry.name,
preserveLastModified = preserveFileTimestamps,
lastModified = entry.time,
unixMode = unixMode,
) {
zipFile.getInputStream(zipEntry).use { input ->
input.copyTo(this)
}
}
}
}
}
}
}
}

private fun String.isJavaTypeName(): Boolean = javaTypeNameRegex.matches(this)

// Not a data class because of the bytearray
private class R8JarEntry(val name: String, val time: Long, val bytes: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as R8JarEntry

if (time != other.time) return false
if (name != other.name) return false
if (!bytes.contentEquals(other.bytes)) return false

return true
}

override fun hashCode(): Int {
var result = time.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + bytes.contentHashCode()
return result
}

override fun toString(): String {
return "R8JarEntry(name='$name', time=$time, bytes=${bytes.toHexString()})"
}
}
private data class R8JarEntry(val name: String, val time: Long, val unixMode: Int)

private companion object {
const val R8_MAIN_CLASS = "com.android.tools.r8.R8"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ internal value class UnixMode private constructor(internal val value: Int) {

fun file(permissions: Int = UnixStat.DEFAULT_FILE_PERM): UnixMode =
UnixMode(UnixStat.FILE_FLAG or permissions)

fun raw(mode: Int): UnixMode = UnixMode(mode)
}
}

internal fun createZipOutputStream(
destination: File,
entryCompression: ZipEntryCompression,
zip64: Boolean,
encoding: String?,
): ZipOutputStream {
val method =
when (entryCompression) {
Expand All @@ -41,6 +44,7 @@ internal fun createZipOutputStream(
return stream.apply {
setUseZip64(if (zip64) Zip64Mode.AsNeeded else Zip64Mode.Never)
setMethod(method)
encoding?.let(::setEncoding)
}
Comment thread
Copilot marked this conversation as resolved.
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,6 @@ public open class ShadowCopyAction(
CopyActionProcessingStreamAction {
init {
logger.info("Relocator count: {}.", relocators.size)
if (encoding != null) {
zipOutStr.setEncoding(encoding)
}
}

override fun processFile(details: FileCopyDetailsInternal) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,12 @@ public abstract class ShadowJar : Jar() {
}
val zosProvider = { destination: File ->
try {
createZipOutputStream(destination, actionEntryCompression, isZip64)
createZipOutputStream(
destination = destination,
entryCompression = actionEntryCompression,
zip64 = isZip64,
encoding = metadataCharset,
)
} catch (e: Exception) {
throw IOException("Unable to create ZIP output stream for file $destination.", e)
}
Expand Down Expand Up @@ -557,7 +562,7 @@ public abstract class ShadowJar : Jar() {
enableKotlinModuleRemapping = false, // Unused param.
preserveFileTimestamps = isPreserveFileTimestamps,
failOnDuplicateEntries = failOnDuplicateEntries.get(),
metadataCharset,
encoding = metadataCharset,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.github.jengelman.gradle.plugins.shadow.internal

import assertk.assertThat
import assertk.assertions.isEqualTo
import com.github.jengelman.gradle.plugins.shadow.util.zipOutputStream
import java.nio.file.Path
import org.apache.tools.zip.UnixStat
import org.apache.tools.zip.ZipFile
import org.gradle.api.internal.project.ProjectInternal
import org.gradle.api.tasks.bundling.ZipEntryCompression
import org.gradle.process.ExecOperations
import org.gradle.testfixtures.ProjectBuilder
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir

class R8MinimizerTest {
private val project = ProjectBuilder.builder().build()

@Test
fun normalizeJarPreservesUnixPermissions(@TempDir tempDir: Path) {
val inputJar = tempDir.resolve("input.jar")
val outputJar = tempDir.resolve("output.jar")
val expectedExecutableMode = UnixStat.FILE_FLAG or 493 // 0755 octal

inputJar.zipOutputStream().use { zos ->
zos.writeEntry("bin/script.sh", unixMode = UnixMode.raw(expectedExecutableMode)) {
write("echo hello\n".toByteArray())
}
zos.writeEntry("com/example/Foo.class") {
write("class bytes".toByteArray())
}
zos.writeEntry("META-INF/MANIFEST.MF") {
write("Manifest-Version: 1.0\n".toByteArray())
}
}

val execOperations = (project as ProjectInternal).services.get(ExecOperations::class.java)
val r8Spec = project.objects.newInstance(DefaultR8Spec::class.java)
val minimizer =
R8Minimizer(
execOperations = execOperations,
logger = project.logger,
r8Classpath = project.files(),
r8Spec = r8Spec,
javaLauncher = project.provider { null },
sourceSetsClassesDirs = emptyList(),
keptDependencyFiles = emptyList(),
relocators = emptyList(),
preserveFileTimestamps = true,
reproducibleFileOrder = true,
zip64 = false,
entryCompression = ZipEntryCompression.DEFLATED,
metadataCharset = Charsets.UTF_8.toString(),
)

minimizer.normalizeJar(inputJar.toFile(), outputJar.toFile())

ZipFile(outputJar.toFile()).use { zipFile ->
// Executable unix mode must be preserved
val scriptEntry = zipFile.getEntry("bin/script.sh")
assertThat(scriptEntry.unixMode).isEqualTo(expectedExecutableMode)
}
}
}