Skip to content

Commit ca202ad

Browse files
committed
Support Maven's outputTimestamp when repackaging jars and wars
Closes gh-20176
1 parent df8c25e commit ca202ad

File tree

11 files changed

+319
-6
lines changed

11 files changed

+319
-6
lines changed

spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.io.IOException;
2323
import java.nio.file.Files;
2424
import java.nio.file.Path;
25+
import java.nio.file.attribute.FileTime;
2526
import java.nio.file.attribute.PosixFilePermission;
2627
import java.util.HashSet;
2728
import java.util.Set;
@@ -44,6 +45,8 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable {
4445

4546
private final JarArchiveOutputStream jarOutputStream;
4647

48+
private final FileTime lastModifiedTime;
49+
4750
/**
4851
* Create a new {@link JarWriter} instance.
4952
* @param file the file to write
@@ -62,13 +65,29 @@ public JarWriter(File file) throws FileNotFoundException, IOException {
6265
* @throws FileNotFoundException if the file cannot be found
6366
*/
6467
public JarWriter(File file, LaunchScript launchScript) throws FileNotFoundException, IOException {
68+
this(file, launchScript, null);
69+
}
70+
71+
/**
72+
* Create a new {@link JarWriter} instance.
73+
* @param file the file to write
74+
* @param launchScript an optional launch script to prepend to the front of the jar
75+
* @param lastModifiedTime an optional last modified time to apply to the written
76+
* entries
77+
* @throws IOException if the file cannot be opened
78+
* @throws FileNotFoundException if the file cannot be found
79+
* @since 2.3.0
80+
*/
81+
public JarWriter(File file, LaunchScript launchScript, FileTime lastModifiedTime)
82+
throws FileNotFoundException, IOException {
6583
FileOutputStream fileOutputStream = new FileOutputStream(file);
6684
if (launchScript != null) {
6785
fileOutputStream.write(launchScript.toByteArray());
6886
setExecutableFilePermission(file);
6987
}
7088
this.jarOutputStream = new JarArchiveOutputStream(fileOutputStream);
7189
this.jarOutputStream.setEncoding("UTF-8");
90+
this.lastModifiedTime = lastModifiedTime;
7291
}
7392

7493
private void setExecutableFilePermission(File file) {
@@ -85,7 +104,11 @@ private void setExecutableFilePermission(File file) {
85104

86105
@Override
87106
protected void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException {
88-
this.jarOutputStream.putArchiveEntry(asJarArchiveEntry(entry));
107+
JarArchiveEntry jarEntry = asJarArchiveEntry(entry);
108+
if (this.lastModifiedTime != null) {
109+
jarEntry.setLastModifiedTime(this.lastModifiedTime);
110+
}
111+
this.jarOutputStream.putArchiveEntry(jarEntry);
89112
if (entryWriter != null) {
90113
entryWriter.write(this.jarOutputStream);
91114
}

spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.io.File;
2020
import java.io.IOException;
21+
import java.nio.file.attribute.FileTime;
2122
import java.util.jar.JarFile;
2223

2324
import org.springframework.util.Assert;
@@ -82,6 +83,22 @@ public void repackage(File destination, Libraries libraries) throws IOException
8283
* @since 1.3.0
8384
*/
8485
public void repackage(File destination, Libraries libraries, LaunchScript launchScript) throws IOException {
86+
this.repackage(destination, libraries, launchScript, null);
87+
}
88+
89+
/**
90+
* Repackage to the given destination so that it can be launched using '
91+
* {@literal java -jar}'.
92+
* @param destination the destination file (may be the same as the source)
93+
* @param libraries the libraries required to run the archive
94+
* @param launchScript an optional launch script prepended to the front of the jar
95+
* @param lastModifiedTime an optional last modified time to apply to the archive and
96+
* its contents
97+
* @throws IOException if the file cannot be repackaged
98+
* @since 2.3.0
99+
*/
100+
public void repackage(File destination, Libraries libraries, LaunchScript launchScript, FileTime lastModifiedTime)
101+
throws IOException {
85102
Assert.isTrue(destination != null && !destination.isDirectory(), "Invalid destination");
86103
destination = destination.getAbsoluteFile();
87104
File source = getSource();
@@ -97,7 +114,7 @@ public void repackage(File destination, Libraries libraries, LaunchScript launch
97114
destination.delete();
98115
try {
99116
try (JarFile sourceJar = new JarFile(workingSource)) {
100-
repackage(sourceJar, destination, libraries, launchScript);
117+
repackage(sourceJar, destination, libraries, launchScript, lastModifiedTime);
101118
}
102119
}
103120
finally {
@@ -107,11 +124,14 @@ public void repackage(File destination, Libraries libraries, LaunchScript launch
107124
}
108125
}
109126

110-
private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript)
111-
throws IOException {
112-
try (JarWriter writer = new JarWriter(destination, launchScript)) {
127+
private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript,
128+
FileTime lastModifiedTime) throws IOException {
129+
try (JarWriter writer = new JarWriter(destination, launchScript, lastModifiedTime)) {
113130
write(sourceJar, libraries, writer);
114131
}
132+
if (lastModifiedTime != null) {
133+
destination.setLastModified(lastModifiedTime.toMillis());
134+
}
115135
}
116136

117137
private void renameFile(File file, File dest) {

spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies {
2626

2727
intTestImplementation(platform(project(":spring-boot-project:spring-boot-parent")))
2828
intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform"))
29+
intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools"))
2930
intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
3031
intTestImplementation("org.apache.maven.shared:maven-invoker")
3132
intTestImplementation("org.assertj:assertj-core")

spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@
1616
package org.springframework.boot.maven;
1717

1818
import java.io.File;
19+
import java.io.IOException;
20+
import java.util.List;
21+
import java.util.concurrent.atomic.AtomicReference;
22+
import java.util.jar.JarFile;
23+
import java.util.stream.Collectors;
1924

2025
import org.junit.jupiter.api.TestTemplate;
2126
import org.junit.jupiter.api.extension.ExtendWith;
2227

28+
import org.springframework.boot.loader.tools.FileUtils;
29+
import org.springframework.util.FileSystemUtils;
30+
2331
import static org.assertj.core.api.Assertions.assertThat;
2432

2533
/**
@@ -316,4 +324,35 @@ void whenJarIsRepackagedWithTheCustomLayeredLayout(MavenBuild mavenBuild) {
316324
});
317325
}
318326

327+
@TestTemplate
328+
void whenJarIsRepackagedWithOutputTimestampConfiguredThenJarIsReproducible(MavenBuild mavenBuild)
329+
throws InterruptedException {
330+
String firstHash = buildJarWithOutputTimestamp(mavenBuild);
331+
Thread.sleep(1500);
332+
String secondHash = buildJarWithOutputTimestamp(mavenBuild);
333+
assertThat(firstHash).isEqualTo(secondHash);
334+
}
335+
336+
private String buildJarWithOutputTimestamp(MavenBuild mavenBuild) {
337+
AtomicReference<String> jarHash = new AtomicReference<>();
338+
mavenBuild.project("jar-output-timestamp").execute((project) -> {
339+
File repackaged = new File(project, "target/jar-output-timestamp-0.0.1.BUILD-SNAPSHOT.jar");
340+
assertThat(repackaged).isFile();
341+
assertThat(repackaged.lastModified()).isEqualTo(1584352800000L);
342+
try (JarFile jar = new JarFile(repackaged)) {
343+
List<String> unreproducibleEntries = jar.stream()
344+
.filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L)
345+
.map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime())
346+
.collect(Collectors.toList());
347+
assertThat(unreproducibleEntries).isEmpty();
348+
jarHash.set(FileUtils.sha1Hash(repackaged));
349+
FileSystemUtils.deleteRecursively(project);
350+
}
351+
catch (IOException ex) {
352+
throw new RuntimeException(ex);
353+
}
354+
});
355+
return jarHash.get();
356+
}
357+
319358
}

spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,18 @@
1717
package org.springframework.boot.maven;
1818

1919
import java.io.File;
20+
import java.io.IOException;
21+
import java.util.List;
22+
import java.util.concurrent.atomic.AtomicReference;
23+
import java.util.jar.JarFile;
24+
import java.util.stream.Collectors;
2025

2126
import org.junit.jupiter.api.TestTemplate;
2227
import org.junit.jupiter.api.extension.ExtendWith;
2328

29+
import org.springframework.boot.loader.tools.FileUtils;
30+
import org.springframework.util.FileSystemUtils;
31+
2432
import static org.assertj.core.api.Assertions.assertThat;
2533

2634
/**
@@ -63,4 +71,35 @@ void whenRequiresUnpackConfigurationIsProvidedItIsReflectedInTheRepackagedWar(Ma
6371
.hasEntryWithNameStartingWith("WEB-INF/lib/spring-jcl-"));
6472
}
6573

74+
@TestTemplate
75+
void whenWarIsRepackagedWithOutputTimestampConfiguredThenWarIsReproducible(MavenBuild mavenBuild)
76+
throws InterruptedException {
77+
String firstHash = buildWarWithOutputTimestamp(mavenBuild);
78+
Thread.sleep(1500);
79+
String secondHash = buildWarWithOutputTimestamp(mavenBuild);
80+
assertThat(firstHash).isEqualTo(secondHash);
81+
}
82+
83+
private String buildWarWithOutputTimestamp(MavenBuild mavenBuild) {
84+
AtomicReference<String> warHash = new AtomicReference<>();
85+
mavenBuild.project("war-output-timestamp").execute((project) -> {
86+
File repackaged = new File(project, "target/war-output-timestamp-0.0.1.BUILD-SNAPSHOT.war");
87+
assertThat(repackaged).isFile();
88+
assertThat(repackaged.lastModified()).isEqualTo(1584352800000L);
89+
try (JarFile jar = new JarFile(repackaged)) {
90+
List<String> unreproducibleEntries = jar.stream()
91+
.filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L)
92+
.map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime())
93+
.collect(Collectors.toList());
94+
assertThat(unreproducibleEntries).isEmpty();
95+
warHash.set(FileUtils.sha1Hash(repackaged));
96+
FileSystemUtils.deleteRecursively(project);
97+
}
98+
catch (IOException ex) {
99+
throw new RuntimeException(ex);
100+
}
101+
});
102+
return warHash.get();
103+
}
104+
66105
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<groupId>org.springframework.boot.maven.it</groupId>
6+
<artifactId>jar-output-timestamp</artifactId>
7+
<version>0.0.1.BUILD-SNAPSHOT</version>
8+
<properties>
9+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
10+
<project.build.outputTimestamp>2020-03-16T02:00:00-08:00</project.build.outputTimestamp>
11+
<maven.compiler.source>@java.version@</maven.compiler.source>
12+
<maven.compiler.target>@java.version@</maven.compiler.target>
13+
</properties>
14+
<build>
15+
<plugins>
16+
<plugin>
17+
<groupId>@project.groupId@</groupId>
18+
<artifactId>@project.artifactId@</artifactId>
19+
<version>@project.version@</version>
20+
<executions>
21+
<execution>
22+
<goals>
23+
<goal>repackage</goal>
24+
</goals>
25+
</execution>
26+
</executions>
27+
</plugin>
28+
<plugin>
29+
<groupId>org.apache.maven.plugins</groupId>
30+
<artifactId>maven-jar-plugin</artifactId>
31+
<version>@maven-jar-plugin.version@</version>
32+
<configuration>
33+
<archive>
34+
<manifest>
35+
<mainClass>some.random.Main</mainClass>
36+
</manifest>
37+
<manifestEntries>
38+
<Not-Used>Foo</Not-Used>
39+
</manifestEntries>
40+
</archive>
41+
</configuration>
42+
</plugin>
43+
</plugins>
44+
</build>
45+
<dependencies>
46+
<dependency>
47+
<groupId>org.springframework</groupId>
48+
<artifactId>spring-context</artifactId>
49+
<version>@spring-framework.version@</version>
50+
</dependency>
51+
<dependency>
52+
<groupId>jakarta.servlet</groupId>
53+
<artifactId>jakarta.servlet-api</artifactId>
54+
<version>@jakarta-servlet.version@</version>
55+
<scope>provided</scope>
56+
</dependency>
57+
</dependencies>
58+
</project>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2012-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.test;
18+
19+
public class SampleApplication {
20+
21+
public static void main(String[] args) {
22+
}
23+
24+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<groupId>org.springframework.boot.maven.it</groupId>
6+
<artifactId>war-output-timestamp</artifactId>
7+
<version>0.0.1.BUILD-SNAPSHOT</version>
8+
<packaging>war</packaging>
9+
<properties>
10+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
11+
<project.build.outputTimestamp>2020-03-16T02:00:00-08:00</project.build.outputTimestamp>
12+
<maven.compiler.source>@java.version@</maven.compiler.source>
13+
<maven.compiler.target>@java.version@</maven.compiler.target>
14+
</properties>
15+
<build>
16+
<plugins>
17+
<plugin>
18+
<groupId>@project.groupId@</groupId>
19+
<artifactId>@project.artifactId@</artifactId>
20+
<version>@project.version@</version>
21+
<executions>
22+
<execution>
23+
<goals>
24+
<goal>repackage</goal>
25+
</goals>
26+
</execution>
27+
</executions>
28+
</plugin>
29+
<plugin>
30+
<groupId>org.apache.maven.plugins</groupId>
31+
<artifactId>maven-war-plugin</artifactId>
32+
<version>@maven-war-plugin.version@</version>
33+
<configuration>
34+
<archive>
35+
<manifestEntries>
36+
<Not-Used>Foo</Not-Used>
37+
</manifestEntries>
38+
</archive>
39+
</configuration>
40+
</plugin>
41+
</plugins>
42+
</build>
43+
<dependencies>
44+
<dependency>
45+
<groupId>org.springframework</groupId>
46+
<artifactId>spring-context</artifactId>
47+
<version>@spring-framework.version@</version>
48+
</dependency>
49+
<dependency>
50+
<groupId>jakarta.servlet</groupId>
51+
<artifactId>jakarta.servlet-api</artifactId>
52+
<version>@jakarta-servlet.version@</version>
53+
<scope>provided</scope>
54+
</dependency>
55+
</dependencies>
56+
</project>

0 commit comments

Comments
 (0)