You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/24 11:45:46 UTC
(maven-jar-plugin) branch master updated: fix: clamp pre-1980 outputTimestamp to DATE_MIN with a warning
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-jar-plugin.git
The following commit(s) were added to refs/heads/master by this push:
new 68d0b3f fix: clamp pre-1980 outputTimestamp to DATE_MIN with a warning
68d0b3f is described below
commit 68d0b3f8ee992e4968eecdc612d71cd053be1fa7
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Thu Sep 24 13:45:40 2026 +0200
fix: clamp pre-1980 outputTimestamp to DATE_MIN with a warning
SOURCE_DATE_EPOCH=0 (Unix epoch, 1970) is a widely-used reproducible-build
convention (Debian, many Apache projects). Since 4.0.0-beta-2 the plugin
delegates directly to the JDK jar tool, which rejects dates before 1980 with:
date 1970-01-01T00:00:00Z is not within the valid range 1980-01-01T00:00:02Z to 2099-12-31T23:59:59Z
This was silently accepted by maven-jar-plugin 3.3.x and is a regression.
- In getOutputTimestamp(), parse the resolved timestamp as an Instant and clamp
it to EPOCH_MIN (315532802L = 1980-01-01T00:00:02Z) when it falls below that
value, emitting a WARN-level message with the ISO minimum date string
- Values already within range, or unparseable strings passed verbatim to the
tool, are left unchanged
- EPOCH_MIN (long) avoids repeated Instant parsing; minValid pre-computes the
ISO string used in both the warning and the return value
- The minimum is T00:00:02Z not T00:00:00Z because the ZIP format uses MS-DOS
time (2-second granularity); T00:00:01Z is not representable (see JDK-8246129,
fixed in JDK 15)
- Add 10 test cases in AbstractJarMojoTest covering epoch=0, negative seconds,
ISO before 1980, ISO within range, exact EPOCH_MIN, modern timestamp,
unparseable string, null, and a cross-check that EPOCH_MIN equals the parsed
Instant epoch-second
Fixes #595.
---
.../apache/maven/plugins/jar/AbstractJarMojo.java | 45 +++-
.../maven/plugins/jar/AbstractJarMojoTest.java | 230 +++++++++++++++++++++
2 files changed, 273 insertions(+), 2 deletions(-)
diff --git a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java
index ea294d2..0db111d 100644
--- a/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java
+++ b/src/main/java/org/apache/maven/plugins/jar/AbstractJarMojo.java
@@ -50,6 +50,21 @@ import org.apache.maven.shared.archiver.MavenArchiver;
* @author Martin Desruisseaux
*/
public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Mojo {
+ /**
+ * Minimum Unix time (seconds since epoch) accepted by the {@code jar} tool.
+ * The {@code jar} tool enforces that all ZIP entry timestamps fall within the range
+ * {@code 1980-01-01T00:00:02Z} to {@code 2099-12-31T23:59:59Z}.
+ * The lower bound is {@code T00:00:02Z} rather than {@code T00:00:00Z} because
+ * {@code 1980-01-01T00:00:00Z} is a sentinel value ({@code DOSTIME_BEFORE_1980}) in the
+ * JDK ZIP implementation that caused extra timezone metadata to be written, breaking
+ * reproducibility (fixed in JDK 15, see JDK-8246129). The value {@code T00:00:01Z} is not
+ * representable in MS-DOS time (2-second granularity), so {@code T00:00:02Z} is the lowest
+ * safe value.
+ * A JUnit test verifies that this constant equals
+ * {@code Instant.parse("1980-01-01T00:00:02Z").getEpochSecond()}.
+ */
+ static final long EPOCH_MIN = 315532802L;
+
/**
* Identifier of the tool to use. This identifier must match the identifier of a tool
* registered as a {@link ToolProvider}. By default, the {@code "jar"} tool is used.
@@ -254,6 +269,14 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj
* Returns the output timestamp or, as a fallback, the {@code SOURCE_DATE_EPOCH} environment variable.
* If the timestamp is expressed in seconds, it is converted to ISO 8601 format. Otherwise it is returned as-is.
*
+ * <p>When the timestamp is given as a number of seconds and resolves to a date before {@code EPOCH_MIN}
+ * (1980-01-01T00:00:02Z — the minimum accepted by the {@code jar} tool; see {@link #EPOCH_MIN}),
+ * it is automatically clamped to that minimum and a warning is logged.
+ * This handles the common {@code SOURCE_DATE_EPOCH=0} convention used by Debian and other
+ * reproducible-build environments.</p>
+ *
+ * <p>ISO 8601 strings are returned as-is and validated by the {@code jar} tool directly.</p>
+ *
* @return the timestamp in presumed ISO 8601 format, or {@code null} if none
* @throws MojoException if the timestamp looks like a number of seconds but cannot be parsed as such
*
@@ -270,13 +293,31 @@ public abstract class AbstractJarMojo implements org.apache.maven.api.plugin.Moj
for (int i = time.length(); --i >= 0; ) {
char c = time.charAt(i);
if ((c < '0' || c > '9') && (i != 0 || c != '-')) {
+ // Not a plain integer — treat as ISO 8601 and pass through as-is.
return time;
}
}
+ // Plain integer: convert from seconds to ISO 8601, clamping to EPOCH_MIN if needed.
try {
- return Instant.ofEpochSecond(Long.parseLong(time)).toString();
+ long seconds = Long.parseLong(time);
+ if (seconds < EPOCH_MIN) {
+ String instant = Instant.ofEpochSecond(seconds).toString();
+ String minValid = Instant.ofEpochSecond(EPOCH_MIN).toString();
+ log.warn("Output timestamp \""
+ + time
+ + "\" (resolved to "
+ + instant
+ + ") is before the minimum value accepted by the jar tool ("
+ + minValid
+ + "). Clamping to minimum. "
+ + "If you use SOURCE_DATE_EPOCH=0, set it to at least "
+ + minValid
+ + '.');
+ return minValid;
+ }
+ return Instant.ofEpochSecond(seconds).toString();
} catch (NumberFormatException | DateTimeException e) {
- throw new MojoException("Timestamp \"" + time + "\" is not a number of seconds.", e);
+ throw new MojoException("Timestamp \"" + time + "\" is not a valid number of seconds.", e);
}
}
diff --git a/src/test/java/org/apache/maven/plugins/jar/AbstractJarMojoTest.java b/src/test/java/org/apache/maven/plugins/jar/AbstractJarMojoTest.java
new file mode 100644
index 0000000..c2c691d
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/jar/AbstractJarMojoTest.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.maven.plugins.jar;
+
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+import org.apache.maven.api.PathScope;
+import org.apache.maven.api.plugin.Log;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link AbstractJarMojo#getOutputTimestamp()}.
+ */
+class AbstractJarMojoTest {
+
+ private static class TestMojo extends AbstractJarMojo {
+ final List<String> warnings = new ArrayList<>();
+
+ TestMojo(String outputTimestamp) {
+ this.outputTimestamp = outputTimestamp;
+ this.log = new Log() {
+ @Override
+ public boolean isDebugEnabled() {
+ return false;
+ }
+
+ @Override
+ public void debug(CharSequence message) {}
+
+ @Override
+ public void debug(CharSequence message, Throwable throwable) {}
+
+ @Override
+ public void debug(Throwable throwable) {}
+
+ @Override
+ public void debug(Supplier<String> message) {}
+
+ @Override
+ public void debug(Supplier<String> message, Throwable throwable) {}
+
+ @Override
+ public boolean isInfoEnabled() {
+ return false;
+ }
+
+ @Override
+ public void info(CharSequence message) {}
+
+ @Override
+ public void info(CharSequence message, Throwable throwable) {}
+
+ @Override
+ public void info(Throwable throwable) {}
+
+ @Override
+ public void info(Supplier<String> message) {}
+
+ @Override
+ public void info(Supplier<String> message, Throwable throwable) {}
+
+ @Override
+ public boolean isWarnEnabled() {
+ return true;
+ }
+
+ @Override
+ public void warn(CharSequence message) {
+ warnings.add(message.toString());
+ }
+
+ @Override
+ public void warn(CharSequence message, Throwable throwable) {
+ warnings.add(message.toString());
+ }
+
+ @Override
+ public void warn(Throwable throwable) {}
+
+ @Override
+ public void warn(Supplier<String> message) {
+ warnings.add(message.get());
+ }
+
+ @Override
+ public void warn(Supplier<String> message, Throwable throwable) {
+ warnings.add(message.get());
+ }
+
+ @Override
+ public boolean isErrorEnabled() {
+ return false;
+ }
+
+ @Override
+ public void error(CharSequence message) {}
+
+ @Override
+ public void error(CharSequence message, Throwable throwable) {}
+
+ @Override
+ public void error(Throwable throwable) {}
+
+ @Override
+ public void error(Supplier<String> message) {}
+
+ @Override
+ public void error(Supplier<String> message, Throwable throwable) {}
+ };
+ }
+
+ @Override
+ protected Path getClassesDirectory() {
+ return null;
+ }
+
+ @Override
+ protected String getClassifier() {
+ return null;
+ }
+
+ @Override
+ protected String getType() {
+ return "jar";
+ }
+
+ @Override
+ protected PathScope getDependencyScope() {
+ return PathScope.MAIN_COMPILE;
+ }
+ }
+
+ /** No timestamp configured → null. */
+ @Test
+ void returnsNullWhenNotConfigured() {
+ assertNull(new TestMojo(null).getOutputTimestamp());
+ }
+
+ /** A valid ISO 8601 string is returned as-is (jar tool validates it). */
+ @Test
+ void validIso8601PassesThroughUnchanged() {
+ var mojo = new TestMojo("2023-06-15T12:00:00Z");
+ assertEquals("2023-06-15T12:00:00Z", mojo.getOutputTimestamp());
+ assertTrue(mojo.warnings.isEmpty());
+ }
+
+ /** An ISO 8601 string before 1980 is also returned as-is — jar tool is the validator. */
+ @Test
+ void iso8601BeforeMinPassesThroughUnchanged() {
+ var mojo = new TestMojo("1970-01-01T00:00:00Z");
+ assertEquals("1970-01-01T00:00:00Z", mojo.getOutputTimestamp());
+ assertTrue(mojo.warnings.isEmpty());
+ }
+
+ /** Verifies that EPOCH_MIN equals Instant.parse("1980-01-01T00:00:02Z").getEpochSecond(). */
+ @Test
+ void epochMinMatchesParsedInstant() {
+ assertEquals(Instant.parse("1980-01-01T00:00:02Z").getEpochSecond(), AbstractJarMojo.EPOCH_MIN);
+ }
+
+ /**
+ * SOURCE_DATE_EPOCH=0 (Unix epoch) as seconds → clamped to EPOCH_MIN with a warning.
+ * Primary regression scenario from issue #595.
+ */
+ @Test
+ void epochZeroSecondsClamped() {
+ var mojo = new TestMojo("0");
+ assertEquals(Instant.ofEpochSecond(AbstractJarMojo.EPOCH_MIN).toString(), mojo.getOutputTimestamp());
+ assertEquals(1, mojo.warnings.size());
+ assertTrue(mojo.warnings.get(0).contains("1980-01-01T00:00:02Z"));
+ }
+
+ /** Negative seconds (before Unix epoch) → clamped to EPOCH_MIN with a warning. */
+ @Test
+ void negativeEpochSecondsClamped() {
+ var mojo = new TestMojo("-1");
+ assertEquals(Instant.ofEpochSecond(AbstractJarMojo.EPOCH_MIN).toString(), mojo.getOutputTimestamp());
+ assertEquals(1, mojo.warnings.size());
+ }
+
+ /** Any seconds value before EPOCH_MIN → clamped. */
+ @Test
+ void epochSecondsBeforeMinClamped() {
+ // 315532801 = 1980-01-01T00:00:01Z, one second before EPOCH_MIN
+ var mojo = new TestMojo("315532801");
+ assertEquals(Instant.ofEpochSecond(AbstractJarMojo.EPOCH_MIN).toString(), mojo.getOutputTimestamp());
+ assertEquals(1, mojo.warnings.size());
+ }
+
+ /** Exactly EPOCH_MIN (315532802) → not clamped, no warning. */
+ @Test
+ void epochSecondsAtEpochMinNotClamped() {
+ var mojo = new TestMojo("315532802");
+ assertEquals(Instant.ofEpochSecond(AbstractJarMojo.EPOCH_MIN).toString(), mojo.getOutputTimestamp());
+ assertTrue(mojo.warnings.isEmpty());
+ }
+
+ /** A valid modern timestamp as seconds → converted to ISO 8601, no clamping. */
+ @Test
+ void validEpochSecondsConvertedToIso8601() {
+ // 2000-01-01T00:00:00Z = 946684800 seconds since epoch
+ var mojo = new TestMojo("946684800");
+ assertEquals("2000-01-01T00:00:00Z", mojo.getOutputTimestamp());
+ assertTrue(mojo.warnings.isEmpty());
+ }
+}