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 @@ public class MavenArchiver "${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 ARTIFACT_EXPRESSION_PREFIXES; static @@ -812,28 +820,80 @@ public void setBuildJdkSpecDefaultEntry( boolean buildJdkSpecDefaultEntry ) * @return the parsed timestamp, may be null if null 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. + * + *

Either as {@link java.time.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME} or as a number representing seconds + * since the epoch (like SOURCE_DATE_EPOCH). + * + * @param outputTimestamp the value of {@code ${project.build.outputTimestamp}} (may be {@code null}) + * @return the parsed timestamp as an {@code Optional}, {@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 parseBuildOutputTimestamp( String outputTimestamp ) + { + // Fail-fast on nulls + if ( outputTimestamp == null ) + { + return Optional.empty(); + } + + // Number representing seconds since the epoch + if ( StringUtils.isNotEmpty( outputTimestamp ) && StringUtils.isNumeric( outputTimestamp ) ) { - return new Date( Long.parseLong( outputTimestamp ) * 1000 ); + return Optional.of( Instant.ofEpochSecond( Long.parseLong( outputTimestamp ) ) ); } - if ( outputTimestamp == null || outputTimestamp.length() < 2 ) + // no timestamp configured (1 character configuration is useful to override a full value during pom + // inheritance) + if ( outputTimestamp.length() < 2 ) { - // no timestamp configured (1 character configuration is useful to override a full value during pom - // inheritance) - return null; + return Optional.empty(); } - DateFormat df = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ssXXX" ); 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 @@ public Date parseOutputTimestamp( String outputTimestamp ) /** * Configure Reproducible Builds archive creation if a timestamp is provided. * - * @param outputTimestamp the value of ${project.build.outputTimestamp} (may be null) - * @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 @@ public boolean equals( Object o ) } } - @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 @@ private File getClasspathFile( String file ) 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 @@ public void testParseOutputTimestamp() 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 ) ); } }