diff --git a/README.md b/README.md index 8ec40f39..4f0fa5b9 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,25 @@ micronautBuild { ``` You can use [the same DSL as in Gradle](https://docs.gradle.org/current/dsl/org.gradle.api.artifacts.ResolutionStrategy.html). + +## Gradle Problems API diagnostics + +On the Gradle 9 line, Micronaut Build reports selected internal validation failures through Gradle's Problems API. No extra configuration is required. The build still fails at the same point and keeps the existing failure message, but Gradle also records a structured problem in the generated report at `build/reports/problems/problems-report.html`. Gradle prints a link to that report unless the build runs with `--no-problems-report`. + +The stable Micronaut Build problem group is `micronaut-build > validation`. Covered problem IDs include: + +* `enforced-platform-not-supported` +* `micronaut-version-mismatch` +* `unsupported-test-framework` +* `invalid-pom-coordinates` +* `pom-verification-failed` +* `asciidoc-output-validation-failed` +* `maven-central-deployment-failed` + +For example, if a dependency upgrades `io.micronaut:micronaut-core` away from the Micronaut version declared by the build, the failure remains actionable and includes the existing diagnostic command: + +```shell +./gradlew --dependencyInsight --configuration compileClasspath --dependency io.micronaut:micronaut-core +``` + +Problem details are intentionally bounded to validation context such as versions, configurations, and report paths. Credentials, signing data, Maven Central bearer tokens, raw environment dumps, and unbounded HTTP responses must not be added to problem details or reports. diff --git a/micronaut-gradle-plugins/src/functionalTest/groovy/io/micronaut/build/ProblemsApiFunctionalTest.groovy b/micronaut-gradle-plugins/src/functionalTest/groovy/io/micronaut/build/ProblemsApiFunctionalTest.groovy new file mode 100644 index 00000000..b762e732 --- /dev/null +++ b/micronaut-gradle-plugins/src/functionalTest/groovy/io/micronaut/build/ProblemsApiFunctionalTest.groovy @@ -0,0 +1,65 @@ +package io.micronaut.build + +import java.nio.file.Files + +class ProblemsApiFunctionalTest extends AbstractFunctionalTest { + + void "reports Micronaut version mismatch as a Gradle problem"() { + given: + withSample("test-micronaut-module") + + file("subproject1/build.gradle") << """ + dependencies { + implementation("io.micronaut:micronaut-core:4.8.4") + } + """ + + when: + fails 'compileJava' + + then: + errorOutputContains "Micronaut version mismatch: project declares 4.6.3 but resolved version is 4.8.4. You probably have a dependency which triggered an upgrade of micronaut-core. In order to determine where it comes from, you can run ./gradlew --dependencyInsight --configuration compileClasspath --dependency io.micronaut:micronaut-core" + problemsReportContains("micronaut-version-mismatch") + problemsReportContains("Micronaut Build validation") + } + + void "reports generated Asciidoc output validation failure as a Gradle problem"() { + given: + settingsFile << """ + pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } + } + + rootProject.name = "asciidoc-validation-problems" + """ + buildFile << """ + plugins { + id "io.micronaut.build.internal.docs" + } + + tasks.register("validateBrokenDocs", io.micronaut.build.docs.ValidateAsciidocOutputTask) { + inputDirectory = layout.projectDirectory.dir("broken-docs") + report = layout.buildDirectory.file("reports/broken-docs.txt") + failOnError = true + } + """ + file("broken-docs/index.html").text = "Unresolved directive in index.adoc - include::missing.adoc[]" + + when: + fails 'validateBrokenDocs' + + then: + errorOutputContains "Validation of generated asciidoctor files failed. See the report at" + problemsReportContains("asciidoc-output-validation-failed") + problemsReportContains("Micronaut Build validation") + } + + private void problemsReportContains(String text) { + def report = testDirectory.resolve("build/reports/problems/problems-report.html") + assert Files.exists(report): "Expected Gradle Problems report at $report" + assert report.text.contains(text): "Expected Gradle Problems report to contain '$text'" + } +} diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java index 1fc56e21..53249b44 100644 --- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java +++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MavenCentralPublishTask.java @@ -1,9 +1,11 @@ package io.micronaut.build; +import io.micronaut.build.problems.MicronautBuildProblems; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.Property; +import org.gradle.api.problems.Problems; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.Optional; @@ -13,6 +15,7 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -22,8 +25,12 @@ import java.time.Duration; import java.util.Base64; import java.util.UUID; +import java.util.regex.Pattern; + +import javax.inject.Inject; public abstract class MavenCentralPublishTask extends DefaultTask { + private static final Pattern DEPLOYMENT_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._:-]{0,255}"); public enum PublishingType { AUTOMATIC, @@ -44,6 +51,9 @@ public enum PublishingType { @Option(option = "publishing-type", description = "Configures the Maven Central publishing type.") public abstract Property getPublishingType(); + @Inject + public abstract Problems getProblems(); + public MavenCentralPublishTask() { super(); setDescription("Publishes a bundle using Maven Central's Publisher API"); @@ -90,23 +100,24 @@ public void uploadBundle() throws URISyntaxException, IOException, InterruptedEx .build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + var sanitizedBody = MicronautBuildProblems.sanitizeDiagnosticText(response.body()); - getLogger().lifecycle("Upload response: {} {}", response.statusCode(), response.body()); + getLogger().lifecycle("Upload response: {} {}", response.statusCode(), sanitizedBody); if (response.statusCode() >= 200 && response.statusCode() < 300) { - var deploymentId = response.body(); + var deploymentId = extractDeploymentId(response.body()); if (deploymentId != null && !deploymentId.isEmpty()) { verifyDeploymentStatus(client, deploymentId); } else { - throw new GradleException("Could not extract deploymentId from response: " + response.body()); + throw deploymentFailure("Could not extract deploymentId from response: " + sanitizedBody, "Maven Central returned a successful upload response without a valid deployment id."); } } else { - throw new GradleException("Unexpected status code: " + response.statusCode() + " (" + response.body() + ")"); + throw deploymentFailure("Unexpected status code: " + response.statusCode() + " (" + sanitizedBody + ")", "Maven Central returned HTTP " + response.statusCode() + " while uploading the publication bundle."); } } private void verifyDeploymentStatus(HttpClient client, String deploymentId) throws IOException, InterruptedException { - var statusUrl = "https://central.sonatype.com/api/v1/publisher/status?id=" + deploymentId; + var statusUrl = buildStatusUrl(deploymentId); getLogger().lifecycle("Checking deployment status for {}", deploymentId); int maxLookups = 100; while (--maxLookups >= 0) { @@ -117,8 +128,9 @@ private void verifyDeploymentStatus(HttpClient client, String deploymentId) thro .build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + var sanitizedBody = MicronautBuildProblems.sanitizeDiagnosticText(response.body()); - getLogger().lifecycle("Status check: {} {}", response.statusCode(), response.body()); + getLogger().lifecycle("Status check: {} {}", response.statusCode(), sanitizedBody); var body = response.body(); if (response.statusCode() == 200) { @@ -127,14 +139,39 @@ private void verifyDeploymentStatus(HttpClient client, String deploymentId) thro return; } if (body.contains("\"deploymentState\":\"FAILED\"")) { - throw new GradleException("Deployment " + deploymentId + " failed: " + body); + throw deploymentFailure("Deployment " + deploymentId + " failed: " + sanitizedBody, "Maven Central reported a failed deployment state for deployment " + deploymentId + "."); } - } else if (response.statusCode() < 200 || response.statusCode() > 300) { - getLogger().warn("Status check for deployment " + deploymentId + " failed with: " + body + ". This doesn't necessarily mean that deployment failed, please check status on https://central.sonatype.com/publishing"); + } else if (response.statusCode() < 200 || response.statusCode() >= 300) { + getLogger().warn("Status check for deployment " + deploymentId + " failed with: " + sanitizedBody + ". This doesn't necessarily mean that deployment failed, please check status on https://central.sonatype.com/publishing"); break; } Thread.sleep(30_000); } } + + private RuntimeException deploymentFailure(String message, String details) { + return MicronautBuildProblems.throwing(getProblems(), new GradleException(message), MicronautBuildProblems.MAVEN_CENTRAL_DEPLOYMENT_FAILED, spec -> spec + .contextualLabel("Maven Central deployment failed") + .details(details) + .solution("Check the sanitized Maven Central response and verify the publication bundle and deployment status at https://central.sonatype.com/publishing.")); + } + + static String extractDeploymentId(String responseBody) { + if (responseBody == null) { + return null; + } + var deploymentId = responseBody.trim(); + if (!MicronautBuildProblems.sanitizeDiagnosticText(deploymentId).equals(deploymentId)) { + return null; + } + if (DEPLOYMENT_ID.matcher(deploymentId).matches()) { + return deploymentId; + } + return null; + } + + static String buildStatusUrl(String deploymentId) { + return "https://central.sonatype.com/api/v1/publisher/status?id=" + URLEncoder.encode(deploymentId, StandardCharsets.UTF_8); + } } diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy index f5181801..9c2c0745 100644 --- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy +++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautBuildCommonPlugin.groovy @@ -1,6 +1,7 @@ package io.micronaut.build import com.diffplug.gradle.spotless.SpotlessTask +import io.micronaut.build.problems.MicronautBuildProblems import io.micronaut.build.utils.DefaultVersions import org.gradle.api.GradleException import org.gradle.api.Plugin @@ -8,6 +9,7 @@ import org.gradle.api.Project import org.gradle.api.artifacts.ResolvableDependencies import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.artifacts.result.ResolvedComponentResult +import org.gradle.api.problems.Problems import org.gradle.api.tasks.compile.GroovyCompile import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.diagnostics.DependencyReportTask @@ -15,12 +17,21 @@ import org.gradle.api.tasks.javadoc.Groovydoc import org.gradle.api.tasks.testing.Test import org.gradle.jvm.tasks.Jar +import javax.inject.Inject + import static io.micronaut.build.BomSupport.coreBomArtifactId import static io.micronaut.build.utils.VersionHandling.versionProviderOrDefault /** * Micronaut internal Gradle plugin. Not intended to be used in user's projects. */ class MicronautBuildCommonPlugin implements Plugin { + private final Problems problems + + @Inject + MicronautBuildCommonPlugin(Problems problems) { + this.problems = problems + } + void apply(Project project) { project.pluginManager.apply(MicronautBasePlugin) project.pluginManager.apply(MicronautQualityChecksParticipantPlugin) @@ -63,7 +74,12 @@ class MicronautBuildCommonPlugin implements Plugin { project.configurations.globalBoms.dependencies.addAllLater(micronautBuild.enableBom.zip(micronautVersionProvider) { enabled, micronautVersion -> if (enabled) { if (micronautBuild.enforcedPlatform.get()) { - throw new GradleException("Do not use enforcedPlatform. Please remove the micronautBuild.enforcedPlatform setting") + String message = "Do not use enforcedPlatform. Please remove the micronautBuild.enforcedPlatform setting" + throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.ENFORCED_PLATFORM_NOT_SUPPORTED) { + it.contextualLabel(message) + .details("micronautBuild.enforcedPlatform is not supported by the Micronaut Build plugins.") + .solution("Remove the micronautBuild.enforcedPlatform setting and let Micronaut Build add the standard platform dependency.") + } } String artifactId = coreBomArtifactId(micronautVersion) [project.dependencies.platform("io.micronaut:$artifactId:$micronautVersion")] @@ -154,7 +170,13 @@ class MicronautBuildCommonPlugin implements Plugin { if (id.group == 'io.micronaut' && id.module == 'micronaut-core') { def (resolvedMajor, resolvedMinor, resolvedPatch) = id.version.tokenize('.') if (resolvedMajor != major || resolvedMinor != minor) { - throw new GradleException("Micronaut version mismatch: project declares $micronautVersion but resolved version is ${id.version}. You probably have a dependency which triggered an upgrade of micronaut-core. In order to determine where it comes from, you can run ./gradlew --dependencyInsight --configuration $configName --dependency io.micronaut:micronaut-core") + String dependencyInsight = "./gradlew --dependencyInsight --configuration $configName --dependency io.micronaut:micronaut-core" + String message = "Micronaut version mismatch: project declares $micronautVersion but resolved version is ${id.version}. You probably have a dependency which triggered an upgrade of micronaut-core. In order to determine where it comes from, you can run $dependencyInsight" + throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.MICRONAUT_VERSION_MISMATCH) { + it.contextualLabel("Micronaut version mismatch on $configName") + .details("Project declares Micronaut $micronautVersion but $configName resolved io.micronaut:micronaut-core:${id.version}.") + .solution("Run $dependencyInsight to determine which dependency selected the resolved micronaut-core version.") + } } } } diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy index 1955cc7a..35552379 100644 --- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy +++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/MicronautModulePlugin.groovy @@ -1,11 +1,17 @@ package io.micronaut.build import groovy.transform.CompileStatic +import io.micronaut.build.problems.MicronautBuildProblems +import org.gradle.api.Action import io.micronaut.build.utils.DefaultVersions import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.Dependency +import org.gradle.api.problems.ProblemSpec +import org.gradle.api.problems.Problems + +import javax.inject.Inject import static io.micronaut.build.utils.VersionHandling.versionProviderOrDefault @@ -16,6 +22,12 @@ import static io.micronaut.build.utils.VersionHandling.versionProviderOrDefault */ @CompileStatic class MicronautModulePlugin implements Plugin { + private final Problems problems + + @Inject + MicronautModulePlugin(Problems problems) { + this.problems = problems + } @Override void apply(Project project) { @@ -23,7 +35,7 @@ class MicronautModulePlugin implements Plugin { configureStandardDependencies(project) } - private static void configureStandardDependencies(Project project) { + private void configureStandardDependencies(Project project) { var micronautBuild = project.extensions.getByType(MicronautBuildExtension) var deps = project.dependencies deps.with { @@ -44,7 +56,7 @@ class MicronautModulePlugin implements Plugin { deps.create(versionProviderOrDefault(project, 'micronaut-test', DefaultVersions.MICRONAUT_TEST_VERSION).map { "io.micronaut.test:micronaut-test-junit5:${it}" }.get()) ) } else { - throw new GradleException("Unsupported test framework: $it") + return unsupportedTestFramework(it) } } ) @@ -58,9 +70,19 @@ class MicronautModulePlugin implements Plugin { deps.create(versionProviderOrDefault(project, 'junit6', DefaultVersions.JUNIT6_VERSION).map { "org.junit.jupiter:junit-jupiter-engine:${it}" }.get()), ) } else { - throw new GradleException("Unsupported test framework: $it") + return unsupportedTestFramework(it) } } ) } + + private List unsupportedTestFramework(TestFramework testFramework) { + String message = "Unsupported test framework: $testFramework" + throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.UNSUPPORTED_TEST_FRAMEWORK, { + ProblemSpec spec -> + spec.contextualLabel(message) + .details("The Micronaut Build module plugin only supports ${TestFramework.SPOCK} and ${TestFramework.JUNIT6} test framework defaults.") + .solution("Configure micronautBuild.testFramework to ${TestFramework.SPOCK} or ${TestFramework.JUNIT6}.") + } as Action) + } } diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java index c9a1a8c8..5c9e350c 100644 --- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java +++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/docs/ValidateAsciidocOutputTask.java @@ -15,11 +15,13 @@ */ package io.micronaut.build.docs; +import io.micronaut.build.problems.MicronautBuildProblems; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.Property; +import org.gradle.api.problems.Problems; import org.gradle.api.tasks.CacheableTask; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputDirectory; @@ -38,6 +40,8 @@ import java.util.List; import java.util.Map; +import javax.inject.Inject; + import static io.micronaut.build.utils.ConsoleUtils.clickableUrl; @CacheableTask @@ -55,6 +59,9 @@ public abstract class ValidateAsciidocOutputTask extends DefaultTask { @OutputFile public abstract RegularFileProperty getReport(); + @Inject + public abstract Problems getProblems(); + @TaskAction void validate() throws IOException { final Map> errors = new HashMap<>(); @@ -81,7 +88,12 @@ void validate() throws IOException { } } if (getFailOnError().getOrElse(true)) { - throw new GradleException("Validation of generated asciidoctor files failed. See the report at " + clickableUrl(getReport().getAsFile().get())); + var reportFile = getReport().getAsFile().get(); + var message = "Validation of generated asciidoctor files failed. See the report at " + clickableUrl(reportFile); + throw MicronautBuildProblems.throwing(getProblems(), new GradleException(message), MicronautBuildProblems.ASCIIDOC_OUTPUT_VALIDATION_FAILED, spec -> spec + .contextualLabel("Generated Asciidoc output contains unresolved directives") + .details("Generated HTML files contain unresolved Asciidoc directives. See the validation report at " + reportFile + ".") + .solution("Fix the unresolved directives in the generated documentation source and rerun the docs validation task.")); } } } diff --git a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy index 559cf7b1..1eaaa8da 100644 --- a/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy +++ b/micronaut-gradle-plugins/src/main/groovy/io/micronaut/build/pom/PomChecker.groovy @@ -1,12 +1,14 @@ package io.micronaut.build.pom import groovy.transform.CompileStatic +import io.micronaut.build.problems.MicronautBuildProblems import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property +import org.gradle.api.problems.Problems import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile @@ -59,6 +61,9 @@ abstract class PomChecker extends DefaultTask { @Inject abstract WorkerExecutor getWorkerExecutor() + @Inject + abstract Problems getProblems() + PomChecker() { description = "Verifies a POM file" group = VERIFICATION_GROUP @@ -78,7 +83,12 @@ abstract class PomChecker extends DefaultTask { ErrorCollector errorCollector = new ErrorCollector(silencedDeps) def coordinates = pomCoordinates.get().split(':') if (coordinates.length != 3) { - throw new GradleException("Incorrect POM coordinates '${pomCoordinates.get()}': should be of the form group:artifact:version ") + String message = "Incorrect POM coordinates '${pomCoordinates.get()}': should be of the form group:artifact:version" + throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.INVALID_POM_COORDINATES) { + it.contextualLabel("Invalid POM coordinates") + .details("The configured POM coordinates do not use the expected group:artifact:version format.") + .solution("Set pomCoordinates to a complete group:artifact:version coordinate.") + } } def queue = new ArrayDeque>() queue.add([coordinates[0], coordinates[1], coordinates[2], pomFile.get().asFile, pomCoordinates.get()] as List) @@ -158,7 +168,13 @@ abstract class PomChecker extends DefaultTask { try (var writer = new BufferedWriter(new PrintWriter(System.err))) { writeSuggestions(errorCollector.suggestions, writer) } - throw new GradleException("POM verification failed. See report in ${reportFile}") + String message = "POM verification failed. See report in ${reportFile}" + int errorCount = errorCollector.errors.size() + throw MicronautBuildProblems.throwing(problems, new GradleException(message), MicronautBuildProblems.POM_VERIFICATION_FAILED) { + it.contextualLabel("POM verification failed") + .details("POM verification found $errorCount validation error(s). See report in ${reportFile}.") + .solution("Review the POM verification report and either fix the dependencies or add a targeted micronautBom suppression when the dependency is intentional.") + } } } diff --git a/micronaut-gradle-plugins/src/main/java/io/micronaut/build/problems/MicronautBuildProblems.java b/micronaut-gradle-plugins/src/main/java/io/micronaut/build/problems/MicronautBuildProblems.java new file mode 100644 index 00000000..1e83332f --- /dev/null +++ b/micronaut-gradle-plugins/src/main/java/io/micronaut/build/problems/MicronautBuildProblems.java @@ -0,0 +1,69 @@ +package io.micronaut.build.problems; + +import org.gradle.api.Action; +import org.gradle.api.problems.ProblemGroup; +import org.gradle.api.problems.ProblemId; +import org.gradle.api.problems.ProblemSpec; +import org.gradle.api.problems.Problems; +import org.gradle.api.problems.Severity; + +import java.util.regex.Pattern; + +public final class MicronautBuildProblems { + public static final String DOCUMENTATION_URL = "https://github.com/micronaut-projects/micronaut-build#gradle-problems-api-diagnostics"; + + private static final int MAX_DIAGNOSTIC_LENGTH = 1_000; + private static final int SANITIZATION_INPUT_LENGTH = MAX_DIAGNOSTIC_LENGTH * 2; + private static final String SENSITIVE_KEYS = "authorization|password|passwd|secret|token|api[-_ ]?key|access[-_ ]?key|username|user"; + private static final Pattern SENSITIVE_QUOTED_KEY_VALUE = Pattern.compile("(?i)([\"']?(?:" + SENSITIVE_KEYS + ")[\"']?\\s*[:=]\\s*)([\"'])(?:bearer|basic)?\\s*.*?\\2"); + private static final Pattern SENSITIVE_UNTERMINATED_QUOTED_KEY_VALUE = Pattern.compile("(?i)([\"']?(?:" + SENSITIVE_KEYS + ")[\"']?\\s*[:=]\\s*)([\"'])(?:bearer|basic)?\\s*[^\"']*$"); + private static final Pattern SENSITIVE_KEY_VALUE = Pattern.compile("(?i)([\"']?(?:" + SENSITIVE_KEYS + ")[\"']?\\s*[:=]\\s*)(?:bearer|basic)?\\s*[^\"'\\s,;)}\\]]+"); + private static final Pattern BEARER_TOKEN = Pattern.compile("(?i)bearer\\s+[a-z0-9._~+/=-]+"); + private static final Pattern BASIC_TOKEN = Pattern.compile("(?i)basic\\s+[a-z0-9._~+/=-]+"); + + public static final ProblemGroup MICRONAUT_BUILD = ProblemGroup.create("micronaut-build", "Micronaut Build"); + public static final ProblemGroup VALIDATION = ProblemGroup.create("validation", "Micronaut Build validation", MICRONAUT_BUILD); + + public static final ProblemId ENFORCED_PLATFORM_NOT_SUPPORTED = validationProblem("enforced-platform-not-supported", "Enforced platform is not supported"); + public static final ProblemId MICRONAUT_VERSION_MISMATCH = validationProblem("micronaut-version-mismatch", "Micronaut version mismatch"); + public static final ProblemId UNSUPPORTED_TEST_FRAMEWORK = validationProblem("unsupported-test-framework", "Unsupported test framework"); + public static final ProblemId INVALID_POM_COORDINATES = validationProblem("invalid-pom-coordinates", "Invalid POM coordinates"); + public static final ProblemId POM_VERIFICATION_FAILED = validationProblem("pom-verification-failed", "POM verification failed"); + public static final ProblemId ASCIIDOC_OUTPUT_VALIDATION_FAILED = validationProblem("asciidoc-output-validation-failed", "Asciidoc output validation failed"); + public static final ProblemId MAVEN_CENTRAL_DEPLOYMENT_FAILED = validationProblem("maven-central-deployment-failed", "Maven Central deployment failed"); + + private MicronautBuildProblems() { + } + + public static RuntimeException throwing(Problems problems, Throwable exception, ProblemId id, Action action) { + return problems.getReporter().throwing(exception, id, spec -> { + spec.severity(Severity.ERROR) + .documentedAt(DOCUMENTATION_URL); + action.execute(spec); + }); + } + + public static String sanitizeDiagnosticText(String value) { + if (value == null || value.isBlank()) { + return ""; + } + boolean inputTruncated = value.length() > SANITIZATION_INPUT_LENGTH; + String sanitized = value.substring(0, Math.min(value.length(), SANITIZATION_INPUT_LENGTH)) + .replaceAll("[\\r\\n\\t]+", " "); + sanitized = SENSITIVE_QUOTED_KEY_VALUE.matcher(sanitized).replaceAll("$1$2$2"); + sanitized = SENSITIVE_UNTERMINATED_QUOTED_KEY_VALUE.matcher(sanitized).replaceAll("$1$2$2"); + sanitized = SENSITIVE_KEY_VALUE.matcher(sanitized).replaceAll("$1"); + sanitized = BEARER_TOKEN.matcher(sanitized).replaceAll("Bearer "); + sanitized = BASIC_TOKEN.matcher(sanitized).replaceAll("Basic "); + if (inputTruncated || sanitized.length() > MAX_DIAGNOSTIC_LENGTH) { + String suffix = "... (truncated)"; + int prefixLength = Math.min(sanitized.length(), MAX_DIAGNOSTIC_LENGTH - suffix.length()); + return sanitized.substring(0, prefixLength) + suffix; + } + return sanitized; + } + + private static ProblemId validationProblem(String name, String displayName) { + return ProblemId.create(name, displayName, VALIDATION); + } +} diff --git a/micronaut-gradle-plugins/src/test/groovy/io/micronaut/build/MavenCentralPublishTaskTest.groovy b/micronaut-gradle-plugins/src/test/groovy/io/micronaut/build/MavenCentralPublishTaskTest.groovy new file mode 100644 index 00000000..1b15b5e6 --- /dev/null +++ b/micronaut-gradle-plugins/src/test/groovy/io/micronaut/build/MavenCentralPublishTaskTest.groovy @@ -0,0 +1,44 @@ +package io.micronaut.build + +import io.micronaut.build.problems.MicronautBuildProblems +import spock.lang.Specification + +class MavenCentralPublishTaskTest extends Specification { + + void "accepts deployment id response body"() { + expect: + MavenCentralPublishTask.extractDeploymentId(" 5f3c1c67-0ec6-4a4b-8246-064f1f61b287 ") == "5f3c1c67-0ec6-4a4b-8246-064f1f61b287" + } + + void "encodes deployment id in status URL"() { + expect: + MavenCentralPublishTask.buildStatusUrl("deployment:abc") == "https://central.sonatype.com/api/v1/publisher/status?id=deployment%3Aabc" + } + + void "rejects credential json upload response as deployment id"() { + given: + def responseBody = '{"token":"abc123","password":"secret","Authorization":"Bearer abc.def","username":"admin"}' + + when: + def deploymentId = MavenCentralPublishTask.extractDeploymentId(responseBody) + def sanitizedBody = MicronautBuildProblems.sanitizeDiagnosticText(responseBody) + + then: + deploymentId == null + !sanitizedBody.contains('abc123') + !sanitizedBody.contains('secret') + !sanitizedBody.contains('abc.def') + !sanitizedBody.contains('admin') + } + + void "rejects credential-like deployment id response body"() { + expect: + MavenCentralPublishTask.extractDeploymentId(responseBody) == null + + where: + responseBody << [ + 'token:abc123', + 'password:secret' + ] + } +} diff --git a/micronaut-gradle-plugins/src/test/groovy/io/micronaut/build/problems/MicronautBuildProblemsTest.groovy b/micronaut-gradle-plugins/src/test/groovy/io/micronaut/build/problems/MicronautBuildProblemsTest.groovy new file mode 100644 index 00000000..a012da8e --- /dev/null +++ b/micronaut-gradle-plugins/src/test/groovy/io/micronaut/build/problems/MicronautBuildProblemsTest.groovy @@ -0,0 +1,58 @@ +package io.micronaut.build.problems + +import spock.lang.Specification + +class MicronautBuildProblemsTest extends Specification { + + void "sanitizes sensitive diagnostic values"() { + expect: + MicronautBuildProblems.sanitizeDiagnosticText('password=secret token:abc Authorization: Bearer abc.def username=admin') == + 'password= token: Authorization: username=' + } + + void "sanitizes sensitive json diagnostic values"() { + when: + def sanitized = MicronautBuildProblems.sanitizeDiagnosticText('{"token":"abc123","password":"secret","Authorization":"Bearer abc.def","username":"admin"}') + + then: + !sanitized.contains('abc123') + !sanitized.contains('secret') + !sanitized.contains('abc.def') + !sanitized.contains('admin') + sanitized == '{"token":"","password":"","Authorization":"","username":""}' + } + + void "sanitizes sensitive quoted json diagnostic values with spaces and commas"() { + when: + def sanitized = MicronautBuildProblems.sanitizeDiagnosticText('{"password":"secret value","token":"abc,def","Authorization":"Bearer abc def","username":"admin user"}') + + then: + !sanitized.contains('secret value') + !sanitized.contains('abc,def') + !sanitized.contains('abc def') + !sanitized.contains('admin user') + sanitized == '{"password":"","token":"","Authorization":"","username":""}' + } + + void "bounds long diagnostic values"() { + when: + def sanitized = MicronautBuildProblems.sanitizeDiagnosticText('a' * 1_100) + + then: + sanitized.length() < 1_100 + sanitized.endsWith('... (truncated)') + } + + void "bounds diagnostic values before sanitizing"() { + given: + def diagnostic = '{"password":"' + ('secret value ' * 200) + '","message":"' + ('a' * 5_000) + '"}' + + when: + def sanitized = MicronautBuildProblems.sanitizeDiagnosticText(diagnostic) + + then: + sanitized.length() <= 1_000 + sanitized.endsWith('... (truncated)') + !sanitized.contains('secret value') + } +}