diff --git a/CHANGELOG.md b/CHANGELOG.md index bbfda10..eef16b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Features - add option to ignore source bundle upload failures ([#209](https://github.com/getsentry/sentry-maven-plugin/pull/209)) +- Deterministic Bundle Id generation ([#220](https://github.com/getsentry/sentry-maven-plugin/pull/220)) + - to enable set reproducibleBundleId to true and add an outputTimestamp to properties + - reproducible JAR builds require maven-jar-plugin >= 3.2.0 ### Dependencies diff --git a/examples/sentry-maven-plugin-example/pom.xml b/examples/sentry-maven-plugin-example/pom.xml index 68d8497..00ab836 100644 --- a/examples/sentry-maven-plugin-example/pom.xml +++ b/examples/sentry-maven-plugin-example/pom.xml @@ -11,6 +11,7 @@ 1.8 1.8 + 2026-01-01T00:00:00Z @@ -46,6 +47,8 @@ false false false + + false false false @@ -66,6 +69,7 @@ org.apache.maven.plugins maven-assembly-plugin + 3.2.0 @@ -87,6 +91,18 @@ + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + true + + + + org.codehaus.mojo build-helper-maven-plugin diff --git a/src/main/java/io/sentry/UploadSourceBundleMojo.java b/src/main/java/io/sentry/UploadSourceBundleMojo.java index 2c0d96c..d2c0ce1 100644 --- a/src/main/java/io/sentry/UploadSourceBundleMojo.java +++ b/src/main/java/io/sentry/UploadSourceBundleMojo.java @@ -5,11 +5,15 @@ import io.sentry.cli.SentryCliRunner; import io.sentry.telemetry.SentryTelemetryService; import java.io.*; -import java.nio.charset.Charset; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.*; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.maven.execution.MavenSession; import org.apache.maven.model.Resource; @@ -73,6 +77,9 @@ public class UploadSourceBundleMojo extends AbstractMojo { @Parameter(defaultValue = DEFAULT_IGNORE_SOURCE_BUNDLE_UPLOAD_FAILURE_STRING) private boolean ignoreSourceBundleUploadFailure; + @Parameter(defaultValue = DEFAULT_REPRODUCIBLE_BUNDLE_ID_STRING) + private boolean reproducibleBundleId; + @SuppressWarnings("NullAway") @Component private @NotNull BuildPluginManager pluginManager; @@ -85,20 +92,27 @@ public void execute() throws MojoExecutionException { return; } - final @NotNull String bundleId = UUID.randomUUID().toString(); final @NotNull File collectedSourcesTargetDir = new File(sentryBuildDir(), "collected-sources"); final @NotNull File sourceBundleTargetDir = new File(sentryBuildDir(), "source-bundle"); final @NotNull SentryCliRunner cliRunner = new SentryCliRunner( debugSentryCli, sentryCliExecutablePath, mavenProject, mavenSession, pluginManager); + collectSources(collectedSourcesTargetDir); + + final @NotNull String bundleId; + if (reproducibleBundleId) { + bundleId = generateDeterministicBundleId(collectedSourcesTargetDir); + } else { + bundleId = UUID.randomUUID().toString(); + } + createDebugMetaPropertiesFile(bundleId); - collectSources(bundleId, collectedSourcesTargetDir); bundleSources(cliRunner, bundleId, collectedSourcesTargetDir, sourceBundleTargetDir); uploadSourceBundle(cliRunner, sourceBundleTargetDir); } - private void collectSources(@NotNull String bundleId, @NotNull File outputDir) { + private void collectSources(@NotNull File outputDir) { final @Nullable ISpan span = SentryTelemetryService.getInstance().startTask("collectSources"); logger.debug("Collecting files from source directories"); @@ -168,6 +182,81 @@ private void collectSources(@NotNull String bundleId, @NotNull File outputDir) { return new File(outputDirectory, "sentry"); } + /** + * Generates a deterministic bundle ID based on the MD5 hash of all collected source files. This + * ensures reproducible builds produce the same bundle ID when the source files are identical. + * + * @param collectedSourcesDir the directory containing the collected source files + * @return a UUID v4 string derived from the hash of the source files + */ + private @NotNull String generateDeterministicBundleId(final @NotNull File collectedSourcesDir) { + final @Nullable ISpan span = + SentryTelemetryService.getInstance().startTask("generateDeterministicBundleId"); + try { + final @NotNull MessageDigest digest = MessageDigest.getInstance("MD5"); + + if (collectedSourcesDir.exists() && collectedSourcesDir.isDirectory()) { + try (final @NotNull Stream stream = Files.walk(collectedSourcesDir.toPath())) { + final @NotNull List sortedFiles = + stream + .filter(Files::isRegularFile) + .sorted( + Comparator.comparing( + p -> + collectedSourcesDir + .toPath() + .relativize(p) + .toString() + .replace('\\', '/'))) + .collect(Collectors.toList()); + + for (final @NotNull Path file : sortedFiles) { + final @NotNull String relativePath = + collectedSourcesDir.toPath().relativize(file).toString().replace('\\', '/'); + digest.update(relativePath.getBytes(StandardCharsets.UTF_8)); + + // Include the file content in the hash + final byte[] fileBytes = Files.readAllBytes(file); + digest.update(fileBytes); + } + } + } + + final byte[] hashBytes = digest.digest(); + return bytesToUuid(hashBytes); + } catch (NoSuchAlgorithmException e) { + logger.warn("MD5 algorithm not available, falling back to random UUID", e); + return UUID.randomUUID().toString(); + } catch (IOException e) { + logger.warn( + "Failed to read source files for bundle ID generation, falling back to random UUID", e); + SentryTelemetryService.getInstance().captureError(e, "generateDeterministicBundleId"); + return UUID.randomUUID().toString(); + } catch (Throwable t) { + logger.warn("Failed to generate deterministic bundle ID, falling back to random UUID", t); + SentryTelemetryService.getInstance().captureError(t, "generateDeterministicBundleId"); + return UUID.randomUUID().toString(); + } finally { + SentryTelemetryService.getInstance().endTask(span); + } + } + + /** + * Converts 16 bytes into a UUID v4 string format (RFC 4122). + * + * @param hashBytes the hash bytes (exactly 16 bytes expected from MD5) + * @return a UUID string in the format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + */ + private @NotNull String bytesToUuid(final byte[] hashBytes) { + // Set version 4 (bits 12-15 of time_hi_and_version to 0100) + hashBytes[6] = (byte) ((hashBytes[6] & 0x0F) | 0x40); + // Set variant to RFC 4122 (bits 6-7 of clock_seq_hi_and_reserved to 10) + hashBytes[8] = (byte) ((hashBytes[8] & 0x3F) | 0x80); + + final @NotNull ByteBuffer buffer = ByteBuffer.wrap(hashBytes); + return new UUID(buffer.getLong(), buffer.getLong()).toString(); + } + private void bundleSources( final @NotNull SentryCliRunner cliRunner, final @NotNull String bundleId, @@ -280,11 +369,17 @@ private void createDebugMetaPropertiesFile(final @NotNull String bundleId) } final @NotNull File debugMetaFile = new File(sentryBuildDir, "sentry-debug-meta.properties"); - final @NotNull Properties properties = createDebugMetaProperties(bundleId); try (final @NotNull BufferedWriter fileWriter = - Files.newBufferedWriter(debugMetaFile.toPath(), Charset.defaultCharset())) { - properties.store(fileWriter, "Generated by sentry-maven-plugin"); + Files.newBufferedWriter(debugMetaFile.toPath(), StandardCharsets.UTF_8)) { + // Write properties without timestamp comment for reproducible builds + // Properties are written in sorted order for consistency + fileWriter.write("# Generated by sentry-maven-plugin"); + fileWriter.newLine(); + fileWriter.write("io.sentry.build-tool=maven"); + fileWriter.newLine(); + fileWriter.write("io.sentry.bundle-ids=" + bundleId); + fileWriter.newLine(); final @NotNull Resource resource = new Resource(); resource.setDirectory(sentryBuildDir.getPath()); @@ -300,13 +395,4 @@ private void createDebugMetaPropertiesFile(final @NotNull String bundleId) SentryTelemetryService.getInstance().endTask(span); } } - - private @NotNull Properties createDebugMetaProperties(final @NotNull String bundleId) { - final @NotNull Properties properties = new Properties(); - - properties.setProperty("io.sentry.bundle-ids", bundleId); - properties.setProperty("io.sentry.build-tool", "maven"); - - return properties; - } } diff --git a/src/main/java/io/sentry/config/PluginConfig.java b/src/main/java/io/sentry/config/PluginConfig.java index 80619ef..4dc12c6 100644 --- a/src/main/java/io/sentry/config/PluginConfig.java +++ b/src/main/java/io/sentry/config/PluginConfig.java @@ -14,6 +14,8 @@ public class PluginConfig { public static final @NotNull String DEFAULT_SKIP_SOURCE_BUNDLE_STRING = "false"; public static final boolean DEFAULT_IGNORE_SOURCE_BUNDLE_UPLOAD_FAILURE = false; public static final @NotNull String DEFAULT_IGNORE_SOURCE_BUNDLE_UPLOAD_FAILURE_STRING = "false"; + public static final boolean DEFAULT_REPRODUCIBLE_BUNDLE_ID = false; + public static final @NotNull String DEFAULT_REPRODUCIBLE_BUNDLE_ID_STRING = "false"; public static final boolean DEFAULT_SKIP_TELEMETRY = false; public static final @NotNull String DEFAULT_SKIP_TELEMETRY_STRING = "false"; public static final boolean DEFAULT_DEBUG_SENTRY_CLI = false; diff --git a/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt b/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt index b7ddf55..4255d97 100644 --- a/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt +++ b/src/test/java/io/sentry/integration/uploadSourceBundle/PomUtils.kt @@ -4,6 +4,7 @@ fun basePom( skipPlugin: Boolean = false, skipSourceBundle: Boolean = false, ignoreSourceBundleUploadFailure: Boolean = false, + reproducibleBundleId: Boolean = false, sentryCliPath: String? = null, extraSourceRoots: List = listOf(), extraSourceContextDirs: List = emptyList(), @@ -91,6 +92,7 @@ fun basePom( $skipPlugin $skipSourceBundle $ignoreSourceBundleUploadFailure + $reproducibleBundleId true sentry-sdks sentry-maven diff --git a/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt b/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt index 21a1a3f..e9133a7 100644 --- a/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt +++ b/src/test/java/io/sentry/integration/uploadSourceBundle/UploadSourceBundleTestIT.kt @@ -29,6 +29,7 @@ class UploadSourceBundleTestIT { skipPlugin: Boolean = false, skipSourceBundle: Boolean = false, ignoreSourceBundleUploadFailure: Boolean = false, + reproducibleBundleId: Boolean = false, sentryCliPath: String? = null, extraSourceRoots: List = listOf(), extraSourceContextDirs: List = emptyList(), @@ -39,6 +40,7 @@ class UploadSourceBundleTestIT { skipPlugin, skipSourceBundle, ignoreSourceBundleUploadFailure, + reproducibleBundleId, sentryCliPath, extraSourceRoots, extraSourceContextDirs, @@ -247,4 +249,176 @@ class UploadSourceBundleTestIT { myProps.load(FileInputStream("$baseDir/target/sentry/properties/sentry-debug-meta.properties")) return myProps.getProperty("io.sentry.bundle-ids") } + + private fun getPropertiesFileContent(baseDir: String): String = + File("$baseDir/target/sentry/properties/sentry-debug-meta.properties").readText() + + @Test + fun `bundle ID changes when source content changes`() { + val projectDir = File(file, "content-change-test") + projectDir.mkdirs() + + val sourceDir = File(projectDir, "src/main/java") + sourceDir.mkdirs() + + installMavenWrapper(projectDir, "3.8.6") + getPOM(projectDir, reproducibleBundleId = true) + + // First build with initial content + File(sourceDir, "Main.java").writeText("public class Main { int version = 1; }") + + val verifier1 = Verifier(projectDir.absolutePath) + verifier1.isAutoclean = false + verifier1.executeGoal("install") + verifier1.verifyErrorFreeLog() + + val bundleId1 = getBundleIdFromProperties(projectDir.absolutePath) + verifier1.resetStreams() + + // Clean and rebuild with different content + File(projectDir, "target").deleteRecursively() + File(sourceDir, "Main.java").writeText("public class Main { int version = 2; }") + + val verifier2 = Verifier(projectDir.absolutePath) + verifier2.isAutoclean = false + verifier2.executeGoal("install") + verifier2.verifyErrorFreeLog() + + val bundleId2 = getBundleIdFromProperties(projectDir.absolutePath) + verifier2.resetStreams() + + // Verify bundle ID changed + assertTrue( + bundleId1 != bundleId2, + "Bundle ID should change when source content changes", + ) + } + + @Test + fun `properties file does not contain timestamp`() { + val baseDir = setupProject() + val path = getPOM(baseDir) + val verifier = Verifier(path) + verifier.isAutoclean = false + verifier.executeGoal("install") + verifier.verifyErrorFreeLog() + + val propertiesContent = getPropertiesFileContent(baseDir.absolutePath) + + // Properties.store() adds a timestamp like "#Wed Jan 08 10:30:00 CET 2025" + // Verify this pattern is not present + val timestampPattern = Regex("#\\w{3} \\w{3} \\d{2} \\d{2}:\\d{2}:\\d{2}") + assertTrue( + !timestampPattern.containsMatchIn(propertiesContent), + "Properties file should not contain a timestamp comment. Content: $propertiesContent", + ) + + verifier.resetStreams() + } + + @Test + fun `build is reproducible with reproducibleBundleId enabled`() { + val projectDir = File(file, "reproducible-artifact-compare-test") + projectDir.mkdirs() + + val sourceDir = File(projectDir, "src/main/java/com/example") + sourceDir.mkdirs() + + // Create source files + File(sourceDir, "Main.java").writeText( + """ + package com.example; + public class Main { + public static void main(String[] args) { + System.out.println("Reproducible build test"); + } + } + """.trimIndent(), + ) + + installMavenWrapper(projectDir, "3.9.11") + + // Create POM with outputTimestamp for reproducible builds + val pomContent = + basePom(reproducibleBundleId = true) + .replace( + "", + """ + 2025-01-01T00:00:00Z""", + ) + Files.write(Path("${projectDir.absolutePath}/pom.xml"), pomContent.toByteArray(), StandardOpenOption.CREATE) + + // First build: clean install + val verifier1 = Verifier(projectDir.absolutePath) + verifier1.isAutoclean = false + verifier1.executeGoal("clean") + verifier1.executeGoal("install") + verifier1.verifyErrorFreeLog() + verifier1.resetStreams() + + // Second build: clean verify artifact:compare + val verifier2 = Verifier(projectDir.absolutePath) + verifier2.isAutoclean = false + verifier2.executeGoal("clean") + verifier2.executeGoal("verify") + verifier2.addCliOption("-Dartifact.buildCompare.saveAll=true") + verifier2.executeGoal("artifact:compare") + verifier2.verifyErrorFreeLog() + + verifier2.resetStreams() + } + + @Test + fun `build is not reproducible with reproducibleBundleId disabled`() { + val projectDir = File(file, "non-reproducible-artifact-compare-test") + projectDir.mkdirs() + + val sourceDir = File(projectDir, "src/main/java/com/example") + sourceDir.mkdirs() + + File(sourceDir, "Main.java").writeText( + """ + package com.example; + public class Main { + public static void main(String[] args) { + System.out.println("Non-reproducible bundle ID test"); + } + } + """.trimIndent(), + ) + + installMavenWrapper(projectDir, "3.9.11") + + // Create POM with reproducibleBundleId=false but still with outputTimestamp + // This means the JAR will be reproducible, but the bundle ID will change + val pomContent = + basePom(reproducibleBundleId = false) + .replace( + "", + """ + 2025-01-01T00:00:00Z""", + ) + Files.write(Path("${projectDir.absolutePath}/pom.xml"), pomContent.toByteArray(), StandardOpenOption.CREATE) + + // First build + val verifier1 = Verifier(projectDir.absolutePath) + verifier1.isAutoclean = false + verifier1.executeGoal("clean") + verifier1.executeGoal("install") + verifier1.verifyErrorFreeLog() + verifier1.resetStreams() + + // Second build - should fail artifact:compare because bundle ID changes + val verifier2 = Verifier(projectDir.absolutePath) + verifier2.isAutoclean = false + verifier2.executeGoal("clean") + + assertFailsWith { + verifier2.executeGoals(listOf("verify", "artifact:compare")) + } + + verifier2.verifyTextInLog("[ERROR] [Reproducible Builds] rebuild comparison result: 1 files match, 1 differ") + + verifier2.resetStreams() + } }