Skip to content

Commit

Permalink
[MSHARED-1067] - Improve the Reproducible Builds methods
Browse files Browse the repository at this point in the history
Signed-off-by: Jorge Solórzano <jorsol@gmail.com>
  • Loading branch information
jorsol committed Jun 13, 2022
1 parent 2a1d52e commit 1f87b0d
Show file tree
Hide file tree
Showing 2 changed files with 152 additions and 84 deletions.
101 changes: 78 additions & 23 deletions src/main/java/org/apache/maven/archiver/MavenArchiver.java
Expand Up @@ -24,14 +24,19 @@
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.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
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;
Expand Down Expand Up @@ -97,6 +102,10 @@ public class MavenArchiver
"${artifact.groupIdPath}/${artifact.artifactId}/" + "${artifact.baseVersion}/${artifact.artifactId}-"
+ "${artifact.baseVersion}${dashClassifier?}.${artifact.extension}";

private static final ZonedDateTime DATE_MIN = ZonedDateTime.parse( "1980-01-01T00:00:02Z" );

private static final ZonedDateTime DATE_MAX = ZonedDateTime.parse( "2099-12-31T23:59:59Z" );

private static final List<String> ARTIFACT_EXPRESSION_PREFIXES;

static
Expand Down Expand Up @@ -812,28 +821,78 @@ public void setBuildJdkSpecDefaultEntry( boolean buildJdkSpecDefaultEntry )
* @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
* @deprecated Use {@link #parseBuildOutputTimestamp(String)} instead.
*/
@Deprecated
public Date parseOutputTimestamp( String 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_ZONED_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
*/
public static Optional<Instant> parseBuildOutputTimestamp( String outputTimestamp )
{
// Fail-fast on nulls
if ( outputTimestamp == null )
{
return Optional.empty();
}

// Number representing seconds since the epoch
if ( StringUtils.isNumeric( outputTimestamp ) && StringUtils.isNotEmpty( 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+02:00'.
final ZonedDateTime date = ZonedDateTime.parse( outputTimestamp, DateTimeFormatter.ISO_ZONED_DATE_TIME )
.withZoneSameInstant( ZoneOffset.UTC ).truncatedTo( ChronoUnit.SECONDS );

if ( date.isBefore( DATE_MIN ) || date.isAfter( DATE_MAX ) )
{
throw new IllegalArgumentException( "date '" + outputTimestamp + "' is not within the valid range "
+ DATE_MIN + " to " + DATE_MAX );
}
return Optional.of( date.toInstant() );
}
catch ( ParseException pe )
catch ( DateTimeParseException pe )
{
throw new IllegalArgumentException( "Invalid project.build.outputTimestamp value '" + outputTimestamp + "'",
pe );
Expand All @@ -843,18 +902,14 @@ public Date parseOutputTimestamp( String outputTimestamp )
/**
* 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 ) );
}
}
135 changes: 74 additions & 61 deletions src/test/java/org/apache/maven/archiver/MavenArchiverTest.java
Expand Up @@ -39,12 +39,19 @@
import org.eclipse.aether.DefaultRepositorySystemSession;
import org.eclipse.aether.RepositorySystemSession;
import org.junit.jupiter.api.Test;
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;
Expand All @@ -61,7 +68,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
{
Expand All @@ -79,28 +86,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
Expand Down Expand Up @@ -1366,7 +1366,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();
Expand Down Expand Up @@ -1444,54 +1445,66 @@ 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,1000", "9,9000", "1570300662,1570300662000", "2147483648,2147483648000",
"2019-10-05T18:37:42Z,1570300662000", "2019-10-05T20:37:42+02:00,1570300662000",
"2019-10-05T16:37:42-02:00,1570300662000", "1988-02-22T15:23:47.76598Z,572541827000",
"2011-12-03T10:15:30+01:00[Europe/Paris],1322903730000",
"1980-01-01T00:00:02Z,315532802000", "2099-12-31T23:59:59Z,4102444799000" } )
public void testParseOutputTimestampInstant( String value, long expected )
{
assertThat( MavenArchiver.parseBuildOutputTimestamp( value ) )
.contains( Instant.ofEpochMilli( 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" } )
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[Europe/Madrid]" } )
public void testThrownParseOutputTimestampValidRange( 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");
}
}

0 comments on commit 1f87b0d

Please sign in to comment.