From ca202ad59fb2c526c7d1738fc31b5e147aa5f600 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 16 Mar 2020 16:59:34 +0000 Subject: [PATCH] Support Maven's outputTimestamp when repackaging jars and wars Closes gh-20176 --- .../boot/loader/tools/JarWriter.java | 25 +++++++- .../boot/loader/tools/Repackager.java | 28 +++++++-- .../spring-boot-maven-plugin/build.gradle | 1 + .../boot/maven/JarIntegrationTests.java | 39 +++++++++++++ .../boot/maven/WarIntegrationTests.java | 39 +++++++++++++ .../projects/jar-output-timestamp/pom.xml | 58 +++++++++++++++++++ .../main/java/org/test/SampleApplication.java | 24 ++++++++ .../projects/war-output-timestamp/pom.xml | 56 ++++++++++++++++++ .../main/java/org/test/SampleApplication.java | 24 ++++++++ .../src/main/webapp/index.html | 1 + .../boot/maven/RepackageMojo.java | 30 +++++++++- 11 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/pom.xml create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/src/main/java/org/test/SampleApplication.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/pom.xml create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/java/org/test/SampleApplication.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/webapp/index.html diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java index 4a765194310..56bf80f95b2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java @@ -22,6 +22,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import java.nio.file.attribute.PosixFilePermission; import java.util.HashSet; import java.util.Set; @@ -44,6 +45,8 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable { private final JarArchiveOutputStream jarOutputStream; + private final FileTime lastModifiedTime; + /** * Create a new {@link JarWriter} instance. * @param file the file to write @@ -62,6 +65,21 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable { * @throws FileNotFoundException if the file cannot be found */ public JarWriter(File file, LaunchScript launchScript) throws FileNotFoundException, IOException { + this(file, launchScript, null); + } + + /** + * Create a new {@link JarWriter} instance. + * @param file the file to write + * @param launchScript an optional launch script to prepend to the front of the jar + * @param lastModifiedTime an optional last modified time to apply to the written + * entries + * @throws IOException if the file cannot be opened + * @throws FileNotFoundException if the file cannot be found + * @since 2.3.0 + */ + public JarWriter(File file, LaunchScript launchScript, FileTime lastModifiedTime) + throws FileNotFoundException, IOException { FileOutputStream fileOutputStream = new FileOutputStream(file); if (launchScript != null) { fileOutputStream.write(launchScript.toByteArray()); @@ -69,6 +87,7 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable { } this.jarOutputStream = new JarArchiveOutputStream(fileOutputStream); this.jarOutputStream.setEncoding("UTF-8"); + this.lastModifiedTime = lastModifiedTime; } private void setExecutableFilePermission(File file) { @@ -85,7 +104,11 @@ public class JarWriter extends AbstractJarWriter implements AutoCloseable { @Override protected void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException { - this.jarOutputStream.putArchiveEntry(asJarArchiveEntry(entry)); + JarArchiveEntry jarEntry = asJarArchiveEntry(entry); + if (this.lastModifiedTime != null) { + jarEntry.setLastModifiedTime(this.lastModifiedTime); + } + this.jarOutputStream.putArchiveEntry(jarEntry); if (entryWriter != null) { entryWriter.write(this.jarOutputStream); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java index 6b0f5ddae93..274783704c6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java @@ -18,6 +18,7 @@ package org.springframework.boot.loader.tools; import java.io.File; import java.io.IOException; +import java.nio.file.attribute.FileTime; import java.util.jar.JarFile; import org.springframework.util.Assert; @@ -82,6 +83,22 @@ public class Repackager extends Packager { * @since 1.3.0 */ public void repackage(File destination, Libraries libraries, LaunchScript launchScript) throws IOException { + this.repackage(destination, libraries, launchScript, null); + } + + /** + * Repackage to the given destination so that it can be launched using ' + * {@literal java -jar}'. + * @param destination the destination file (may be the same as the source) + * @param libraries the libraries required to run the archive + * @param launchScript an optional launch script prepended to the front of the jar + * @param lastModifiedTime an optional last modified time to apply to the archive and + * its contents + * @throws IOException if the file cannot be repackaged + * @since 2.3.0 + */ + public void repackage(File destination, Libraries libraries, LaunchScript launchScript, FileTime lastModifiedTime) + throws IOException { Assert.isTrue(destination != null && !destination.isDirectory(), "Invalid destination"); destination = destination.getAbsoluteFile(); File source = getSource(); @@ -97,7 +114,7 @@ public class Repackager extends Packager { destination.delete(); try { try (JarFile sourceJar = new JarFile(workingSource)) { - repackage(sourceJar, destination, libraries, launchScript); + repackage(sourceJar, destination, libraries, launchScript, lastModifiedTime); } } finally { @@ -107,11 +124,14 @@ public class Repackager extends Packager { } } - private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript) - throws IOException { - try (JarWriter writer = new JarWriter(destination, launchScript)) { + private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript, + FileTime lastModifiedTime) throws IOException { + try (JarWriter writer = new JarWriter(destination, launchScript, lastModifiedTime)) { write(sourceJar, libraries, writer); } + if (lastModifiedTime != null) { + destination.setLastModified(lastModifiedTime.toMillis()); + } } private void renameFile(File file, File dest) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle index b4344247605..47d21c4dd1c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/build.gradle @@ -26,6 +26,7 @@ dependencies { intTestImplementation(platform(project(":spring-boot-project:spring-boot-parent"))) intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform")) + intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-loader-tools")) intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) intTestImplementation("org.apache.maven.shared:maven-invoker") intTestImplementation("org.assertj:assertj-core") diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java index b03f4afcb35..8e8bf1f3441 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/JarIntegrationTests.java @@ -16,10 +16,18 @@ package org.springframework.boot.maven; import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.jar.JarFile; +import java.util.stream.Collectors; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.loader.tools.FileUtils; +import org.springframework.util.FileSystemUtils; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -316,4 +324,35 @@ class JarIntegrationTests extends AbstractArchiveIntegrationTests { }); } + @TestTemplate + void whenJarIsRepackagedWithOutputTimestampConfiguredThenJarIsReproducible(MavenBuild mavenBuild) + throws InterruptedException { + String firstHash = buildJarWithOutputTimestamp(mavenBuild); + Thread.sleep(1500); + String secondHash = buildJarWithOutputTimestamp(mavenBuild); + assertThat(firstHash).isEqualTo(secondHash); + } + + private String buildJarWithOutputTimestamp(MavenBuild mavenBuild) { + AtomicReference jarHash = new AtomicReference<>(); + mavenBuild.project("jar-output-timestamp").execute((project) -> { + File repackaged = new File(project, "target/jar-output-timestamp-0.0.1.BUILD-SNAPSHOT.jar"); + assertThat(repackaged).isFile(); + assertThat(repackaged.lastModified()).isEqualTo(1584352800000L); + try (JarFile jar = new JarFile(repackaged)) { + List unreproducibleEntries = jar.stream() + .filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L) + .map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime()) + .collect(Collectors.toList()); + assertThat(unreproducibleEntries).isEmpty(); + jarHash.set(FileUtils.sha1Hash(repackaged)); + FileSystemUtils.deleteRecursively(project); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + }); + return jarHash.get(); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java index 63c89ac5389..38c9990a892 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/WarIntegrationTests.java @@ -17,10 +17,18 @@ package org.springframework.boot.maven; import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.jar.JarFile; +import java.util.stream.Collectors; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.loader.tools.FileUtils; +import org.springframework.util.FileSystemUtils; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -63,4 +71,35 @@ class WarIntegrationTests extends AbstractArchiveIntegrationTests { .hasEntryWithNameStartingWith("WEB-INF/lib/spring-jcl-")); } + @TestTemplate + void whenWarIsRepackagedWithOutputTimestampConfiguredThenWarIsReproducible(MavenBuild mavenBuild) + throws InterruptedException { + String firstHash = buildWarWithOutputTimestamp(mavenBuild); + Thread.sleep(1500); + String secondHash = buildWarWithOutputTimestamp(mavenBuild); + assertThat(firstHash).isEqualTo(secondHash); + } + + private String buildWarWithOutputTimestamp(MavenBuild mavenBuild) { + AtomicReference warHash = new AtomicReference<>(); + mavenBuild.project("war-output-timestamp").execute((project) -> { + File repackaged = new File(project, "target/war-output-timestamp-0.0.1.BUILD-SNAPSHOT.war"); + assertThat(repackaged).isFile(); + assertThat(repackaged.lastModified()).isEqualTo(1584352800000L); + try (JarFile jar = new JarFile(repackaged)) { + List unreproducibleEntries = jar.stream() + .filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L) + .map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime()) + .collect(Collectors.toList()); + assertThat(unreproducibleEntries).isEmpty(); + warHash.set(FileUtils.sha1Hash(repackaged)); + FileSystemUtils.deleteRecursively(project); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + }); + return warHash.get(); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/pom.xml new file mode 100644 index 00000000000..5e1fcf629f9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + org.springframework.boot.maven.it + jar-output-timestamp + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + 2020-03-16T02:00:00-08:00 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + repackage + + + + + + org.apache.maven.plugins + maven-jar-plugin + @maven-jar-plugin.version@ + + + + some.random.Main + + + Foo + + + + + + + + + org.springframework + spring-context + @spring-framework.version@ + + + jakarta.servlet + jakarta.servlet-api + @jakarta-servlet.version@ + provided + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/src/main/java/org/test/SampleApplication.java new file mode 100644 index 00000000000..ca2b9a2f0e5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/jar-output-timestamp/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.test; + +public class SampleApplication { + + public static void main(String[] args) { + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/pom.xml new file mode 100644 index 00000000000..ea6a9c9f54b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + org.springframework.boot.maven.it + war-output-timestamp + 0.0.1.BUILD-SNAPSHOT + war + + UTF-8 + 2020-03-16T02:00:00-08:00 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + repackage + + + + + + org.apache.maven.plugins + maven-war-plugin + @maven-war-plugin.version@ + + + + Foo + + + + + + + + + org.springframework + spring-context + @spring-framework.version@ + + + jakarta.servlet + jakarta.servlet-api + @jakarta-servlet.version@ + provided + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/java/org/test/SampleApplication.java new file mode 100644 index 00000000000..ca2b9a2f0e5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.test; + +public class SampleApplication { + + public static void main(String[] args) { + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/webapp/index.html b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/webapp/index.html new file mode 100644 index 00000000000..18ecdcb795c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/war-output-timestamp/src/main/webapp/index.html @@ -0,0 +1 @@ + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java index 2c417df2d2d..bd61c994bbb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RepackageMojo.java @@ -18,8 +18,11 @@ package org.springframework.boot.maven; import java.io.File; import java.io.IOException; +import java.nio.file.attribute.FileTime; +import java.time.OffsetDateTime; import java.util.List; import java.util.Properties; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import org.apache.maven.artifact.Artifact; @@ -140,6 +143,15 @@ public class RepackageMojo extends AbstractPackagerMojo { @Parameter private Properties embeddedLaunchScriptProperties; + /** + * Timestamp for reproducible output archive entries, either formatted as ISO 8601 + * (yyyy-MM-dd'T'HH:mm:ssXXX) or an {@code int} representing seconds + * since the epoch. + * @since 2.3.0 + */ + @Parameter(defaultValue = "${project.build.outputTimestamp}") + private String outputTimestamp; + @Override public void execute() throws MojoExecutionException, MojoFailureException { if (this.project.getPackaging().equals("pom")) { @@ -160,7 +172,7 @@ public class RepackageMojo extends AbstractPackagerMojo { Libraries libraries = getLibraries(this.requiresUnpack); try { LaunchScript launchScript = getLaunchScript(); - repackager.repackage(target, libraries, launchScript); + repackager.repackage(target, libraries, launchScript, parseOutputTimestamp()); } catch (IOException ex) { throw new MojoExecutionException(ex.getMessage(), ex); @@ -168,6 +180,22 @@ public class RepackageMojo extends AbstractPackagerMojo { updateArtifact(source, target, repackager.getBackupFile()); } + private FileTime parseOutputTimestamp() { + // Maven ignore a single-character timestamp as it is "useful to override a full + // value during pom inheritance" + if (this.outputTimestamp == null || this.outputTimestamp.length() < 2) { + return null; + } + long epochSeconds; + try { + epochSeconds = Long.parseLong(this.outputTimestamp); + } + catch (NumberFormatException ex) { + epochSeconds = OffsetDateTime.parse(this.outputTimestamp).toInstant().getEpochSecond(); + } + return FileTime.from(epochSeconds, TimeUnit.SECONDS); + } + /** * Return the source {@link Artifact} to repackage. If a classifier is specified and * an artifact with that classifier exists, it is used. Otherwise, the main artifact