diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java index 7f5edd7476..09fb3b9df6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java @@ -24,6 +24,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.TreeMap; import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.jar.Attributes; @@ -192,8 +193,13 @@ public abstract class Packager { } protected final void write(JarFile sourceJar, Libraries libraries, AbstractJarWriter writer) throws IOException { + write(sourceJar, libraries, writer, false); + } + + protected final void write(JarFile sourceJar, Libraries libraries, AbstractJarWriter writer, + boolean ensureReproducibleBuild) throws IOException { Assert.notNull(libraries, "Libraries must not be null"); - write(sourceJar, writer, new PackagedLibraries(libraries)); + write(sourceJar, writer, new PackagedLibraries(libraries, ensureReproducibleBuild)); } private void write(JarFile sourceJar, AbstractJarWriter writer, PackagedLibraries libraries) throws IOException { @@ -454,18 +460,18 @@ public abstract class Packager { } /** - * An {@link UnpackHandler} that determines that an entry needs to be unpacked if a - * library that requires unpacking has a matching entry name. + * Libraries that should be packaged into the archive. */ private final class PackagedLibraries { - private final Map libraries = new LinkedHashMap<>(); + private final Map libraries; private final UnpackHandler unpackHandler; private final Function libraryLookup; - PackagedLibraries(Libraries libraries) throws IOException { + PackagedLibraries(Libraries libraries, boolean ensureReproducibleBuild) throws IOException { + this.libraries = (ensureReproducibleBuild) ? new TreeMap<>() : new LinkedHashMap<>(); libraries.doWithLibraries((library) -> { if (isZip(library::openStream)) { addLibrary(library); @@ -521,6 +527,10 @@ public abstract class Packager { writer.writeIndexFile(layout.getClasspathIndexFileLocation(), names); } + /** + * An {@link UnpackHandler} that determines that an entry needs to be unpacked if + * a library that requires unpacking has a matching entry name. + */ private class PackagedLibrariesUnpackHandler implements UnpackHandler { @Override 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 e5d66796ac..329b7ce90c 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 @@ -141,7 +141,7 @@ public class Repackager extends Packager { 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); + write(sourceJar, libraries, writer, lastModifiedTime != null); } if (lastModifiedTime != null) { destination.setLastModified(lastModifiedTime.toMillis()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AbstractArchiveIntegrationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AbstractArchiveIntegrationTests.java index 05cc9a7764..5232a76a15 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AbstractArchiveIntegrationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AbstractArchiveIntegrationTests.java @@ -30,11 +30,13 @@ import java.util.function.Consumer; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.Manifest; +import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.zip.ZipEntry; import org.assertj.core.api.AbstractAssert; import org.assertj.core.api.AssertProvider; +import org.assertj.core.api.ListAssert; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.contentOf; @@ -43,6 +45,7 @@ import static org.assertj.core.api.Assertions.contentOf; * Base class for archive (jar or war) related Maven plugin integration tests. * * @author Andy Wilkinson + * @author Scott Frederick */ abstract class AbstractArchiveIntegrationTests { @@ -155,6 +158,15 @@ abstract class AbstractArchiveIntegrationTests { return this; } + ListAssert entryNamesInPath(String path) { + List matches = new ArrayList<>(); + withJarFile((jarFile) -> withEntries(jarFile, + (entries) -> matches.addAll(entries.map(ZipEntry::getName) + .filter((name) -> name.startsWith(path) && name.length() > path.length()) + .collect(Collectors.toList())))); + return new ListAssert<>(matches); + } + JarAssert manifest(Consumer consumer) { withJarFile((jarFile) -> { try { 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 c85ecc9c88..a6e4b3157d 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 @@ -18,6 +18,7 @@ package org.springframework.boot.maven; import java.io.File; import java.io.IOException; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -38,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Andy Wilkinson * @author Madhura Bhave + * @author Scott Frederick */ @ExtendWith(MavenBuildExtension.class) class JarIntegrationTests extends AbstractArchiveIntegrationTests { @@ -394,4 +396,31 @@ class JarIntegrationTests extends AbstractArchiveIntegrationTests { return jarHash.get(); } + @TestTemplate + void whenJarIsRepackagedWithDefaultsThenLibrariesAreNotSorted(MavenBuild mavenBuild) throws InterruptedException { + mavenBuild.project("jar").execute((project) -> { + File repackaged = new File(project, "target/jar-0.0.1.BUILD-SNAPSHOT.jar"); + List unsortedLibs = Arrays.asList("BOOT-INF/lib/spring-context", "BOOT-INF/lib/spring-aop", + "BOOT-INF/lib/spring-beans", "BOOT-INF/lib/spring-core", "BOOT-INF/lib/spring-jcl", + "BOOT-INF/lib/spring-expression", "BOOT-INF/lib/jakarta.servlet-api", + "BOOT-INF/lib/spring-boot-jarmode-layertools"); + assertThat(jar(repackaged)).entryNamesInPath("BOOT-INF/lib/").zipSatisfy(unsortedLibs, + (String jarLib, String expectedLib) -> assertThat(jarLib).startsWith(expectedLib)); + }); + } + + @TestTemplate + void whenJarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(MavenBuild mavenBuild) + throws InterruptedException { + mavenBuild.project("jar-output-timestamp").execute((project) -> { + File repackaged = new File(project, "target/jar-output-timestamp-0.0.1.BUILD-SNAPSHOT.jar"); + List sortedLibs = Arrays.asList("BOOT-INF/lib/jakarta.servlet-api", "BOOT-INF/lib/spring-aop", + "BOOT-INF/lib/spring-beans", "BOOT-INF/lib/spring-boot-jarmode-layertools", + "BOOT-INF/lib/spring-context", "BOOT-INF/lib/spring-core", "BOOT-INF/lib/spring-expression", + "BOOT-INF/lib/spring-jcl"); + assertThat(jar(repackaged)).entryNamesInPath("BOOT-INF/lib/").zipSatisfy(sortedLibs, + (String jarLib, String expectedLib) -> assertThat(jarLib).startsWith(expectedLib)); + }); + } + } 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 6615cd1567..e32b6f58d0 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 @@ -19,6 +19,7 @@ package org.springframework.boot.maven; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -38,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; * Integration tests for the Maven plugin's war support. * * @author Andy Wilkinson + * @author Scott Frederick */ @ExtendWith(MavenBuildExtension.class) class WarIntegrationTests extends AbstractArchiveIntegrationTests { @@ -110,6 +112,36 @@ class WarIntegrationTests extends AbstractArchiveIntegrationTests { return warHash.get(); } + @TestTemplate + void whenWarIsRepackagedWithDefaultsThenLibrariesAreNotSorted(MavenBuild mavenBuild) throws InterruptedException { + mavenBuild.project("war").execute((project) -> { + File repackaged = new File(project, "target/war-0.0.1.BUILD-SNAPSHOT.war"); + List unsortedLibs = Arrays.asList("WEB-INF/lib/spring-aop", "WEB-INF/lib/spring-beans", + "WEB-INF/lib/spring-expression", "WEB-INF/lib/spring-context", "WEB-INF/lib/spring-core", + "WEB-INF/lib/spring-jcl", "WEB-INF/lib/spring-boot-jarmode-layertools"); + assertThat(jar(repackaged)).entryNamesInPath("WEB-INF/lib/").zipSatisfy(unsortedLibs, + (String jarLib, String expectedLib) -> assertThat(jarLib).startsWith(expectedLib)); + }); + } + + @TestTemplate + void whenWarIsRepackagedWithOutputTimestampConfiguredThenLibrariesAreSorted(MavenBuild mavenBuild) + throws InterruptedException { + mavenBuild.project("war-output-timestamp").execute((project) -> { + File repackaged = new File(project, "target/war-output-timestamp-0.0.1.BUILD-SNAPSHOT.war"); + List sortedLibs = Arrays.asList( + // these libraries are copied from the original war, sorted when + // packaged by Maven + "WEB-INF/lib/spring-aop", "WEB-INF/lib/spring-beans", "WEB-INF/lib/spring-context", + "WEB-INF/lib/spring-core", "WEB-INF/lib/spring-expression", "WEB-INF/lib/spring-jcl", + // these libraries are contributed by Spring Boot repackaging, and + // sorted separately + "WEB-INF/lib/spring-boot-jarmode-layertools"); + assertThat(jar(repackaged)).entryNamesInPath("WEB-INF/lib/").zipSatisfy(sortedLibs, + (String jarLib, String expectedLib) -> assertThat(jarLib).startsWith(expectedLib)); + }); + } + @TestTemplate void whenADependencyHasSystemScopeAndInclusionOfSystemScopeDependenciesIsEnabledItIsIncludedInTheRepackagedJar( MavenBuild mavenBuild) {