Skip to content

Commit 90b0b3e

Browse files
snazyGoooler
andauthored
Make the output of PropertiesFileTransformer reproducible (#1861)
* `PropertiesFileTransformer` - make merged properties reproducible `PropertiesFileTransformer` leverages `java.util.Properties`, which relies on `java.util.Hashtable`. The serialized properties are not guaranteed to be reproducible. This change changes the transformer to use a sorted map to generate reproducible output. The existing class `CleanProperties` is replaced with `ReproducibleProperties`. Functionality around charset handling is retained, and extended with a functionality to generate unicode escapes (ASCII output). * Cleanups --------- Co-authored-by: Goooler <wangzongler@gmail.com>
1 parent 57096c8 commit 90b0b3e

File tree

8 files changed

+172
-75
lines changed

8 files changed

+172
-75
lines changed

docs/changes/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- Update ASM and jdependency to support Java 26. ([#1799](https://github.com/GradleUp/shadow/pull/1799))
2121
- Bump min Gradle requirement to 9.0.0. ([#1801](https://github.com/GradleUp/shadow/pull/1801))
2222
- Deprecate `PreserveFirstFoundResourceTransformer.resources`. ([#1855](https://github.com/GradleUp/shadow/pull/1855))
23+
- Make the output of `PropertiesFileTransformer` reproducible. ([#1861](https://github.com/GradleUp/shadow/pull/1861))
2324

2425
### Fixed
2526

src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/FindResourceInClasspathTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import assertk.assertThat
55
import assertk.assertions.contains
66
import assertk.assertions.doesNotContain
77
import com.github.jengelman.gradle.plugins.shadow.tasks.FindResourceInClasspath
8-
import com.github.jengelman.gradle.plugins.shadow.util.variantSeparatorsPathString
8+
import com.github.jengelman.gradle.plugins.shadow.testkit.variantSeparatorsPathString
99
import kotlin.io.path.appendText
1010
import org.junit.jupiter.api.Test
1111

src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/PropertiesFileTransformerTest.kt

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import assertk.assertThat
44
import assertk.assertions.contains
55
import assertk.assertions.isEqualTo
66
import com.github.jengelman.gradle.plugins.shadow.testkit.getContent
7+
import com.github.jengelman.gradle.plugins.shadow.testkit.invariantEolString
78
import com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer.MergeStrategy
89
import com.github.jengelman.gradle.plugins.shadow.util.Issue
9-
import com.github.jengelman.gradle.plugins.shadow.util.invariantEolString
1010
import kotlin.io.path.appendText
1111
import org.gradle.testkit.runner.TaskOutcome.FAILED
1212
import org.junit.jupiter.api.Assertions.fail
@@ -51,13 +51,31 @@ class PropertiesFileTransformerTest : BaseTransformerTest() {
5151
runWithSuccess(shadowJarPath)
5252

5353
val expected = when (strategy) {
54-
MergeStrategy.First -> arrayOf("key1=one", "key2=one", "key3=two")
55-
MergeStrategy.Latest -> arrayOf("key1=one", "key2=two", "key3=two")
56-
MergeStrategy.Append -> arrayOf("key1=one", "key2=one;two", "key3=two")
54+
MergeStrategy.First ->
55+
"""
56+
|key1=one
57+
|key2=one
58+
|key3=two
59+
|
60+
""".trimMargin()
61+
MergeStrategy.Latest ->
62+
"""
63+
|key1=one
64+
|key2=two
65+
|key3=two
66+
|
67+
""".trimMargin()
68+
MergeStrategy.Append ->
69+
"""
70+
|key1=one
71+
|key2=one;two
72+
|key3=two
73+
|
74+
""".trimMargin()
5775
else -> fail("Unexpected strategy: $strategy")
5876
}
5977
val content = outputShadowedJar.use { it.getContent("META-INF/test.properties") }
60-
assertThat(content).contains(*expected)
78+
assertThat(content.invariantEolString).isEqualTo(expected)
6179
}
6280
}
6381

@@ -168,8 +186,6 @@ class PropertiesFileTransformerTest : BaseTransformerTest() {
168186
val content = outputShadowedJar.use { it.getContent("META-INF/test.properties") }
169187
assertThat(content.trimIndent()).isEqualTo(
170188
"""
171-
#
172-
173189
foo=one,two
174190
""".trimIndent(),
175191
)
Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
package com.github.jengelman.gradle.plugins.shadow.util
22

3-
import java.nio.file.FileSystems
43
import java.nio.file.Path
54
import kotlin.io.path.readText
65
import kotlin.io.path.writeText
76

87
fun Path.prependText(text: String) = writeText(text + readText())
9-
10-
val String.invariantEolString: String get() = replace(System.lineSeparator(), "\n")
11-
12-
val String.variantSeparatorsPathString: String get() = replace("/", fileSystem.separator)
13-
14-
private val fileSystem = FileSystems.getDefault()
Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
11
package com.github.jengelman.gradle.plugins.shadow.internal
22

3-
import java.io.BufferedWriter
4-
import java.io.IOException
3+
import java.io.OutputStream
4+
import java.io.StringWriter
55
import java.io.Writer
6-
import java.util.Date
6+
import java.nio.charset.Charset
77
import java.util.Properties
88

99
/**
10-
* Introduced in order to remove prepended timestamp when creating output stream.
10+
* Provides functionality for reproducible serialization.
1111
*/
1212
internal class CleanProperties : Properties() {
13-
@Throws(IOException::class)
1413
override fun store(writer: Writer, comments: String) {
15-
super.store(StripCommentsWithTimestampBufferedWriter(writer), comments)
14+
throw UnsupportedOperationException("use writeWithoutComments()")
1615
}
1716

18-
private class StripCommentsWithTimestampBufferedWriter(out: Writer) : BufferedWriter(out) {
19-
private val lengthOfExpectedTimestamp = ("#" + Date().toString()).length
17+
override fun store(out: OutputStream, comments: String?) {
18+
throw UnsupportedOperationException("use writeWithoutComments()")
19+
}
2020

21-
@Throws(IOException::class)
22-
override fun write(str: String) {
23-
if (str.couldBeCommentWithTimestamp) return
24-
super.write(str)
25-
}
21+
fun writeWithoutComments(charset: Charset, os: OutputStream) {
22+
val bufferedReader = StringWriter().apply {
23+
super.store(this, null)
24+
}.toString().reader().buffered()
2625

27-
private val String?.couldBeCommentWithTimestamp: Boolean get() {
28-
return this != null && startsWith("#") && length == lengthOfExpectedTimestamp
29-
}
26+
os.bufferedWriter(charset).apply {
27+
var line: String? = null
28+
while (bufferedReader.readLine().also { line = it } != null && line != null) {
29+
if (!line.startsWith("#")) {
30+
write(line)
31+
newLine()
32+
}
33+
}
34+
}.flush()
3035
}
36+
37+
override val entries: MutableSet<MutableMap.MutableEntry<Any, Any>>
38+
// Yields the entries for Properties.store0() in sorted order.
39+
get() = super.entries.toSortedSet(compareBy { it.key.toString() })
3140
}

src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/PropertiesFileTransformer.kt

Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.github.jengelman.gradle.plugins.shadow.transformers
22

33
import com.github.jengelman.gradle.plugins.shadow.internal.CleanProperties
4-
import com.github.jengelman.gradle.plugins.shadow.internal.inputStream
54
import com.github.jengelman.gradle.plugins.shadow.internal.mapProperty
65
import com.github.jengelman.gradle.plugins.shadow.internal.property
76
import com.github.jengelman.gradle.plugins.shadow.internal.setProperty
@@ -123,6 +122,10 @@ public open class PropertiesFileTransformer @Inject constructor(
123122
@get:Input
124123
public open val mergeSeparator: Property<String> = objectFactory.property(",")
125124

125+
/**
126+
* The character set to use when reading and writing property files.
127+
* Defaults to `ISO-8859-1`.
128+
*/
126129
@get:Input
127130
public open val charsetName: Property<String> = objectFactory.property(Charsets.ISO_8859_1.name())
128131

@@ -146,49 +149,31 @@ public open class PropertiesFileTransformer @Inject constructor(
146149
}
147150

148151
override fun transform(context: TransformerContext) {
149-
val props = propertiesEntries[context.path]
150-
val incoming = loadAndTransformKeys(context.inputStream)
151-
if (props == null) {
152-
propertiesEntries[context.path] = incoming
153-
} else {
154-
for ((key, value) in incoming) {
155-
if (props.containsKey(key)) {
156-
when (MergeStrategy.from(mergeStrategyFor(context.path))) {
157-
MergeStrategy.Latest -> {
158-
props[key] = value
159-
}
160-
MergeStrategy.Append -> {
161-
props[key] = props.getProperty(key as String) + mergeSeparatorFor(context.path) + value
162-
}
163-
MergeStrategy.First -> Unit
164-
MergeStrategy.Fail -> {
165-
val conflictsForPath = conflicts.computeIfAbsent(context.path) { mutableMapOf() }
166-
conflictsForPath.compute(key as String) { _, count -> (count ?: 1) + 1 }
167-
}
152+
val props = propertiesEntries.computeIfAbsent(context.path) { CleanProperties() }
153+
loadAndTransformKeys(context.inputStream) { key, value ->
154+
if (props.containsKey(key)) {
155+
when (MergeStrategy.from(mergeStrategyFor(context.path))) {
156+
MergeStrategy.Latest -> {
157+
props[key] = value
158+
}
159+
MergeStrategy.Append -> {
160+
props[key] = props[key] as String + mergeSeparatorFor(context.path) + value
161+
}
162+
MergeStrategy.First -> Unit
163+
MergeStrategy.Fail -> {
164+
val conflictsForPath = conflicts.computeIfAbsent(context.path) { mutableMapOf() }
165+
conflictsForPath.compute(key) { _, count -> (count ?: 1) + 1 }
168166
}
169-
} else {
170-
props[key] = value
171167
}
168+
} else {
169+
props[key] = value
172170
}
173171
}
174172
}
175173

176-
private fun loadAndTransformKeys(inputStream: InputStream): CleanProperties {
177-
val props = CleanProperties()
178-
// InputStream closed by caller, so we don't do it here.
179-
props.load(inputStream.bufferedReader(charset))
180-
return transformKeys(props)
181-
}
182-
183-
private fun transformKeys(properties: Properties): CleanProperties {
184-
if (keyTransformer == IDENTITY) {
185-
return properties as CleanProperties
186-
}
187-
val result = CleanProperties()
188-
properties.forEach { (key, value) ->
189-
result[keyTransformer(key as String)] = value
190-
}
191-
return result
174+
private fun loadAndTransformKeys(inputStream: InputStream, action: (key: String, value: String) -> Unit) {
175+
val props = Properties().apply { load(inputStream.bufferedReader(charset)) }
176+
props.forEach { action(keyTransformer(it.key as String), it.value as String) }
192177
}
193178

194179
private fun mergeStrategyFor(path: String): String {
@@ -233,14 +218,9 @@ public open class PropertiesFileTransformer @Inject constructor(
233218
error(message)
234219
}
235220

236-
// Cannot close the writer as the OutputStream needs to remain open.
237-
val zipWriter = os.writer(charset)
238221
propertiesEntries.forEach { (path, props) ->
239222
os.putNextEntry(zipEntry(path, preserveFileTimestamps))
240-
props.inputStream(charset).bufferedReader(charset).use {
241-
it.copyTo(zipWriter)
242-
}
243-
zipWriter.flush()
223+
props.writeWithoutComments(charset, os)
244224
os.closeEntry()
245225
}
246226
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.github.jengelman.gradle.plugins.shadow.internal
2+
3+
import assertk.assertThat
4+
import assertk.assertions.isEqualTo
5+
import com.github.jengelman.gradle.plugins.shadow.testkit.invariantEolString
6+
import java.io.ByteArrayOutputStream
7+
import java.nio.charset.Charset
8+
import java.nio.charset.StandardCharsets
9+
import org.junit.jupiter.params.ParameterizedTest
10+
import org.junit.jupiter.params.provider.MethodSource
11+
12+
class CleanPropertiesTest {
13+
@ParameterizedTest
14+
@MethodSource("generalCharsetsProvider")
15+
fun emptyProperties(charset: Charset) {
16+
val output = CleanProperties().writeToString(charset)
17+
18+
assertThat(output).isEqualTo("")
19+
}
20+
21+
@ParameterizedTest
22+
@MethodSource("generalCharsetsProvider")
23+
fun asciiProps(charset: Charset) {
24+
val output = CleanProperties().also { props ->
25+
props["key"] = "value"
26+
props["key2"] = "value2"
27+
props["a"] = "b"
28+
props["d"] = "e"
29+
props["0"] = "1"
30+
props["b"] = "c"
31+
props["c"] = "d"
32+
props["e"] = "f"
33+
}.writeToString(charset)
34+
35+
assertThat(output).isEqualTo(
36+
"""
37+
|0=1
38+
|a=b
39+
|b=c
40+
|c=d
41+
|d=e
42+
|e=f
43+
|key=value
44+
|key2=value2
45+
|
46+
""".trimMargin(),
47+
)
48+
}
49+
50+
@ParameterizedTest
51+
@MethodSource("utfCharsetsProvider")
52+
fun utfProps(charset: Charset) {
53+
val output = CleanProperties().also { props ->
54+
props["äöüß"] = "aouss"
55+
props["áèô"] = "aeo"
56+
props["€²³"] = "x"
57+
props["传傳磨宿说説"] = "b"
58+
}.writeToString(charset)
59+
60+
assertThat(output).isEqualTo(
61+
"""
62+
|áèô=aeo
63+
|äöüß=aouss
64+
|€²³=x
65+
|传傳磨宿说説=b
66+
|
67+
""".trimMargin(),
68+
)
69+
}
70+
71+
private companion object Companion {
72+
@JvmStatic
73+
fun generalCharsetsProvider() = listOf(
74+
StandardCharsets.ISO_8859_1,
75+
StandardCharsets.US_ASCII,
76+
) + utfCharsetsProvider()
77+
78+
@JvmStatic
79+
fun utfCharsetsProvider() = listOf(
80+
StandardCharsets.UTF_8,
81+
StandardCharsets.UTF_16,
82+
)
83+
84+
fun CleanProperties.writeToString(charset: Charset): String {
85+
return ByteArrayOutputStream().also { writeWithoutComments(charset, it) }
86+
.toString(charset.name()).invariantEolString
87+
}
88+
}
89+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.github.jengelman.gradle.plugins.shadow.testkit
2+
3+
import java.nio.file.FileSystems
4+
5+
val String.invariantEolString: String get() = replace(System.lineSeparator(), "\n")
6+
7+
val String.variantSeparatorsPathString: String get() = replace("/", fileSystem.separator)
8+
9+
private val fileSystem = FileSystems.getDefault()

0 commit comments

Comments
 (0)