From 5b48eb30cc31b72c229edfbd3c770d1fbf951dfe Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 5 Jan 2026 12:15:11 -0800 Subject: [PATCH 1/2] Polish --- .../boot/jarmode/tools/ExtractCommand.java | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java index 68ae5f7cc64..28603c142e4 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.JarOutputStream; @@ -287,6 +288,26 @@ class ExtractCommand extends Command { } } + private static void mkdirs(File file) throws IOException { + if (!file.exists() && !file.mkdirs()) { + throw new IOException("Unable to create directory " + file); + } + } + + private static JarEntry createJarEntry(String location, JarEntry originalEntry) { + JarEntry entry = new JarEntry(location); + copyFileTime(getLastModifiedTime(originalEntry), entry::setLastModifiedTime); + copyFileTime(getLastAccessTime(originalEntry), entry::setLastAccessTime); + copyFileTime(getCreationTime(originalEntry), entry::setCreationTime); + return entry; + } + + private static void copyFileTime(FileTime fileTime, Consumer setter) { + if (fileTime != null) { + setter.accept(fileTime); + } + } + private static FileTime getCreationTime(JarEntry entry) { return (entry.getCreationTime() != null) ? entry.getCreationTime() : entry.getLastModifiedTime(); } @@ -299,29 +320,6 @@ class ExtractCommand extends Command { return (entry.getLastModifiedTime() != null) ? entry.getLastModifiedTime() : entry.getCreationTime(); } - private static void mkdirs(File file) throws IOException { - if (!file.exists() && !file.mkdirs()) { - throw new IOException("Unable to create directory " + file); - } - } - - private static JarEntry createJarEntry(String location, JarEntry originalEntry) { - JarEntry jarEntry = new JarEntry(location); - FileTime lastModifiedTime = getLastModifiedTime(originalEntry); - if (lastModifiedTime != null) { - jarEntry.setLastModifiedTime(lastModifiedTime); - } - FileTime lastAccessTime = getLastAccessTime(originalEntry); - if (lastAccessTime != null) { - jarEntry.setLastAccessTime(lastAccessTime); - } - FileTime creationTime = getCreationTime(originalEntry); - if (creationTime != null) { - jarEntry.setCreationTime(creationTime); - } - return jarEntry; - } - private static void withJarEntries(File file, ThrowingConsumer callback) throws IOException { try (JarFile jarFile = new JarFile(file)) { Enumeration entries = jarFile.entries(); From 54d7f79501edee63a1bb349fd77923a2d1d0f364 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 5 Jan 2026 12:44:51 -0800 Subject: [PATCH 2/2] Fix timestamps set on jars created by the extract command Update `ExtractCommand` so that both the generated jar and the contained manifest entry have timestamps copied from the original. Fixes gh-48664 --- .../boot/jarmode/tools/ExtractCommand.java | 45 +++++++++++-- .../jarmode/tools/AbstractJarModeTests.java | 39 ++++++++---- .../jarmode/tools/ExtractCommandTests.java | 63 +++++++++++++------ 3 files changed, 113 insertions(+), 34 deletions(-) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java index 28603c142e4..86ed936e390 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java @@ -16,6 +16,7 @@ package org.springframework.boot.jarmode.tools; +import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -26,6 +27,7 @@ import java.io.PrintStream; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; import java.util.EnumSet; import java.util.Enumeration; @@ -240,10 +242,17 @@ class ExtractCommand extends Command { String librariesDirectory = getLibrariesDirectory(options); Manifest manifest = jarStructure.createLauncherManifest((library) -> librariesDirectory + library); mkdirs(file.getParentFile()); - try (JarOutputStream output = new JarOutputStream(new FileOutputStream(file), manifest)) { + try (JarOutputStream output = new JarOutputStream(new FileOutputStream(file))) { + ManfiestWriter manfiestWriter = (sourceJarFile) -> { + JarEntry entry = createJarEntry(JarFile.MANIFEST_NAME, + sourceJarFile.getJarEntry(JarFile.MANIFEST_NAME)); + output.putNextEntry(entry); + manifest.write(new BufferedOutputStream(output)); + output.closeEntry(); + }; EnumSet allowedTypes = EnumSet.of(Type.APPLICATION_CLASS_OR_RESOURCE, Type.META_INF); Set writtenEntries = new HashSet<>(); - withJarEntries(this.context.getArchiveFile(), ((stream, jarEntry) -> { + withJarEntries(this.context.getArchiveFile(), manfiestWriter, ((stream, jarEntry) -> { Entry entry = jarStructure.resolve(jarEntry); if (entry != null && allowedTypes.contains(entry.type()) && StringUtils.hasLength(entry.location())) { JarEntry newJarEntry = createJarEntry(entry.location(), jarEntry); @@ -261,6 +270,15 @@ class ExtractCommand extends Command { } })); } + copyTimestamps(this.context.getArchiveFile(), file); + } + + private void copyTimestamps(File source, File destination) throws IOException { + BasicFileAttributes sourceAttributes = Files.getFileAttributeView(source.toPath(), BasicFileAttributeView.class) + .readAttributes(); + Files.getFileAttributeView(destination.toPath(), BasicFileAttributeView.class) + .setTimes(sourceAttributes.lastModifiedTime(), sourceAttributes.lastAccessTime(), + sourceAttributes.creationTime()); } private String getApplicationFilename(Map options) { @@ -296,9 +314,11 @@ class ExtractCommand extends Command { private static JarEntry createJarEntry(String location, JarEntry originalEntry) { JarEntry entry = new JarEntry(location); - copyFileTime(getLastModifiedTime(originalEntry), entry::setLastModifiedTime); - copyFileTime(getLastAccessTime(originalEntry), entry::setLastAccessTime); - copyFileTime(getCreationTime(originalEntry), entry::setCreationTime); + if (originalEntry != null) { + copyFileTime(getLastModifiedTime(originalEntry), entry::setLastModifiedTime); + copyFileTime(getLastAccessTime(originalEntry), entry::setLastAccessTime); + copyFileTime(getCreationTime(originalEntry), entry::setCreationTime); + } return entry; } @@ -321,7 +341,15 @@ class ExtractCommand extends Command { } private static void withJarEntries(File file, ThrowingConsumer callback) throws IOException { + withJarEntries(file, null, callback); + } + + private static void withJarEntries(File file, ManfiestWriter manfiestWriter, ThrowingConsumer callback) + throws IOException { try (JarFile jarFile = new JarFile(file)) { + if (manfiestWriter != null) { + manfiestWriter.writeManifest(jarFile); + } Enumeration entries = jarFile.entries(); while (entries.hasMoreElements()) { JarEntry entry = entries.nextElement(); @@ -350,6 +378,13 @@ class ExtractCommand extends Command { } + @FunctionalInterface + private interface ManfiestWriter { + + void writeManifest(JarFile sourceJarFile) throws IOException; + + } + @FunctionalInterface private interface ThrowingConsumer { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java index 12fd0c638c8..e517fe81bba 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java @@ -16,12 +16,14 @@ package org.springframework.boot.jarmode.tools; +import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.FileTime; import java.time.Instant; import java.util.ArrayDeque; @@ -78,18 +80,13 @@ abstract class AbstractJarModeTests { String... entries) throws IOException { Assert.state(entries.length % 2 == 0, "Entries must be key value pairs"); File file = new File(this.tempDir, "test.jar"); - try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file), manifest)) { + try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) { + ZipEntry manifestEntry = createEntry(JarFile.MANIFEST_NAME, creationTime, lastModifiedTime, lastAccessTime); + jar.putNextEntry(manifestEntry); + manifest.write(new BufferedOutputStream(jar)); + jar.closeEntry(); for (int i = 0; i < entries.length; i += 2) { - ZipEntry entry = new ZipEntry(entries[i]); - if (creationTime != null) { - entry.setCreationTime(FileTime.from(creationTime)); - } - if (lastModifiedTime != null) { - entry.setLastModifiedTime(FileTime.from(lastModifiedTime)); - } - if (lastAccessTime != null) { - entry.setLastAccessTime(FileTime.from(lastAccessTime)); - } + ZipEntry entry = createEntry(entries[i], creationTime, lastModifiedTime, lastAccessTime); jar.putNextEntry(entry); String resource = entries[i + 1]; if (resource != null) { @@ -101,9 +98,29 @@ abstract class AbstractJarModeTests { jar.closeEntry(); } } + Files.getFileAttributeView(file.toPath(), BasicFileAttributeView.class) + .setTimes(asFileTime(lastModifiedTime), asFileTime(lastAccessTime), asFileTime(creationTime)); return file; } + private FileTime asFileTime(Instant instant) { + return (instant != null) ? FileTime.from(instant) : null; + } + + private ZipEntry createEntry(String name, Instant creationTime, Instant lastModifiedTime, Instant lastAccessTime) { + ZipEntry entry = new ZipEntry(name); + if (creationTime != null) { + entry.setCreationTime(FileTime.from(creationTime)); + } + if (lastModifiedTime != null) { + entry.setLastModifiedTime(FileTime.from(lastModifiedTime)); + } + if (lastAccessTime != null) { + entry.setLastAccessTime(FileTime.from(lastAccessTime)); + } + return entry; + } + TestPrintStream runCommand(CommandFactory commandFactory, File archive, String... arguments) { Context context = new Context(archive, this.tempDir); Command command = commandFactory.create(context); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java index fd2c9d7e146..490ab506059 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java @@ -24,9 +24,12 @@ import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.BasicFileAttributes; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Enumeration; import java.util.List; import java.util.Map; import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -79,20 +82,6 @@ class ExtractCommandTests extends AbstractJarModeTests { return runCommand(ExtractCommand::new, archive, args); } - private void timeAttributes(File file) { - try { - BasicFileAttributes basicAttributes = Files - .getFileAttributeView(file.toPath(), BasicFileAttributeView.class) - .readAttributes(); - assertThat(basicAttributes.lastModifiedTime().toInstant().truncatedTo(ChronoUnit.SECONDS)) - .as("last modified time") - .isEqualTo(LAST_MODIFIED_TIME.truncatedTo(ChronoUnit.SECONDS)); - } - catch (IOException ex) { - throw new RuntimeException(ex); - } - } - @Nested class Extract { @@ -155,10 +144,48 @@ class ExtractCommandTests extends AbstractJarModeTests { @Test void appliesFileTimes() { run(ExtractCommandTests.this.archive); - assertThat(file("test/lib/dependency-1.jar")).exists().satisfies(ExtractCommandTests.this::timeAttributes); - assertThat(file("test/lib/dependency-2.jar")).exists().satisfies(ExtractCommandTests.this::timeAttributes); - assertThat(file("test/lib/dependency-3-SNAPSHOT.jar")).exists() - .satisfies(ExtractCommandTests.this::timeAttributes); + assertThat(file("test/lib/dependency-1.jar")).exists().satisfies(this::fileTimeAttributes); + assertThat(file("test/lib/dependency-2.jar")).exists().satisfies(this::fileTimeAttributes); + assertThat(file("test/lib/dependency-3-SNAPSHOT.jar")).exists().satisfies(this::fileTimeAttributes); + assertThat(file("test/test.jar")).exists() + .satisfies(this::fileTimeAttributes) + .satisfies(this::entryTimeAttributes); + } + + private void fileTimeAttributes(File file) { + try { + BasicFileAttributes basicAttributes = Files + .getFileAttributeView(file.toPath(), BasicFileAttributeView.class) + .readAttributes(); + assertThat(basicAttributes.lastModifiedTime().toInstant().truncatedTo(ChronoUnit.SECONDS)) + .as("last modified time") + .isEqualTo(LAST_MODIFIED_TIME.truncatedTo(ChronoUnit.SECONDS)); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + } + + private void entryTimeAttributes(File file) { + try { + try (ZipFile archiveZipFile = new ZipFile(ExtractCommandTests.this.archive)) { + try (ZipFile zipFile = new ZipFile(file)) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + ZipEntry archiveEntry = archiveZipFile.getEntry(entry.getName()); + if (archiveEntry != null) { + assertThat(entry.getLastModifiedTime()).isEqualTo(archiveEntry.getLastModifiedTime()); + assertThat(entry.getLastAccessTime()).isEqualTo(archiveEntry.getLastAccessTime()); + assertThat(entry.getCreationTime()).isEqualTo(archiveEntry.getCreationTime()); + } + } + } + } + } + catch (IOException ex) { + throw new RuntimeException(ex); + } } @Test