From 2f91fe25c7eb625948dfb365876e9f7ef92094b6 Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Fri, 8 Aug 2025 22:22:29 +0300 Subject: [PATCH 1/2] Use StringUtils.uriDecode where feasible Signed-off-by: Dmytro Nosan --- .../boot/loader/jar/AsciiBytes.java | 4 - .../boot/loader/jar/JarURLConnection.java | 40 +------ .../net/protocol/jar/JarUrlConnection.java | 5 +- .../net/protocol/jar/UrlJarFileFactory.java | 5 +- .../net/protocol/nested/NestedLocation.java | 5 +- .../boot/loader/net/util/UrlDecoder.java | 107 ------------------ .../boot/loader/net/util/package-info.java | 20 ---- .../loader/net/protocol/jar/JarUrlTests.java | 5 +- .../boot/loader/net/util/UrlDecoderTests.java | 50 -------- .../OpenTelemetryResourceAttributes.java | 42 +------ .../OpenTelemetryResourceAttributesTests.java | 4 +- 11 files changed, 18 insertions(+), 269 deletions(-) delete mode 100644 loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java delete mode 100644 loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java delete mode 100644 loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java diff --git a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java index b1dbf60e47aa..c7458c60df50 100644 --- a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java +++ b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java @@ -235,10 +235,6 @@ public String toString() { return this.string; } - static String toString(byte[] bytes) { - return new String(bytes, StandardCharsets.UTF_8); - } - static int hashCode(CharSequence charSequence) { // We're compatible with String's hashCode() if (charSequence instanceof StringSequence) { diff --git a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java index f61b5864161c..8f853842fdd9 100644 --- a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java +++ b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java @@ -16,18 +16,18 @@ package org.springframework.boot.loader.jar; -import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; -import java.net.URLEncoder; import java.net.URLStreamHandler; import java.nio.charset.StandardCharsets; import java.security.Permission; +import org.springframework.util.StringUtils; + /** * {@link java.net.JarURLConnection} used to support {@link JarFile#getUrl()}. * @@ -307,41 +307,7 @@ private StringSequence decode(StringSequence source) { if (source.isEmpty() || (source.indexOf('%') < 0)) { return source; } - ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length()); - write(source.toString(), bos); - // AsciiBytes is what is used to store the JarEntries so make it symmetric - return new StringSequence(AsciiBytes.toString(bos.toByteArray())); - } - - private void write(String source, ByteArrayOutputStream outputStream) { - int length = source.length(); - for (int i = 0; i < length; i++) { - int c = source.charAt(i); - if (c > 127) { - String encoded = URLEncoder.encode(String.valueOf((char) c), StandardCharsets.UTF_8); - write(encoded, outputStream); - } - else { - if (c == '%') { - if ((i + 2) >= length) { - throw new IllegalArgumentException( - "Invalid encoded sequence \"" + source.substring(i) + "\""); - } - c = decodeEscapeSequence(source, i); - i += 2; - } - outputStream.write(c); - } - } - } - - private char decodeEscapeSequence(String source, int i) { - int hi = Character.digit(source.charAt(i + 1), 16); - int lo = Character.digit(source.charAt(i + 2), 16); - if (hi == -1 || lo == -1) { - throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); - } - return ((char) ((hi << 4) + lo)); + return new StringSequence(StringUtils.uriDecode(source.toString(), StandardCharsets.UTF_8)); } CharSequence toCharSequence() { diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java index ba6521de5de3..687709debdb6 100644 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java @@ -26,6 +26,7 @@ import java.net.URLClassLoader; import java.net.URLConnection; import java.net.URLStreamHandler; +import java.nio.charset.StandardCharsets; import java.security.Permission; import java.util.Collections; import java.util.List; @@ -35,7 +36,7 @@ import java.util.jar.JarFile; import org.springframework.boot.loader.jar.NestedJarFile; -import org.springframework.boot.loader.net.util.UrlDecoder; +import org.springframework.util.StringUtils; /** * {@link java.net.JarURLConnection} alternative to @@ -341,7 +342,7 @@ static JarUrlConnection open(URL url) throws IOException { if ("runtime".equals(url.getRef())) { jarFileUrl = new URL(jarFileUrl, "#runtime"); } - String entryName = UrlDecoder.decode(spec.substring(separator + 2)); + String entryName = StringUtils.uriDecode(spec.substring(separator + 2), StandardCharsets.UTF_8); JarFile jarFile = jarFiles.getOrCreate(true, jarFileUrl); jarFiles.cacheIfAbsent(true, jarFileUrl, jarFile); if (!hasEntry(jarFile, entryName)) { diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java index fad7d3f97c7a..c0bce545b45d 100644 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java @@ -21,6 +21,7 @@ import java.io.InputStream; import java.lang.Runtime.Version; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -28,7 +29,7 @@ import java.util.jar.JarFile; import org.springframework.boot.loader.net.protocol.nested.NestedLocation; -import org.springframework.boot.loader.net.util.UrlDecoder; +import org.springframework.util.StringUtils; /** * Factory used by {@link UrlJarFiles} to create {@link JarFile} instances. @@ -76,7 +77,7 @@ private boolean isLocal(String host) { private JarFile createJarFileForLocalFile(URL url, Runtime.Version version, Consumer closeAction) throws IOException { - String path = UrlDecoder.decode(url.getPath()); + String path = StringUtils.uriDecode(url.getPath(), StandardCharsets.UTF_8); return new UrlJarFile(new File(path), version, closeAction); } diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java index 36eccddd0e21..84d48e9f668f 100644 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java @@ -19,11 +19,12 @@ import java.io.File; import java.net.URI; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.boot.loader.net.util.UrlDecoder; +import org.springframework.util.StringUtils; /** * A location obtained from a {@code nested:} {@link URL} consisting of a jar file and an @@ -75,7 +76,7 @@ public static NestedLocation fromUrl(URL url) { if (url == null || !"nested".equalsIgnoreCase(url.getProtocol())) { throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol"); } - return parse(UrlDecoder.decode(url.toString().substring(7))); + return parse(StringUtils.uriDecode(url.toString().substring(7), StandardCharsets.UTF_8)); } /** diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java deleted file mode 100644 index f455a49a273e..000000000000 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2012-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.loader.net.util; - -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CoderResult; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; - -/** - * Utility to decode URL strings. - * - * @author Phillip Webb - * @since 3.2.0 - */ -public final class UrlDecoder { - - private UrlDecoder() { - } - - /** - * Decode the given string by decoding URL {@code '%'} escapes. This method should be - * identical in behavior to the {@code decode} method in the internal - * {@code sun.net.www.ParseUtil} JDK class. - * @param string the string to decode - * @return the decoded string - */ - public static String decode(String string) { - int length = string.length(); - if ((length == 0) || (string.indexOf('%') < 0)) { - return string; - } - StringBuilder result = new StringBuilder(length); - ByteBuffer byteBuffer = ByteBuffer.allocate(length); - CharBuffer charBuffer = CharBuffer.allocate(length); - CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); - int index = 0; - while (index < length) { - char ch = string.charAt(index); - if (ch != '%') { - result.append(ch); - if (index + 1 >= length) { - return result.toString(); - } - index++; - continue; - } - index = fillByteBuffer(byteBuffer, string, index, length); - decodeToCharBuffer(byteBuffer, charBuffer, decoder); - result.append(charBuffer.flip()); - - } - return result.toString(); - } - - private static int fillByteBuffer(ByteBuffer byteBuffer, String string, int index, int length) { - byteBuffer.clear(); - do { - byteBuffer.put(unescape(string, index)); - index += 3; - } - while (index < length && string.charAt(index) == '%'); - byteBuffer.flip(); - return index; - } - - private static byte unescape(String string, int index) { - try { - return (byte) Integer.parseInt(string, index + 1, index + 3, 16); - } - catch (NumberFormatException ex) { - throw new IllegalArgumentException(); - } - } - - private static void decodeToCharBuffer(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder decoder) { - decoder.reset(); - charBuffer.clear(); - assertNoError(decoder.decode(byteBuffer, charBuffer, true)); - assertNoError(decoder.flush(charBuffer)); - } - - private static void assertNoError(CoderResult result) { - if (result.isError()) { - throw new IllegalArgumentException("Error decoding percent encoded characters"); - } - } - -} diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java deleted file mode 100644 index b9aca34f8091..000000000000 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2012-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Net utilities. - */ -package org.springframework.boot.loader.net.util; diff --git a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java index 2316df7181c9..94af7b159d94 100644 --- a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java +++ b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java @@ -19,13 +19,14 @@ import java.io.File; import java.net.MalformedURLException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.jar.JarEntry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.springframework.boot.loader.net.util.UrlDecoder; +import org.springframework.util.StringUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -93,7 +94,7 @@ void createWithReservedCharsInName() throws Exception { setup(); URL url = JarUrl.create(this.jarFile, "lib.jar", "com/example/My.class"); assertThat(url).hasToString("jar:nested:%s/!lib.jar!/com/example/My.class".formatted(this.jarFileUrlPath)); - assertThat(UrlDecoder.decode(url.toString())).contains(badFolderName); + assertThat(StringUtils.uriDecode(url.toString(), StandardCharsets.UTF_8)).contains(badFolderName); } } diff --git a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java deleted file mode 100644 index 0513f7bb8a53..000000000000 --- a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2012-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.loader.net.util; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link UrlDecoder}. - * - * @author Phillip Webb - */ -class UrlDecoderTests { - - @Test - void decodeWhenBasicString() { - assertThat(UrlDecoder.decode("a/b/C.class")).isEqualTo("a/b/C.class"); - } - - @Test - void decodeWhenHasSingleByteEncodedCharacters() { - assertThat(UrlDecoder.decode("%61/%62/%43.class")).isEqualTo("a/b/C.class"); - } - - @Test - void decodeWhenHasDoubleByteEncodedCharacters() { - assertThat(UrlDecoder.decode("%c3%a1/b/C.class")).isEqualTo("\u00e1/b/C.class"); - } - - @Test - void decodeWhenHasMixtureOfEncodedAndUnencodedDoubleByteCharacters() { - assertThat(UrlDecoder.decode("%c3%a1/b/\u00c7.class")).isEqualTo("\u00e1/b/\u00c7.class"); - } - -} diff --git a/module/spring-boot-opentelemetry/src/main/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributes.java b/module/spring-boot-opentelemetry/src/main/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributes.java index a8c2bf095d5a..a0a0c44dc8a7 100644 --- a/module/spring-boot-opentelemetry/src/main/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributes.java +++ b/module/spring-boot-opentelemetry/src/main/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributes.java @@ -16,7 +16,6 @@ package org.springframework.boot.opentelemetry.autoconfigure; -import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.LinkedHashMap; @@ -129,7 +128,7 @@ private Map getResourceAttributesFromEnv() { if (index > 0) { String key = attribute.substring(0, index); String value = attribute.substring(index + 1); - attributes.put(key.trim(), decode(value.trim())); + attributes.put(key.trim(), StringUtils.uriDecode(value.trim(), StandardCharsets.UTF_8)); } } String otelServiceName = getEnv("OTEL_SERVICE_NAME"); @@ -143,43 +142,4 @@ private Map getResourceAttributesFromEnv() { return this.systemEnvironment.apply(name); } - /** - * Decodes a percent-encoded string. Converts sequences like '%HH' (where HH - * represents hexadecimal digits) back into their literal representations. - *

- * Inspired by {@code org.apache.commons.codec.net.PercentCodec}. - * @param value value to decode - * @return the decoded string - */ - private static String decode(String value) { - if (value.indexOf('%') < 0) { - return value; - } - byte[] bytes = value.getBytes(StandardCharsets.UTF_8); - ByteArrayOutputStream out = new ByteArrayOutputStream(bytes.length); - for (int i = 0; i < bytes.length; i++) { - byte b = bytes[i]; - if (b != '%') { - out.write(b); - continue; - } - int u = decodeHex(bytes, i + 1); - int l = decodeHex(bytes, i + 2); - if (u >= 0 && l >= 0) { - out.write((u << 4) + l); - } - else { - throw new IllegalArgumentException( - "Failed to decode percent-encoded characters at index %d in the value: '%s'".formatted(i, - value)); - } - i += 2; - } - return out.toString(StandardCharsets.UTF_8); - } - - private static int decodeHex(byte[] bytes, int index) { - return (index < bytes.length) ? Character.digit(bytes[index], 16) : -1; - } - } diff --git a/module/spring-boot-opentelemetry/src/test/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributesTests.java b/module/spring-boot-opentelemetry/src/test/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributesTests.java index 7d5d245bdf0b..ebae139464ae 100644 --- a/module/spring-boot-opentelemetry/src/test/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributesTests.java +++ b/module/spring-boot-opentelemetry/src/test/java/org/springframework/boot/opentelemetry/autoconfigure/OpenTelemetryResourceAttributesTests.java @@ -137,7 +137,7 @@ void otelResourceAttributeValuesShouldBePercentDecodedWhenMultiByteSequences() { void illegalArgumentExceptionShouldBeThrownWhenDecodingIllegalHexCharPercentEncodedValue() { this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "key=abc%ß"); assertThatIllegalArgumentException().isThrownBy(this::getAttributes) - .withMessage("Failed to decode percent-encoded characters at index 3 in the value: 'abc%ß'"); + .withMessage("Incomplete trailing escape (%) pattern"); } @Test @@ -150,7 +150,7 @@ void replacementCharShouldBeUsedWhenDecodingNonUtf8Character() { void illegalArgumentExceptionShouldBeThrownWhenDecodingInvalidPercentEncodedValue() { this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "key=%"); assertThatIllegalArgumentException().isThrownBy(this::getAttributes) - .withMessage("Failed to decode percent-encoded characters at index 0 in the value: '%'"); + .withMessage("Incomplete trailing escape (%) pattern"); } @Test From 6cfe880c8b0acdeedd1ab4472d857c2fdcf5b3c3 Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Sat, 9 Aug 2025 14:29:48 +0300 Subject: [PATCH 2/2] Use StringUtils.uriDecode where feasible Signed-off-by: Dmytro Nosan --- .../boot/loader/jar/AsciiBytes.java | 4 + .../boot/loader/jar/JarURLConnection.java | 40 ++++++- .../net/protocol/jar/JarUrlConnection.java | 5 +- .../net/protocol/jar/UrlJarFileFactory.java | 5 +- .../net/protocol/nested/NestedLocation.java | 5 +- .../boot/loader/net/util/UrlDecoder.java | 107 ++++++++++++++++++ .../boot/loader/net/util/package-info.java | 20 ++++ .../loader/net/protocol/jar/JarUrlTests.java | 5 +- .../boot/loader/net/util/UrlDecoderTests.java | 50 ++++++++ 9 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java create mode 100644 loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java create mode 100644 loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java diff --git a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java index c7458c60df50..b1dbf60e47aa 100644 --- a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java +++ b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java @@ -235,6 +235,10 @@ public String toString() { return this.string; } + static String toString(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + static int hashCode(CharSequence charSequence) { // We're compatible with String's hashCode() if (charSequence instanceof StringSequence) { diff --git a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java index 8f853842fdd9..f61b5864161c 100644 --- a/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java +++ b/loader/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java @@ -16,18 +16,18 @@ package org.springframework.boot.loader.jar; +import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; +import java.net.URLEncoder; import java.net.URLStreamHandler; import java.nio.charset.StandardCharsets; import java.security.Permission; -import org.springframework.util.StringUtils; - /** * {@link java.net.JarURLConnection} used to support {@link JarFile#getUrl()}. * @@ -307,7 +307,41 @@ private StringSequence decode(StringSequence source) { if (source.isEmpty() || (source.indexOf('%') < 0)) { return source; } - return new StringSequence(StringUtils.uriDecode(source.toString(), StandardCharsets.UTF_8)); + ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length()); + write(source.toString(), bos); + // AsciiBytes is what is used to store the JarEntries so make it symmetric + return new StringSequence(AsciiBytes.toString(bos.toByteArray())); + } + + private void write(String source, ByteArrayOutputStream outputStream) { + int length = source.length(); + for (int i = 0; i < length; i++) { + int c = source.charAt(i); + if (c > 127) { + String encoded = URLEncoder.encode(String.valueOf((char) c), StandardCharsets.UTF_8); + write(encoded, outputStream); + } + else { + if (c == '%') { + if ((i + 2) >= length) { + throw new IllegalArgumentException( + "Invalid encoded sequence \"" + source.substring(i) + "\""); + } + c = decodeEscapeSequence(source, i); + i += 2; + } + outputStream.write(c); + } + } + } + + private char decodeEscapeSequence(String source, int i) { + int hi = Character.digit(source.charAt(i + 1), 16); + int lo = Character.digit(source.charAt(i + 2), 16); + if (hi == -1 || lo == -1) { + throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); + } + return ((char) ((hi << 4) + lo)); } CharSequence toCharSequence() { diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java index 687709debdb6..ba6521de5de3 100644 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java @@ -26,7 +26,6 @@ import java.net.URLClassLoader; import java.net.URLConnection; import java.net.URLStreamHandler; -import java.nio.charset.StandardCharsets; import java.security.Permission; import java.util.Collections; import java.util.List; @@ -36,7 +35,7 @@ import java.util.jar.JarFile; import org.springframework.boot.loader.jar.NestedJarFile; -import org.springframework.util.StringUtils; +import org.springframework.boot.loader.net.util.UrlDecoder; /** * {@link java.net.JarURLConnection} alternative to @@ -342,7 +341,7 @@ static JarUrlConnection open(URL url) throws IOException { if ("runtime".equals(url.getRef())) { jarFileUrl = new URL(jarFileUrl, "#runtime"); } - String entryName = StringUtils.uriDecode(spec.substring(separator + 2), StandardCharsets.UTF_8); + String entryName = UrlDecoder.decode(spec.substring(separator + 2)); JarFile jarFile = jarFiles.getOrCreate(true, jarFileUrl); jarFiles.cacheIfAbsent(true, jarFileUrl, jarFile); if (!hasEntry(jarFile, entryName)) { diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java index c0bce545b45d..fad7d3f97c7a 100644 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java @@ -21,7 +21,6 @@ import java.io.InputStream; import java.lang.Runtime.Version; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -29,7 +28,7 @@ import java.util.jar.JarFile; import org.springframework.boot.loader.net.protocol.nested.NestedLocation; -import org.springframework.util.StringUtils; +import org.springframework.boot.loader.net.util.UrlDecoder; /** * Factory used by {@link UrlJarFiles} to create {@link JarFile} instances. @@ -77,7 +76,7 @@ private boolean isLocal(String host) { private JarFile createJarFileForLocalFile(URL url, Runtime.Version version, Consumer closeAction) throws IOException { - String path = StringUtils.uriDecode(url.getPath(), StandardCharsets.UTF_8); + String path = UrlDecoder.decode(url.getPath()); return new UrlJarFile(new File(path), version, closeAction); } diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java index 84d48e9f668f..36eccddd0e21 100644 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java @@ -19,12 +19,11 @@ import java.io.File; import java.net.URI; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.util.StringUtils; +import org.springframework.boot.loader.net.util.UrlDecoder; /** * A location obtained from a {@code nested:} {@link URL} consisting of a jar file and an @@ -76,7 +75,7 @@ public static NestedLocation fromUrl(URL url) { if (url == null || !"nested".equalsIgnoreCase(url.getProtocol())) { throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol"); } - return parse(StringUtils.uriDecode(url.toString().substring(7), StandardCharsets.UTF_8)); + return parse(UrlDecoder.decode(url.toString().substring(7))); } /** diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java new file mode 100644 index 000000000000..f455a49a273e --- /dev/null +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java @@ -0,0 +1,107 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.net.util; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +/** + * Utility to decode URL strings. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class UrlDecoder { + + private UrlDecoder() { + } + + /** + * Decode the given string by decoding URL {@code '%'} escapes. This method should be + * identical in behavior to the {@code decode} method in the internal + * {@code sun.net.www.ParseUtil} JDK class. + * @param string the string to decode + * @return the decoded string + */ + public static String decode(String string) { + int length = string.length(); + if ((length == 0) || (string.indexOf('%') < 0)) { + return string; + } + StringBuilder result = new StringBuilder(length); + ByteBuffer byteBuffer = ByteBuffer.allocate(length); + CharBuffer charBuffer = CharBuffer.allocate(length); + CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + int index = 0; + while (index < length) { + char ch = string.charAt(index); + if (ch != '%') { + result.append(ch); + if (index + 1 >= length) { + return result.toString(); + } + index++; + continue; + } + index = fillByteBuffer(byteBuffer, string, index, length); + decodeToCharBuffer(byteBuffer, charBuffer, decoder); + result.append(charBuffer.flip()); + + } + return result.toString(); + } + + private static int fillByteBuffer(ByteBuffer byteBuffer, String string, int index, int length) { + byteBuffer.clear(); + do { + byteBuffer.put(unescape(string, index)); + index += 3; + } + while (index < length && string.charAt(index) == '%'); + byteBuffer.flip(); + return index; + } + + private static byte unescape(String string, int index) { + try { + return (byte) Integer.parseInt(string, index + 1, index + 3, 16); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException(); + } + } + + private static void decodeToCharBuffer(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder decoder) { + decoder.reset(); + charBuffer.clear(); + assertNoError(decoder.decode(byteBuffer, charBuffer, true)); + assertNoError(decoder.flush(charBuffer)); + } + + private static void assertNoError(CoderResult result) { + if (result.isError()) { + throw new IllegalArgumentException("Error decoding percent encoded characters"); + } + } + +} diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java new file mode 100644 index 000000000000..b9aca34f8091 --- /dev/null +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Net utilities. + */ +package org.springframework.boot.loader.net.util; diff --git a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java index 94af7b159d94..2316df7181c9 100644 --- a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java +++ b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java @@ -19,14 +19,13 @@ import java.io.File; import java.net.MalformedURLException; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.util.jar.JarEntry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.springframework.util.StringUtils; +import org.springframework.boot.loader.net.util.UrlDecoder; import static org.assertj.core.api.Assertions.assertThat; @@ -94,7 +93,7 @@ void createWithReservedCharsInName() throws Exception { setup(); URL url = JarUrl.create(this.jarFile, "lib.jar", "com/example/My.class"); assertThat(url).hasToString("jar:nested:%s/!lib.jar!/com/example/My.class".formatted(this.jarFileUrlPath)); - assertThat(StringUtils.uriDecode(url.toString(), StandardCharsets.UTF_8)).contains(badFolderName); + assertThat(UrlDecoder.decode(url.toString())).contains(badFolderName); } } diff --git a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java new file mode 100644 index 000000000000..0513f7bb8a53 --- /dev/null +++ b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.loader.net.util; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UrlDecoder}. + * + * @author Phillip Webb + */ +class UrlDecoderTests { + + @Test + void decodeWhenBasicString() { + assertThat(UrlDecoder.decode("a/b/C.class")).isEqualTo("a/b/C.class"); + } + + @Test + void decodeWhenHasSingleByteEncodedCharacters() { + assertThat(UrlDecoder.decode("%61/%62/%43.class")).isEqualTo("a/b/C.class"); + } + + @Test + void decodeWhenHasDoubleByteEncodedCharacters() { + assertThat(UrlDecoder.decode("%c3%a1/b/C.class")).isEqualTo("\u00e1/b/C.class"); + } + + @Test + void decodeWhenHasMixtureOfEncodedAndUnencodedDoubleByteCharacters() { + assertThat(UrlDecoder.decode("%c3%a1/b/\u00c7.class")).isEqualTo("\u00e1/b/\u00c7.class"); + } + +}