From 998d59b7ac1a75b26634e4fd2843a7833e554840 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Wed, 1 Mar 2023 19:28:40 -0800 Subject: [PATCH] Ignore system timezone when applying outputTimestamp to entries Update `JarWriter` so that entry times are set with the default TimeZone offset removed. The Javadoc for `ZipEntry.setTime` states: The file entry is "encoded in standard `MS-DOS date and time format`. The default TimeZone is used to convert the epoch time to the MS-DOS data and time. Removing the offset from our UTC time before calling `entry.setTime()` ensures that we get consistent bytes in the zip file when the output stream reapplies the offset during write. Fixes gh-34424 --- .../loader/tools/DefaultTimeZoneOffset.java | 58 ++++++++++++++ .../boot/loader/tools/JarWriter.java | 4 +- .../tools/DefaultTimeZoneOffsetTests.java | 77 +++++++++++++++++++ .../boot/loader/tools/RepackagerTests.java | 3 +- .../boot/maven/JarIntegrationTests.java | 7 +- .../boot/maven/WarIntegrationTests.java | 7 +- 6 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffset.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffsetTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffset.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffset.java new file mode 100644 index 000000000000..e7cc93ccbd8b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffset.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2023 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.springframework.boot.loader.tools; + +import java.nio.file.attribute.FileTime; +import java.util.TimeZone; +import java.util.zip.ZipEntry; + +/** + * Utility class that can be used change a UTC time based on the + * {@link java.util.TimeZone#getDefault() default TimeZone}. This is required because + * {@link ZipEntry#setTime(long)} expects times in the default timezone and not UTC. + * + * @author Phillip Webb + */ +class DefaultTimeZoneOffset { + + static final DefaultTimeZoneOffset INSTANCE = new DefaultTimeZoneOffset(TimeZone.getDefault()); + + private final TimeZone defaultTimeZone; + + DefaultTimeZoneOffset(TimeZone defaultTimeZone) { + this.defaultTimeZone = defaultTimeZone; + } + + /** + * Remove the default offset from the given time. + * @param time the time to remove the default offset from + * @return the time with the default offset removed + */ + FileTime removeFrom(FileTime time) { + return FileTime.fromMillis(removeFrom(time.toMillis())); + } + + /** + * Remove the default offset from the given time. + * @param time the time to remove the default offset from + * @return the time with the default offset removed + */ + long removeFrom(long time) { + return time - this.defaultTimeZone.getOffset(time); + } + +} 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 c45ee567aca7..4abfc2bb5b70 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2023 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. @@ -89,7 +89,7 @@ public JarWriter(File file, LaunchScript launchScript, FileTime lastModifiedTime protected void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException { JarArchiveEntry jarEntry = asJarArchiveEntry(entry); if (this.lastModifiedTime != null) { - jarEntry.setLastModifiedTime(this.lastModifiedTime); + jarEntry.setTime(DefaultTimeZoneOffset.INSTANCE.removeFrom(this.lastModifiedTime).toMillis()); } this.jarOutputStream.putArchiveEntry(jarEntry); if (entryWriter != null) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffsetTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffsetTests.java new file mode 100644 index 000000000000..de7d69207639 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/DefaultTimeZoneOffsetTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-2023 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.springframework.boot.loader.tools; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Calendar; +import java.util.TimeZone; + +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultTimeZoneOffset} + * + * @author Phillip Webb + */ +class DefaultTimeZoneOffsetTests { + + // gh-34424 + + @Test + void removeFromWithLongInDifferentTimeZonesReturnsSameValue() { + long time = OffsetDateTime.of(2000, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC).toInstant().toEpochMilli(); + TimeZone timeZone1 = TimeZone.getTimeZone("GMT"); + TimeZone timeZone2 = TimeZone.getTimeZone("GMT+8"); + TimeZone timeZone3 = TimeZone.getTimeZone("GMT-8"); + long result1 = new DefaultTimeZoneOffset(timeZone1).removeFrom(time); + long result2 = new DefaultTimeZoneOffset(timeZone2).removeFrom(time); + long result3 = new DefaultTimeZoneOffset(timeZone3).removeFrom(time); + long dosTime1 = toDosTime(Calendar.getInstance(timeZone1), result1); + long dosTime2 = toDosTime(Calendar.getInstance(timeZone2), result2); + long dosTime3 = toDosTime(Calendar.getInstance(timeZone3), result3); + assertThat(dosTime1).isEqualTo(dosTime2).isEqualTo(dosTime3); + } + + @Test + void removeFromWithFileTimeReturnsFileTime() { + long time = OffsetDateTime.of(2000, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC).toInstant().toEpochMilli(); + long result = new DefaultTimeZoneOffset(TimeZone.getTimeZone("GMT+8")).removeFrom(time); + assertThat(result).isNotEqualTo(time).isEqualTo(946656000000L); + } + + /** + * Identical functionality to package-private + * org.apache.commons.compress.archivers.zip.ZipUtil.toDosTime(Calendar, long, byte[], + * int) method used by {@link ZipArchiveOutputStream} to convert times. + * @param calendar the source calendar + * @param time the time to convert + * @return the DOS time + */ + private long toDosTime(Calendar calendar, long time) { + calendar.setTimeInMillis(time); + final int year = calendar.get(Calendar.YEAR); + final int month = calendar.get(Calendar.MONTH) + 1; + return ((year - 1980) << 25) | (month << 21) | (calendar.get(Calendar.DAY_OF_MONTH) << 16) + | (calendar.get(Calendar.HOUR_OF_DAY) << 11) | (calendar.get(Calendar.MINUTE) << 5) + | (calendar.get(Calendar.SECOND) >> 1); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java index 0912ffca209a..a4a648c34fb9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java @@ -200,8 +200,9 @@ void allEntriesUseProvidedTimestamp() throws IOException { Repackager repackager = createRepackager(this.testJarFile.getFile(), true); long timestamp = OffsetDateTime.of(2000, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC).toInstant().toEpochMilli(); repackager.repackage(this.destination, NO_LIBRARIES, null, FileTime.fromMillis(timestamp)); + long offsetTimestamp = DefaultTimeZoneOffset.INSTANCE.removeFrom(timestamp); for (ZipArchiveEntry entry : getAllPackagedEntries()) { - assertThat(entry.getTime()).isEqualTo(timestamp); + assertThat(entry.getTime()).isEqualTo(offsetTimestamp); } } 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 3f959aa35efb..20cf13f78bc7 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 @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.TimeZone; import java.util.concurrent.atomic.AtomicReference; import java.util.jar.JarFile; import java.util.stream.Collectors; @@ -401,10 +402,12 @@ private String buildJarWithOutputTimestamp(MavenBuild mavenBuild) { 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); + long expectedModified = 1584352800000L; + long offsetExpectedModified = expectedModified - TimeZone.getDefault().getOffset(expectedModified); + assertThat(repackaged.lastModified()).isEqualTo(expectedModified); try (JarFile jar = new JarFile(repackaged)) { List unreproducibleEntries = jar.stream() - .filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L) + .filter((entry) -> entry.getLastModifiedTime().toMillis() != offsetExpectedModified) .map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime()) .collect(Collectors.toList()); assertThat(unreproducibleEntries).isEmpty(); 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 c3d2af0f83cc..69e291860a3a 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 @@ -22,6 +22,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.TimeZone; import java.util.concurrent.atomic.AtomicReference; import java.util.jar.JarFile; import java.util.stream.Collectors; @@ -96,10 +97,12 @@ private String buildWarWithOutputTimestamp(MavenBuild mavenBuild) { 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); + long expectedModified = 1584352800000L; + assertThat(repackaged.lastModified()).isEqualTo(expectedModified); + long offsetExpectedModified = expectedModified - TimeZone.getDefault().getOffset(expectedModified); try (JarFile jar = new JarFile(repackaged)) { List unreproducibleEntries = jar.stream() - .filter((entry) -> entry.getLastModifiedTime().toMillis() != 1584352800000L) + .filter((entry) -> entry.getLastModifiedTime().toMillis() != offsetExpectedModified) .map((entry) -> entry.getName() + ": " + entry.getLastModifiedTime()) .collect(Collectors.toList()); assertThat(unreproducibleEntries).isEmpty();