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
This commit is contained in:
Phillip Webb
2026-01-05 14:11:54 -08:00
parent 5b48eb30cc
commit 54d7f79501
3 changed files with 113 additions and 34 deletions
@@ -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<Type> allowedTypes = EnumSet.of(Type.APPLICATION_CLASS_OR_RESOURCE, Type.META_INF);
Set<String> 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<Option, String> 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<JarEntry> 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 {
@@ -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);
@@ -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<? extends ZipEntry> 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