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());
+    }
+}