[9.3.0] Record PackageZipper timestamps in the local time zone (from #30592) (#31180) ### Description `PackageZipper` stamps every entry with a fixed instant of 1980-01-01 00:00:00 UTC, but DOS timestamps carry no time zone. The recorded date therefore depends on the zone of the machine that runs the tool. Behind UTC, that instant falls before the DOS epoch and is not representable at all, which also affects Bazel's own build: ``` ERROR: .../src/BUILD:293:13: Building src/package_jdk_minimal.zip failed: (Exit 1): package_zipper failed Exception in thread "main" java.lang.IllegalArgumentException: 1979-12-31 16:00:00 is not representable in the DOS time format. It must be in the range 1980-01-01 00:00:00 to 2107-12-31 23:59:59 at com.google.devtools.build.zip.ZipUtil.unixToDosTime(ZipUtil.java:168) at com.google.devtools.build.zip.LocalFileHeader.create(LocalFileHeader.java:96) at com.google.devtools.build.zip.PackageZipper.writeBytesEntry(PackageZipper.java:212) ``` Ahead of UTC the instant is representable, but records a later time of day: in `Europe/Berlin` the packed field is `0x00210800` (01:00:00) where a UTC machine writes `0x00210000` (00:00:00). The archive bytes differ by builder time zone, which defeats hermeticity. Fixes #31165. ### Motivation ### Build API Changes No ### Checklist - [x] I have added tests for the new use cases (if any). - [ ] I have updated the documentation (if applicable). ### Release Notes RELNOTES: None Closes #30592. PiperOrigin-RevId: 959815070 Change-Id: Ib95a2cd9a0cbb40f1cc0a6264de3621e42045e2f <!-- Thank you for contributing to Bazel! Please read the contribution guidelines: https://bazel.build/contribute --> ### Description <!-- Please provide a brief summary of the changes in this PR. --> ### Motivation <!-- Why is this change important? Does it fix a specific bug or add a new feature? If this PR fixes an existing issue, please link it here (e.g. "Fixes #1234"). --> ### Build API Changes <!-- Does this PR affect the Build API? (e.g. Starlark API, providers, command-line flags, native rules) If yes, please answer the following: 1. Has this been discussed in a design doc or issue? (Please link it) 2. Is the change backward compatible? 3. If it's a breaking change, what is the migration plan? --> No ### Checklist - [ ] I have added tests for the new use cases (if any). - [ ] I have updated the documentation (if applicable). ### Release Notes <!-- If this is a new feature, please add 'RELNOTES[NEW]: <description>' here. If this is a breaking change, please add 'RELNOTES[INC]: <reason>' here. If this change should be mentioned in release notes, please add 'RELNOTES: <reason>' here. --> RELNOTES: None Co-authored-by: Fabian Meumertzheim <fabian@meumertzhe.im>
diff --git a/src/java_tools/singlejar/java/com/google/devtools/build/zip/PackageZipper.java b/src/java_tools/singlejar/java/com/google/devtools/build/zip/PackageZipper.java index b43a459..165a87e 100644 --- a/src/java_tools/singlejar/java/com/google/devtools/build/zip/PackageZipper.java +++ b/src/java_tools/singlejar/java/com/google/devtools/build/zip/PackageZipper.java
@@ -194,7 +194,9 @@ byte[] compressedBytes = deflate(rawBytes, compressionLevel); ZipFileEntry entry = new ZipFileEntry(entryName); - entry.setTime(315532800000L); // 1980-01-01 00:00:00 UTC + // DOS timestamps carry no time zone and are always interpreted in the local one, so the + // instant has to be derived from the local zone to record the same date everywhere. + entry.setTime(ZipUtil.DOS_EPOCH); entry.setVersion((short) 20); if (compressedBytes.length >= rawBytes.length && rawBytes.length > 0) {
diff --git a/src/java_tools/singlejar/javatests/com/google/devtools/build/zip/BUILD b/src/java_tools/singlejar/javatests/com/google/devtools/build/zip/BUILD index 95e406e..49decb4 100644 --- a/src/java_tools/singlejar/javatests/com/google/devtools/build/zip/BUILD +++ b/src/java_tools/singlejar/javatests/com/google/devtools/build/zip/BUILD
@@ -26,6 +26,23 @@ runtime_deps = [":zip_tests"], ) +# DOS timestamps are always interpreted in the local time zone, so packaging is exercised on both +# sides of UTC: behind it, an instant pinned to UTC midnight falls before the DOS epoch and is not +# representable at all, ahead of it, it records a later time of day than on a UTC machine. +java_test( + name = "PackageZipperTest_behind_utc", + env = {"TZ": "America/Los_Angeles"}, + test_class = "com.google.devtools.build.zip.PackageZipperTest", + runtime_deps = [":zip_tests"], +) + +java_test( + name = "PackageZipperTest_ahead_of_utc", + env = {"TZ": "Europe/Berlin"}, + test_class = "com.google.devtools.build.zip.PackageZipperTest", + runtime_deps = [":zip_tests"], +) + filegroup( name = "srcs", srcs = glob(["*.java"]) + ["BUILD"],
diff --git a/src/java_tools/singlejar/javatests/com/google/devtools/build/zip/PackageZipperTest.java b/src/java_tools/singlejar/javatests/com/google/devtools/build/zip/PackageZipperTest.java new file mode 100644 index 0000000..926c1c7 --- /dev/null +++ b/src/java_tools/singlejar/javatests/com/google/devtools/build/zip/PackageZipperTest.java
@@ -0,0 +1,90 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// 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 +// +// http://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 com.google.devtools.build.zip; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link PackageZipper}. */ +@RunWith(JUnit4.class) +public class PackageZipperTest { + + /** 1980-01-01 00:00:00 packed into the DOS date and time fields. */ + private static final int DOS_1980_01_01 = 0x00210000; + + /** Offset of the modification time field in a local file header. */ + private static final int LOCAL_HEADER_MOD_TIME_OFFSET = 10; + + private Path tmpDir; + private Path outputZip; + private Path serverJar; + private Path installBaseKey; + + @Before + public void setUpInputs() throws IOException { + tmpDir = Files.createTempDirectory("package_zipper_test"); + outputZip = tmpDir.resolve("package.zip"); + serverJar = tmpDir.resolve("A-server.jar"); + installBaseKey = tmpDir.resolve("install_base_key"); + + try (OutputStream out = Files.newOutputStream(serverJar); + ZipOutputStream jar = new ZipOutputStream(out)) { + jar.putNextEntry(new ZipEntry("build-data.properties")); + jar.write("build.label=1.2.3\n".getBytes(UTF_8)); + jar.closeEntry(); + } + Files.write(installBaseKey, "0123456789abcdef".getBytes(UTF_8)); + } + + @Test + public void writesEntriesWithLocalDosEpochTimestamp() throws IOException { + Path file = Files.write(tmpDir.resolve("foo.txt"), "foo".getBytes(UTF_8)); + + PackageZipper.main( + new String[] { + outputZip.toString(), serverJar.toString(), installBaseKey.toString(), file.toString() + }); + + try (ZipFile zip = new ZipFile(outputZip.toFile(), UTF_8)) { + List<String> names = new ArrayList<>(); + for (ZipEntry entry : Collections.list(zip.entries())) { + names.add(entry.getName()); + // The recorded date is the same in every time zone, so the archive bytes are as well. + assertThat(ZipUtil.unixToDosTime(entry.getTime())).isEqualTo(DOS_1980_01_01); + } + assertThat(names) + .containsExactly("A-server.jar", "build-label.txt", "foo.txt", "install_base_key"); + } + + // The first local file header starts at offset 0. + byte[] archive = Files.readAllBytes(outputZip); + assertThat(ZipUtil.get32(archive, LOCAL_HEADER_MOD_TIME_OFFSET)).isEqualTo(DOS_1980_01_01); + } +}