[MSHARED-1067] Improve Reproducible Builds methods
Signed-off-by: Jorge Solórzano <jorsol@gmail.com>
This closes #22
diff --git a/src/main/java/org/apache/maven/archiver/MavenArchiver.java b/src/main/java/org/apache/maven/archiver/MavenArchiver.java
index 2f7fae6..5242da7 100644
--- a/src/main/java/org/apache/maven/archiver/MavenArchiver.java
+++ b/src/main/java/org/apache/maven/archiver/MavenArchiver.java
@@ -24,14 +24,18 @@
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.jar.Attributes;
@@ -97,6 +101,10 @@
"${artifact.groupIdPath}/${artifact.artifactId}/" + "${artifact.baseVersion}/${artifact.artifactId}-"
+ "${artifact.baseVersion}${dashClassifier?}.${artifact.extension}";
+ private static final Instant DATE_MIN = Instant.parse( "1980-01-01T00:00:02Z" );
+
+ private static final Instant DATE_MAX = Instant.parse( "2099-12-31T23:59:59Z" );
+
private static final List<String> ARTIFACT_EXPRESSION_PREFIXES;
static
@@ -812,28 +820,80 @@
* @return the parsed timestamp, may be <code>null</code> if <code>null</code> input or input contains only 1
* character
* @since 3.5.0
- * @throws java.lang.IllegalArgumentException if the outputTimestamp is neither ISO 8601 nor an integer
+ * @throws IllegalArgumentException if the outputTimestamp is neither ISO 8601 nor an integer, or it's not within
+ * the valid range 1980-01-01T00:00:02Z to 2099-12-31T23:59:59Z
+ * @deprecated Use {@link #parseBuildOutputTimestamp(String)} instead.
*/
+ @Deprecated
public Date parseOutputTimestamp( String outputTimestamp )
{
- if ( StringUtils.isNumeric( outputTimestamp ) && StringUtils.isNotEmpty( outputTimestamp ) )
+ return parseBuildOutputTimestamp( outputTimestamp ).map( Date::from ).orElse( null );
+ }
+
+ /**
+ * Configure Reproducible Builds archive creation if a timestamp is provided.
+ *
+ * @param outputTimestamp the value of {@code ${project.build.outputTimestamp}} (may be {@code null})
+ * @return the parsed timestamp as {@link java.util.Date}
+ * @since 3.5.0
+ * @see #parseOutputTimestamp
+ * @deprecated Use {@link #configureReproducibleBuild(String)} instead.
+ */
+ @Deprecated
+ public Date configureReproducible( String outputTimestamp )
+ {
+ configureReproducibleBuild( outputTimestamp );
+ return parseOutputTimestamp( outputTimestamp );
+ }
+
+ /**
+ * Parse output timestamp configured for Reproducible Builds' archive entries.
+ *
+ * <p>Either as {@link java.time.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME} or as a number representing seconds
+ * since the epoch (like <a href="https://reproducible-builds.org/docs/source-date-epoch/">SOURCE_DATE_EPOCH</a>).
+ *
+ * @param outputTimestamp the value of {@code ${project.build.outputTimestamp}} (may be {@code null})
+ * @return the parsed timestamp as an {@code Optional<Instant>}, {@code empty} if input is {@code null} or input
+ * contains only 1 character (not a number)
+ * @since 3.6.0
+ * @throws IllegalArgumentException if the outputTimestamp is neither ISO 8601 nor an integer, or it's not within
+ * the valid range 1980-01-01T00:00:02Z to 2099-12-31T23:59:59Z
+ */
+ public static Optional<Instant> parseBuildOutputTimestamp( String outputTimestamp )
+ {
+ // Fail-fast on nulls
+ if ( outputTimestamp == null )
{
- return new Date( Long.parseLong( outputTimestamp ) * 1000 );
+ return Optional.empty();
}
- if ( outputTimestamp == null || outputTimestamp.length() < 2 )
+ // Number representing seconds since the epoch
+ if ( StringUtils.isNotEmpty( outputTimestamp ) && StringUtils.isNumeric( outputTimestamp ) )
{
- // no timestamp configured (1 character configuration is useful to override a full value during pom
- // inheritance)
- return null;
+ return Optional.of( Instant.ofEpochSecond( Long.parseLong( outputTimestamp ) ) );
}
- DateFormat df = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ssXXX" );
+ // no timestamp configured (1 character configuration is useful to override a full value during pom
+ // inheritance)
+ if ( outputTimestamp.length() < 2 )
+ {
+ return Optional.empty();
+ }
+
try
{
- return df.parse( outputTimestamp );
+ // Parse the date in UTC such as '2011-12-03T10:15:30Z' or with an offset '2019-10-05T20:37:42+06:00'.
+ final Instant date = OffsetDateTime.parse( outputTimestamp )
+ .withOffsetSameInstant( ZoneOffset.UTC ).truncatedTo( ChronoUnit.SECONDS ).toInstant();
+
+ if ( date.isBefore( DATE_MIN ) || date.isAfter( DATE_MAX ) )
+ {
+ throw new IllegalArgumentException( "'" + date + "' is not within the valid range "
+ + DATE_MIN + " to " + DATE_MAX );
+ }
+ return Optional.of( date );
}
- catch ( ParseException pe )
+ catch ( DateTimeParseException pe )
{
throw new IllegalArgumentException( "Invalid project.build.outputTimestamp value '" + outputTimestamp + "'",
pe );
@@ -843,18 +903,14 @@
/**
* Configure Reproducible Builds archive creation if a timestamp is provided.
*
- * @param outputTimestamp the value of <code>${project.build.outputTimestamp}</code> (may be <code>null</code>)
- * @return the parsed timestamp
- * @since 3.5.0
- * @see #parseOutputTimestamp
+ * @param outputTimestamp the value of {@code project.build.outputTimestamp} (may be {@code null})
+ * @since 3.6.0
+ * @see #parseBuildOutputTimestamp(String)
*/
- public Date configureReproducible( String outputTimestamp )
+ public void configureReproducibleBuild( String outputTimestamp )
{
- Date outputDate = parseOutputTimestamp( outputTimestamp );
- if ( outputDate != null )
- {
- getArchiver().configureReproducible( outputDate );
- }
- return outputDate;
+ parseBuildOutputTimestamp( outputTimestamp )
+ .map( FileTime::from )
+ .ifPresent( modifiedTime -> getArchiver().configureReproducibleBuild( modifiedTime ) );
}
}
diff --git a/src/test/java/org/apache/maven/archiver/MavenArchiverTest.java b/src/test/java/org/apache/maven/archiver/MavenArchiverTest.java
index 1605f6a..728c8b7 100644
--- a/src/test/java/org/apache/maven/archiver/MavenArchiverTest.java
+++ b/src/test/java/org/apache/maven/archiver/MavenArchiverTest.java
@@ -39,12 +39,21 @@
import org.eclipse.aether.DefaultRepositorySystemSession;
import org.eclipse.aether.RepositorySystemSession;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.EmptySource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -61,7 +70,7 @@
import java.util.zip.ZipEntry;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
class MavenArchiverTest
{
@@ -79,28 +88,21 @@
}
}
- @Test
- void testInvalidModuleNames()
+ @ParameterizedTest
+ @EmptySource
+ @ValueSource( strings = { ".", "dash-is-invalid", "plus+is+invalid", "colon:is:invalid", "new.class",
+ "123.at.start.is.invalid", "digit.at.123start.is.invalid" } )
+ void testInvalidModuleNames( String value )
{
- assertThat( MavenArchiver.isValidModuleName( "" ) ).isFalse();
- assertThat( MavenArchiver.isValidModuleName( "." ) ).isFalse();
- assertThat( MavenArchiver.isValidModuleName( "dash-is-invalid" ) ).isFalse();
- assertThat( MavenArchiver.isValidModuleName( "plus+is+invalid" ) ).isFalse();
- assertThat( MavenArchiver.isValidModuleName( "colon:is:invalid" ) ).isFalse();
- assertThat( MavenArchiver.isValidModuleName( "new.class" ) ).isFalse();
- assertThat( MavenArchiver.isValidModuleName( "123.at.start.is.invalid" ) ).isFalse();
- assertThat( MavenArchiver.isValidModuleName( "digit.at.123start.is.invalid" ) ).isFalse();
+ assertThat( MavenArchiver.isValidModuleName( value ) ).isFalse();
}
- @Test
- void testValidModuleNames()
+ @ParameterizedTest
+ @ValueSource( strings = { "a", "a.b", "a_b", "trailing0.digits123.are456.ok789", "UTF8.chars.are.okay.äëïöüẍ",
+ "ℤ€ℕ" } )
+ void testValidModuleNames( String value )
{
- assertThat( MavenArchiver.isValidModuleName( "a" ) ).isTrue();
- assertThat( MavenArchiver.isValidModuleName( "a.b" ) ).isTrue();
- assertThat( MavenArchiver.isValidModuleName( "a_b" ) ).isTrue();
- assertThat( MavenArchiver.isValidModuleName( "trailing0.digits123.are456.ok789" ) ).isTrue();
- assertThat( MavenArchiver.isValidModuleName( "UTF8.chars.are.okay.äëïöüẍ" ) ).isTrue();
- assertThat( MavenArchiver.isValidModuleName( "ℤ€ℕ" ) ).isTrue();
+ assertThat( MavenArchiver.isValidModuleName( value ) ).isTrue();
}
@Test
@@ -1366,7 +1368,8 @@
URL resource = Thread.currentThread().getContextClassLoader().getResource( file );
if ( resource == null )
{
- fail( "Cannot retrieve java.net.URL for file: " + file + " on the current test classpath." );
+ throw new IllegalStateException( "Cannot retrieve java.net.URL for file: " + file
+ + " on the current test classpath." );
}
URI uri = new File( resource.getPath() ).toURI().normalize();
@@ -1444,54 +1447,75 @@
assertThat( archiver.parseOutputTimestamp( "*" ) ).isNull();
assertThat( archiver.parseOutputTimestamp( "1570300662" ).getTime() ).isEqualTo( 1570300662000L );
- assertThat( archiver.parseOutputTimestamp( "0" ).getTime() ).isEqualTo( 0L );
+ assertThat( archiver.parseOutputTimestamp( "0" ).getTime() ).isZero();
assertThat( archiver.parseOutputTimestamp( "1" ).getTime() ).isEqualTo( 1000L );
- assertThat( archiver.parseOutputTimestamp( "2019-10-05T18:37:42Z" ).getTime() ).isEqualTo( 1570300662000L );
- assertThat( archiver.parseOutputTimestamp( "2019-10-05T20:37:42+02:00" ).getTime() ).isEqualTo(
- 1570300662000L );
- assertThat( archiver.parseOutputTimestamp( "2019-10-05T16:37:42-02:00" ).getTime() ).isEqualTo(
- 1570300662000L );
+ assertThat( archiver.parseOutputTimestamp( "2019-10-05T18:37:42Z" ).getTime() )
+ .isEqualTo( 1570300662000L );
+ assertThat( archiver.parseOutputTimestamp( "2019-10-05T20:37:42+02:00" ).getTime() )
+ .isEqualTo( 1570300662000L );
+ assertThat( archiver.parseOutputTimestamp( "2019-10-05T16:37:42-02:00" ).getTime() )
+ .isEqualTo( 1570300662000L );
// These must result in IAE because we expect extended ISO format only (ie with - separator for date and
// : separator for timezone), hence the XXX SimpleDateFormat for tz offset
// X SimpleDateFormat accepts timezone without separator while date has separator, which is a mix between
// basic (no separators, both for date and timezone) and extended (separator for both)
- try
- {
- archiver.parseOutputTimestamp( "2019-10-05T20:37:42+0200" );
- fail();
- }
- catch ( IllegalArgumentException ignored )
- {
- }
- try
- {
- archiver.parseOutputTimestamp( "2019-10-05T20:37:42-0200" );
- fail();
- }
- catch ( IllegalArgumentException ignored )
- {
- }
+ assertThatExceptionOfType( IllegalArgumentException.class )
+ .isThrownBy( () -> archiver.parseOutputTimestamp( "2019-10-05T20:37:42+0200" ) );
+ assertThatExceptionOfType( IllegalArgumentException.class )
+ .isThrownBy( () -> archiver.parseOutputTimestamp( "2019-10-05T20:37:42-0200" ) );
+ }
- // These unfortunately fail although the input is valid according to ISO 8601
- // SDF does not allow strict telescoping parsing w/o permitting invalid input as depicted above.
- // One has to use the new Java Time API for this.
- try
- {
- archiver.parseOutputTimestamp( "2019-10-05T20:37:42+02" );
- fail();
- }
- catch ( IllegalArgumentException ignored )
- {
- }
- try
- {
- archiver.parseOutputTimestamp( "2019-10-05T20:37:42-02" );
- fail();
- }
- catch ( IllegalArgumentException ignored )
- {
- }
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource( strings = { ".", " ", "_", "-", "T", "/", "!", "!", "*", "ñ" } )
+ public void testEmptyParseOutputTimestampInstant( String value )
+ {
+ // Empty optional if null or 1 char
+ assertThat( MavenArchiver.parseBuildOutputTimestamp( value ) ).isEmpty();
+ }
+
+ @ParameterizedTest
+ @CsvSource( { "0,0", "1,1", "9,9", "1570300662,1570300662", "2147483648,2147483648",
+ "2019-10-05T18:37:42Z,1570300662", "2019-10-05T20:37:42+02:00,1570300662",
+ "2019-10-05T16:37:42-02:00,1570300662", "1988-02-22T15:23:47.76598Z,572541827",
+ "2011-12-03T10:15:30+01:00,1322903730", "1980-01-01T00:00:02Z,315532802", "2099-12-31T23:59:59Z,4102444799" } )
+ public void testParseOutputTimestampInstant( String value, long expected )
+ {
+ assertThat( MavenArchiver.parseBuildOutputTimestamp( value ) )
+ .contains( Instant.ofEpochSecond( expected ) );
+ }
+
+ @ParameterizedTest
+ @ValueSource( strings = { "2019-10-05T20:37:42+0200", "2019-10-05T20:37:42-0200", "2019-10-05T25:00:00Z",
+ "2019-10-05", "XYZ", "Tue, 3 Jun 2008 11:05:30 GMT", "2011-12-03T10:15:30+01:00[Europe/Paris]" } )
+ public void testThrownParseOutputTimestampInstant( String outputTimestamp )
+ {
+ // Invalid parsing
+ assertThatExceptionOfType( IllegalArgumentException.class )
+ .isThrownBy( () -> MavenArchiver.parseBuildOutputTimestamp( outputTimestamp ) )
+ .withCauseInstanceOf( DateTimeParseException.class );
+ }
+
+ @ParameterizedTest
+ @ValueSource( strings = { "1980-01-01T00:00:01Z", "2100-01-01T00:00Z", "2100-02-28T23:59:59Z",
+ "2099-12-31T23:59:59-01:00", "1980-01-01T00:15:35+01:00", "1980-01-01T10:15:35+14:00" } )
+ public void testThrownParseOutputTimestampInvalidRange( String outputTimestamp )
+ {
+ // date is not within the valid range 1980-01-01T00:00:02Z to 2099-12-31T23:59:59Z
+ assertThatExceptionOfType( IllegalArgumentException.class )
+ .isThrownBy( () -> MavenArchiver.parseBuildOutputTimestamp( outputTimestamp ) )
+ .withMessageContaining("is not within the valid range 1980-01-01T00:00:02Z to 2099-12-31T23:59:59Z");
+ }
+
+ @ParameterizedTest
+ @CsvSource( { "2011-12-03T10:15:30+01,1322903730", "2019-10-05T20:37:42+02,1570300662",
+ "2011-12-03T10:15:30+06,1322885730", "1988-02-22T20:37:42+06,572539062" } )
+ @EnabledForJreRange( min = JRE.JAVA_9 )
+ public void testShortOffset( String value, long expected )
+ {
+ assertThat( MavenArchiver.parseBuildOutputTimestamp( value ) )
+ .contains( Instant.ofEpochSecond( expected ) );
}
}