From 69d34c96bf8ab3c1a57641982ed6b1a4dd2f2764 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Thu, 2 Mar 2023 12:03:16 -0800 Subject: [PATCH] Apply consistent timestamps to files added to a fat archive Update logic in `BootZipCopyAction` to align with the recent changes made in the Maven plugin (commit 998d59b7). Timestamps are now specified in UTC and offset against the default timezone before being written. 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. Closes gh-21005 --- .../tasks/bundling/BootZipCopyAction.java | 11 +-- .../tasks/bundling/DefaultTimeZoneOffset.java | 58 ++++++++++++++ .../tasks/bundling/LoaderZipEntries.java | 4 +- .../bundling/AbstractBootArchiveTests.java | 5 +- .../bundling/DefaultTimeZoneOffsetTests.java | 77 +++++++++++++++++++ 5 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DefaultTimeZoneOffset.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DefaultTimeZoneOffsetTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java index b9c7c59c486..335e0eb1223 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootZipCopyAction.java @@ -22,9 +22,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; -import java.util.Calendar; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.Collection; -import java.util.GregorianCalendar; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -67,8 +67,9 @@ import org.springframework.util.StringUtils; */ class BootZipCopyAction implements CopyAction { - static final long CONSTANT_TIME_FOR_ZIP_ENTRIES = new GregorianCalendar(1980, Calendar.FEBRUARY, 1, 0, 0, 0) - .getTimeInMillis(); + static final long CONSTANT_TIME_FOR_ZIP_ENTRIES = OffsetDateTime.of(1980, 2, 1, 0, 0, 0, 0, ZoneOffset.UTC) + .toInstant() + .toEpochMilli(); private final File output; @@ -364,7 +365,7 @@ class BootZipCopyAction implements CopyAction { writeParentDirectoriesIfNecessary(name, time); entry.setUnixMode(mode); if (time != null) { - entry.setTime(time); + entry.setTime(DefaultTimeZoneOffset.INSTANCE.removeFrom(time)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DefaultTimeZoneOffset.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/DefaultTimeZoneOffset.java new file mode 100644 index 00000000000..449c3399360 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/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.gradle.tasks.bundling; + +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-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java index 4fd6d854ff4..4c17cc1cbff 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/LoaderZipEntries.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 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. @@ -81,7 +81,7 @@ class LoaderZipEntries { private void prepareEntry(ZipArchiveEntry entry, int unixMode) { if (this.entryTime != null) { - entry.setTime(this.entryTime); + entry.setTime(DefaultTimeZoneOffset.INSTANCE.removeFrom(this.entryTime)); } entry.setUnixMode(unixMode); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java index b09ecb74b10..8ea9154f3a8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java @@ -351,18 +351,19 @@ abstract class AbstractBootArchiveTests { this.task.setPreserveFileTimestamps(false); executeTask(); assertThat(this.task.getArchiveFile().get().getAsFile()).exists(); + long expectedTime = DefaultTimeZoneOffset.INSTANCE.removeFrom(BootZipCopyAction.CONSTANT_TIME_FOR_ZIP_ENTRIES); try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) { Enumeration entries = jarFile.entries(); while (entries.hasMoreElements()) { JarEntry entry = entries.nextElement(); - assertThat(entry.getTime()).isEqualTo(BootZipCopyAction.CONSTANT_TIME_FOR_ZIP_ENTRIES); + assertThat(entry.getTime()).isEqualTo(expectedTime); } } } @Test void constantTimestampMatchesGradleInternalTimestamp() { - assertThat(BootZipCopyAction.CONSTANT_TIME_FOR_ZIP_ENTRIES) + assertThat(DefaultTimeZoneOffset.INSTANCE.removeFrom(BootZipCopyAction.CONSTANT_TIME_FOR_ZIP_ENTRIES)) .isEqualTo(ZipCopyAction.CONSTANT_TIME_FOR_ZIP_ENTRIES); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DefaultTimeZoneOffsetTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/DefaultTimeZoneOffsetTests.java new file mode 100644 index 00000000000..7bae541a1b6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/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.gradle.tasks.bundling; + +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-21005 + + @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); + } + +}