Skip to content

Commit 64714f4

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 aa386eb commit 64714f4

File tree

7 files changed

+107
-81
lines changed

7 files changed

+107
-81
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
@@ -167,8 +167,6 @@ class PropertiesFileTransformerTest : BaseTransformerTest() {
167167
val content = outputShadowedJar.use { it.getContent("META-INF/test.properties") }
168168
assertThat(content.trimIndent()).isEqualTo(
169169
"""
170-
#
171-
172170
foo=one,two
173171
""".trimIndent(),
174172
)

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 & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.github.jengelman.gradle.plugins.shadow.transformers
22

3-
import com.github.jengelman.gradle.plugins.shadow.internal.CleanProperties
4-
import com.github.jengelman.gradle.plugins.shadow.internal.inputStream
3+
import com.github.jengelman.gradle.plugins.shadow.internal.ReproducibleProperties
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
@@ -109,7 +108,7 @@ public open class PropertiesFileTransformer @Inject constructor(
109108
internal val conflicts: MutableMap<String, MutableMap<String, Int>> = mutableMapOf()
110109

111110
@get:Internal
112-
internal val propertiesEntries = mutableMapOf<String, CleanProperties>()
111+
internal val propertiesEntries = mutableMapOf<String, ReproducibleProperties>()
113112

114113
@get:Input
115114
public open val paths: SetProperty<String> = objectFactory.setProperty()
@@ -123,6 +122,22 @@ public open class PropertiesFileTransformer @Inject constructor(
123122
@get:Input
124123
public open val mergeSeparator: Property<String> = objectFactory.property(",")
125124

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

@@ -146,49 +161,35 @@ public open class PropertiesFileTransformer @Inject constructor(
146161
}
147162

148163
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-
}
164+
val props = propertiesEntries.computeIfAbsent(context.path) { ReproducibleProperties() }.props
165+
val mergeStrategy = MergeStrategy.from(mergeStrategyFor(context.path))
166+
val mergeSeparator = if (mergeStrategy == MergeStrategy.Append) mergeSeparatorFor(context.path) else ""
167+
loadAndTransformKeys(context.inputStream) { key, value ->
168+
if (props.containsKey(key)) {
169+
when (mergeStrategy) {
170+
MergeStrategy.Latest -> {
171+
props[key] = value
172+
}
173+
MergeStrategy.Append -> {
174+
props[key] = props[key] + mergeSeparator + value
175+
}
176+
MergeStrategy.First -> Unit
177+
MergeStrategy.Fail -> {
178+
val conflictsForPath = conflicts.computeIfAbsent(context.path) { mutableMapOf() }
179+
conflictsForPath.compute(key) { _, count -> (count ?: 1) + 1 }
168180
}
169-
} else {
170-
props[key] = value
171181
}
182+
} else {
183+
props[key] = value
172184
}
173185
}
174186
}
175187

176-
private fun loadAndTransformKeys(inputStream: InputStream): CleanProperties {
177-
val props = CleanProperties()
188+
private fun loadAndTransformKeys(inputStream: InputStream, keyValue: (key: String, value: String) -> Unit) {
189+
val props = Properties()
178190
// InputStream closed by caller, so we don't do it here.
179191
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
192+
props.forEach { keyValue(keyTransformer(it.key as String), it.value as String) }
192193
}
193194

194195
private fun mergeStrategyFor(path: String): String {
@@ -234,13 +235,9 @@ public open class PropertiesFileTransformer @Inject constructor(
234235
}
235236

236237
// Cannot close the writer as the OutputStream needs to remain open.
237-
val zipWriter = os.writer(charset)
238238
propertiesEntries.forEach { (path, props) ->
239239
os.putNextEntry(zipEntry(path, preserveFileTimestamps))
240-
props.inputStream(charset).bufferedReader(charset).use {
241-
it.copyTo(zipWriter)
242-
}
243-
zipWriter.flush()
240+
props.writeProperties(charset, os, escapeUnicode.get())
244241
os.closeEntry()
245242
}
246243
}

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)