Skip to content

Commit 751dddc

Browse files
committed
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).
1 parent b5dc4ec commit 751dddc

File tree

7 files changed

+107
-80
lines changed

7 files changed

+107
-80
lines changed

api/shadow.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ public class com/github/jengelman/gradle/plugins/shadow/transformers/PropertiesF
419419
public fun <init> (Lorg/gradle/api/model/ObjectFactory;)V
420420
public fun canTransformResource (Lorg/gradle/api/file/FileTreeElement;)Z
421421
public fun getCharsetName ()Lorg/gradle/api/provider/Property;
422+
public fun getEscapeUnicode ()Lorg/gradle/api/provider/Property;
422423
public fun getKeyTransformer ()Lkotlin/jvm/functions/Function1;
423424
public fun getMappings ()Lorg/gradle/api/provider/MapProperty;
424425
public fun getMergeSeparator ()Lorg/gradle/api/provider/Property;

docs/changes/README.md

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

2324
### Fixed
2425

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,6 @@ class PropertiesFileTransformerTest : BaseTransformerTest() {
171171
val content = outputShadowedJar.use { it.getContent("META-INF/test.properties") }
172172
assertThat(content.trimIndent()).isEqualTo(
173173
"""
174-
#
175-
176174
foo=one,two
177175
""".trimIndent(),
178176
)

src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/CleanProperties.kt

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.github.jengelman.gradle.plugins.shadow.internal
2+
3+
import java.io.OutputStream
4+
import java.nio.charset.Charset
5+
import java.util.HexFormat
6+
import java.util.SortedMap
7+
8+
/**
9+
* Maintains a map of properties sorted by key, which can be written out to a text file with a consistent
10+
* ordering to satisfy the requirements of reproducible builds.
11+
*/
12+
internal class ReproducibleProperties {
13+
internal val props: SortedMap<String, String> = sortedMapOf()
14+
15+
fun writeProperties(charset: Charset, os: OutputStream, escape: Boolean) {
16+
val zipWriter = os.writer(charset)
17+
props.forEach { (key, value) ->
18+
zipWriter.write(convertString(key, isKey = true, escape))
19+
zipWriter.write("=")
20+
zipWriter.write(convertString(value, isKey = false, escape))
21+
zipWriter.write("\n")
22+
}
23+
zipWriter.flush()
24+
}
25+
26+
private fun convertString(
27+
str: String,
28+
isKey: Boolean,
29+
escape: Boolean,
30+
): String {
31+
val len = str.length
32+
val out = StringBuilder()
33+
val hex = HexFormat.of().withUpperCase()
34+
for (x in 0..<len) {
35+
val aChar = str[x]
36+
// Handle the common case first, avoid more expensive special cases
37+
if ((aChar.code > 61) && (aChar.code < 127)) {
38+
out.append(if (aChar == '\\') "\\\\" else aChar)
39+
continue
40+
}
41+
when (aChar) {
42+
' ' -> {
43+
if (x == 0 || isKey) out.append('\\')
44+
out.append(' ')
45+
}
46+
'\t' -> out.append("\\t")
47+
'\n' -> out.append("\\n")
48+
'\r' -> out.append("\\r")
49+
'\u000c' -> out.append("\\f")
50+
'=', ':', '#', '!' -> out.append('\\').append(aChar)
51+
else -> if (escape && ((aChar.code < 0x0020) || (aChar.code > 0x007e))) {
52+
out.append("\\u").append(hex.toHexDigits(aChar))
53+
} else {
54+
out.append(aChar)
55+
}
56+
}
57+
}
58+
return out.toString()
59+
}
60+
}

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

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

3-
import com.github.jengelman.gradle.plugins.shadow.internal.CleanProperties
3+
import com.github.jengelman.gradle.plugins.shadow.internal.ReproducibleProperties
44
import com.github.jengelman.gradle.plugins.shadow.internal.inputStream
55
import com.github.jengelman.gradle.plugins.shadow.internal.mapProperty
66
import com.github.jengelman.gradle.plugins.shadow.internal.property
@@ -111,7 +111,7 @@ public open class PropertiesFileTransformer @Inject constructor(
111111
internal val conflicts: MutableMap<String, MutableMap<String, Int>> = mutableMapOf()
112112

113113
@get:Internal
114-
internal val propertiesEntries = mutableMapOf<String, CleanProperties>()
114+
internal val propertiesEntries = mutableMapOf<String, ReproducibleProperties>()
115115

116116
@get:Input
117117
public open val paths: SetProperty<String> = objectFactory.setProperty()
@@ -125,6 +125,22 @@ public open class PropertiesFileTransformer @Inject constructor(
125125
@get:Input
126126
public open val mergeSeparator: Property<String> = objectFactory.property(",")
127127

128+
/**
129+
* Properties files are written without escaping unicode characters using the character set
130+
* configured by [charsetName].
131+
*
132+
* Set this property to `true` to escape all unicode characters in the properties file, producing
133+
* ASCII compatible files.
134+
*/
135+
@get:Input
136+
public open val escapeUnicode: Property<Boolean> = objectFactory.property(false)
137+
138+
/**
139+
* The character set to use when reading and writing property files.
140+
* Defaults to `ISO-8859-1`.
141+
*
142+
* See also [escapeUnicode].
143+
*/
128144
@get:Input
129145
public open val charsetName: Property<String> = objectFactory.property(Charsets.ISO_8859_1.name())
130146

@@ -148,49 +164,35 @@ public open class PropertiesFileTransformer @Inject constructor(
148164
}
149165

150166
override fun transform(context: TransformerContext) {
151-
val props = propertiesEntries[context.path]
152-
val incoming = loadAndTransformKeys(context.inputStream)
153-
if (props == null) {
154-
propertiesEntries[context.path] = incoming
155-
} else {
156-
for ((key, value) in incoming) {
157-
if (props.containsKey(key)) {
158-
when (MergeStrategy.from(mergeStrategyFor(context.path))) {
159-
MergeStrategy.Latest -> {
160-
props[key] = value
161-
}
162-
MergeStrategy.Append -> {
163-
props[key] = props.getProperty(key as String) + mergeSeparatorFor(context.path) + value
164-
}
165-
MergeStrategy.First -> Unit
166-
MergeStrategy.Fail -> {
167-
val conflictsForPath: MutableMap<String, Int> = conflicts.computeIfAbsent(context.path) { mutableMapOf() }
168-
conflictsForPath.compute(key as String) { _, count -> (count ?: 1) + 1 }
169-
}
167+
val props = propertiesEntries.computeIfAbsent(context.path) { ReproducibleProperties() }.props
168+
val mergeStrategy = MergeStrategy.from(mergeStrategyFor(context.path))
169+
val mergeSeparator = if (mergeStrategy == MergeStrategy.Append) mergeSeparatorFor(context.path) else ""
170+
loadAndTransformKeys(context.inputStream) { key, value ->
171+
if (props.containsKey(key)) {
172+
when (mergeStrategy) {
173+
MergeStrategy.Latest -> {
174+
props[key] = value
175+
}
176+
MergeStrategy.Append -> {
177+
props[key] = props[key] + mergeSeparator + value
178+
}
179+
MergeStrategy.First -> Unit
180+
MergeStrategy.Fail -> {
181+
val conflictsForPath: MutableMap<String, Int> = conflicts.computeIfAbsent(context.path) { mutableMapOf() }
182+
conflictsForPath.compute(key as String) { _, count -> (count ?: 1) + 1 }
170183
}
171-
} else {
172-
props[key] = value
173184
}
185+
} else {
186+
props[key] = value
174187
}
175188
}
176189
}
177190

178-
private fun loadAndTransformKeys(inputStream: InputStream): CleanProperties {
179-
val props = CleanProperties()
191+
private fun loadAndTransformKeys(inputStream: InputStream, keyValue: (key: String, value: String) -> Unit) {
192+
val props = Properties()
180193
// InputStream closed by caller, so we don't do it here.
181194
props.load(inputStream.bufferedReader(charset))
182-
return transformKeys(props)
183-
}
184-
185-
private fun transformKeys(properties: Properties): CleanProperties {
186-
if (keyTransformer == IDENTITY) {
187-
return properties as CleanProperties
188-
}
189-
val result = CleanProperties()
190-
properties.forEach { (key, value) ->
191-
result[keyTransformer(key as String)] = value
192-
}
193-
return result
195+
props.forEach { keyValue(keyTransformer(it.key as String), it.value as String) }
194196
}
195197

196198
private fun mergeStrategyFor(path: String): String {
@@ -235,13 +237,9 @@ public open class PropertiesFileTransformer @Inject constructor(
235237
}
236238

237239
// Cannot close the writer as the OutputStream needs to remain open.
238-
val zipWriter = os.writer(charset)
239240
propertiesEntries.forEach { (path, props) ->
240241
os.putNextEntry(zipEntry(path, preserveFileTimestamps))
241-
props.inputStream(charset).bufferedReader(charset).use {
242-
it.copyTo(zipWriter)
243-
}
244-
zipWriter.flush()
242+
props.writeProperties(charset, os, escapeUnicode.get())
245243
os.closeEntry()
246244
}
247245
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class PropertiesFileTransformerTest : BaseTransformerTest<PropertiesFileTransfor
7474
transformer.transform(context(path, input2))
7575
}
7676

77-
assertThat(transformer.propertiesEntries[path].orEmpty()).isEqualTo(expectedOutput)
77+
assertThat(transformer.propertiesEntries[path]?.props.orEmpty()).isEqualTo(expectedOutput)
7878
assertThat(transformer.conflicts).isEqualTo(expectedConflicts)
7979
}
8080

@@ -95,7 +95,7 @@ class PropertiesFileTransformerTest : BaseTransformerTest<PropertiesFileTransfor
9595
transformer.transform(context(path, input2))
9696
}
9797

98-
assertThat(transformer.propertiesEntries[path].orEmpty()).isEqualTo(expectedOutput)
98+
assertThat(transformer.propertiesEntries[path]?.props.orEmpty()).isEqualTo(expectedOutput)
9999
}
100100

101101
@ParameterizedTest
@@ -115,7 +115,7 @@ class PropertiesFileTransformerTest : BaseTransformerTest<PropertiesFileTransfor
115115
transformer.transform(context(path, input2))
116116
}
117117

118-
assertThat(transformer.propertiesEntries[path].orEmpty()).isEqualTo(expectedOutput)
118+
assertThat(transformer.propertiesEntries[path]?.props.orEmpty()).isEqualTo(expectedOutput)
119119
}
120120

121121
@ParameterizedTest
@@ -135,7 +135,7 @@ class PropertiesFileTransformerTest : BaseTransformerTest<PropertiesFileTransfor
135135
transformer.transform(context(path, input2))
136136
}
137137

138-
assertThat(transformer.propertiesEntries[path].orEmpty()).isEqualTo(expectedOutput)
138+
assertThat(transformer.propertiesEntries[path]?.props.orEmpty()).isEqualTo(expectedOutput)
139139
}
140140

141141
@ParameterizedTest
@@ -152,7 +152,7 @@ class PropertiesFileTransformerTest : BaseTransformerTest<PropertiesFileTransfor
152152
transformer.transform(context(path, input, Charset.forName(charset)))
153153
}
154154

155-
assertThat(transformer.propertiesEntries[path].orEmpty()).isEqualTo(expectedOutput)
155+
assertThat(transformer.propertiesEntries[path]?.props.orEmpty()).isEqualTo(expectedOutput)
156156
}
157157

158158
private companion object {

0 commit comments

Comments
 (0)