From 99e19db81860994bd79a89c2de4980c2de8fa907 Mon Sep 17 00:00:00 2001 From: Jiawei Wnag Date: Sun, 5 Oct 2025 00:52:39 +0800 Subject: [PATCH 1/2] fix the open issue #11434 --- .../micronaut/http/uri/DefaultUriBuilder.java | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/http/src/main/java/io/micronaut/http/uri/DefaultUriBuilder.java b/http/src/main/java/io/micronaut/http/uri/DefaultUriBuilder.java index bd8733d68f4..54291dab010 100644 --- a/http/src/main/java/io/micronaut/http/uri/DefaultUriBuilder.java +++ b/http/src/main/java/io/micronaut/http/uri/DefaultUriBuilder.java @@ -385,11 +385,14 @@ private String buildQueryParams(Map values) { while (nameIterator.hasNext()) { Map.Entry> entry = nameIterator.next(); String rawName = entry.getKey(); - String name = expandOrEncode(rawName, values); + // Query parameter names should be encoded using RFC3986 rules (spaces as %20) + String name = isTemplate(rawName, values) ? UriTemplate.of(rawName).expand(values) : encodeQueryParam(rawName); final Iterator i = entry.getValue().iterator(); while (i.hasNext()) { - String v = expandOrEncode(i.next(), values); + String rawValue = i.next(); + // Query parameter values should be encoded using RFC3986 rules (spaces as %20) + String v = isTemplate(rawValue, values) ? UriTemplate.of(rawValue).expand(values) : encodeQueryParam(rawValue); builder.append(name).append('=').append(v); if (i.hasNext()) { builder.append('&'); @@ -414,7 +417,24 @@ private String expandOrEncode(String value, Map values) return value; } - private String encode(String userInfo) { - return URLEncoder.encode(userInfo, StandardCharsets.UTF_8); + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** + * Encodes a URI component for use in a query string, adhering to RFC3986. + * Spaces are encoded as '%20', not '+'. + * + * @param value The value to encode. + * @return The encoded value. + */ + private String encodeQueryParam(String value) { + // URLEncoder.encode encodes spaces as '+', which is not RFC3986 compliant for query parameters. + // We need to replace '+' with '%20' after encoding. + // Also, URLEncoder.encode doesn't encode '~', which is unreserved in RFC3986 but often encoded. + // However, for query parameters, it's generally safe to leave '~' unencoded. + // The primary concern here is space encoding. + String encoded = URLEncoder.encode(value, StandardCharsets.UTF_8); + return encoded.replace("+", "%20"); } } From c1c6d85964bc363c689995fa4d1f6aaa07c1c57f Mon Sep 17 00:00:00 2001 From: Jiawei Wnag Date: Tue, 7 Oct 2025 12:59:13 +0800 Subject: [PATCH 2/2] upload a reproducer (groovy script) --- reproduce_issue.groovy | 106 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 reproduce_issue.groovy diff --git a/reproduce_issue.groovy b/reproduce_issue.groovy new file mode 100644 index 00000000000..2133306e6f4 --- /dev/null +++ b/reproduce_issue.groovy @@ -0,0 +1,106 @@ +import groovy.transform.Field +import java.io.ByteArrayOutputStream + +@Field +String testSource = """ +package io.micronaut.reproduce + +import io.micronaut.http.uri.UriBuilder +import spock.lang.Specification + +class ReproduceIssueSpec extends Specification { + + def "test UriBuilder encodes spaces as plus not percent20"() { + println "DEBUG: ReproduceIssueSpec test method entered (UriBuilder direct test)." + + given: "a query parameter with a space" + String paramValue = "value with spaces" + + when: "a UriBuilder encodes the parameter" + // The issue states DefaultUriBuilder uses java.net.URLEncoder. + // UriBuilder.of("/").queryParam() eventually uses DefaultUriBuilder. + String encodedQuery = UriBuilder.of("/").queryParam("myParam", paramValue).build().getRawQuery() + + then: "the encoded parameter should use %20 for spaces as per RFC3986" + String expectedEncodedPlus = "myParam=value+with+spaces" // As reported in the bug (W3C HTML 4 / java.net.URLEncoder default) + String expectedEncodedPercent20 = "myParam=value%20with%20spaces" // Expected correct encoding (RFC 3986) + + println "DEBUG: Encoded query: '\$encodedQuery'" + + if (encodedQuery == expectedEncodedPlus) { + println ">>> BUG REPRODUCED: UriBuilder encoded spaces as '+' as per the issue description." + System.exit(129) // Indicate bug reproduced + } else if (encodedQuery == expectedEncodedPercent20) { + println ">>> BUG NOT REPRODUCED: UriBuilder encoded spaces as '%20', meaning the bug is fixed." + System.exit(0) // Indicate bug not reproduced + } else { + println ">>> UNEXPECTED RESULT: Encoded query: '\$encodedQuery'. Neither '+' nor '%20' encoding found." + System.exit(1) // General error, unexpected encoding + } + } +} +""" + +def testDir = new File("test-suite/src/test/groovy/io/micronaut/reproduce") +def testFile = new File(testDir, "ReproduceIssueSpec.groovy") + +try { + testDir.mkdirs() + testFile.write(testSource) + System.out.println "Created test file: ${testFile.absolutePath}" + + // For this simple test, we don't need EmbeddedServer or HttpClient injection. + // Removed -Dmicronaut.test.server.port=-1 as @MicronautTest is no longer used. + // The test should run as a plain Spock test within the existing test-suite. + def command = "./gradlew :test-suite:cleanTest :test-suite:test --tests io.micronaut.reproduce.ReproduceIssueSpec --rerun-tasks --info" + System.out.println "Executing Gradle command: ${command}" + + ByteArrayOutputStream stdout = new ByteArrayOutputStream() + ByteArrayOutputStream stderr = new ByteArrayOutputStream() + + def process = command.execute() + process.waitForProcessOutput(stdout, stderr) // Capture output + + System.out.println "--- Gradle Standard Output ---" + System.out.println stdout.toString() + System.out.println "--- Gradle Standard Error ---" + System.err.println stderr.toString() + System.out.println "-----------------------------" + + def gradleExitCode = process.exitValue() + System.out.println "Gradle command finished with exit code: ${gradleExitCode}" + + String fullOutput = stdout.toString() + stderr.toString() + + // Check for specific messages from the ReproduceIssueSpec to determine if the test ran and what it found. + if (fullOutput.contains(">>> BUG REPRODUCED:")) { + System.out.println "Script wrapper: Test explicitly reported 'BUG REPRODUCED'." + System.exit(129) // Indicate bug reproduced + } else if (fullOutput.contains(">>> BUG NOT REPRODUCED:")) { + System.out.println "Script wrapper: Test explicitly reported 'BUG NOT REPRODUCED'." + System.exit(0) // Indicate bug not reproduced + } else if (fullOutput.contains(">>> UNEXPECTED RESULT:")) { + System.out.println "Script wrapper: Test reported 'UNEXPECTED RESULT'." + System.exit(1) // General error, unexpected encoding + } else if (fullOutput.contains("NO TESTS WERE EXECUTED")) { + System.err.println "Script wrapper: No tests were executed by Gradle. Check test path and build configuration." + System.exit(1) + } else if (gradleExitCode != 0) { + System.err.println "Script wrapper: Gradle command failed with non-zero exit code (${gradleExitCode}). Review Gradle logs for compilation or test execution errors." + System.exit(1) + } else { + // Gradle exited with 0, but no specific test outcome message was found. This indicates the test did not run correctly. + System.err.println "Script wrapper: Gradle command completed with exit code 0, but no expected test outcome or diagnostic messages found. The test might not have run correctly." + System.exit(1) + } +} catch (Exception e) { + System.err.println "Error in Groovy reproduction script wrapper itself:" + e.printStackTrace(System.err) + System.exit(1) // Script error +} finally { + // Clean up the created test file. + if (testFile.exists()) { + testFile.delete() + System.out.println "Deleted test file: ${testFile.absolutePath}" + } +} \ No newline at end of file