Skip to content

Commit f4c473a

Browse files
fix(spring): handle Unicode classpath resource paths (#24220)
## Summary Fix Spring classpath resource matching when an application is located under a directory whose path contains encoded Unicode or decomposed Unicode characters. `CustomResourceLoader` previously compared resource paths from `URL#getPath()`, which can leave parts of the classpath root percent-encoded. When Spring later returned class resources using a decoded path, the resource no longer matched its parent root and startup could fail with `Parent resource ... not found in the resources!`. This can happen even when both paths refer to the same filesystem location. The issue is that the compared strings are not in the same representation: - `URL#getPath()` can return a path where non-ASCII characters are still percent-encoded, for example `%C3%A7` or `%CC%A7`. - Spring resource resolution can later return the corresponding class resource using a decoded filesystem path. - A raw string comparison then fails, even though both paths point to the same location. This is especially easy to reproduce with decomposed Unicode characters. A decomposed character is represented as a base character plus one or more combining marks instead of a single precomposed code point. For example, `ç` can be represented either as the single code point `U+00E7`, or as `c` plus the combining cedilla `U+0327`. Visually the path can look correct, but the encoded URL path and the decoded filesystem path are different strings. The culprit was therefore not Unicode normalization itself, but comparing URL-encoded paths with decoded paths during parent/child classpath resource matching. This change normalizes resource paths through `URL#toURI().getPath()` for comparisons, while keeping the original URL path for dev-mode cache keys. It also keeps the existing native-image `file:///resources!` handling and falls back to the original URL path if URI conversion is not possible. The key piece needed to make the fix work is using the decoded URI path for comparable resource paths: ```java resource.getURL().toURI().getPath() ``` instead of relying on the raw URL path for matching: ```java resource.getURL().getPath() ``` This follows the JDK recommendation for URL escaping handling. `URL` does not itself encode or decode URL components, and the recommended way to manage URL encoding and decoding is to use `URI` and convert between `URL` and `URI`. Recommended reference: https://docs.oracle.com/javase/8/docs/api/java/net/URL.html Closes #11871 ## Implementation note The regression test needs to exercise `CustomResourceLoader` directly. I found existing Flow tests using both patterns: some use reflection to reach private implementation details, and others keep implementation types package-private so same-package tests can instantiate them directly. I chose to make `CustomResourceLoader` package-private instead of using reflection, because it keeps the test simpler while still avoiding public API exposure. If the maintainers prefer preserving the private nested class, I can switch the test back to reflection and make `CustomResourceLoader` private again. The important behavioral change is limited to the comparable path used for parent/child resource matching. The original URL path is still preserved where the previous encoded form is required, such as dev-mode cache lookup keys. ## Testing - Reproduced the issue with a minimal Spring Boot/Vaadin application in a project path containing `François` and a decomposed Unicode segment. Before the fix, the app failed during test startup with `Parent resource ... not found in the resources!`. - Verified the same minimal application starts/tests successfully after installing the fixed local Flow artifacts. - Added regression coverage in `VaadinServletContextInitializerTest` for a classpath root containing Unicode and decomposed Unicode characters. - Verified the regression test is meaningful: with only the production fix temporarily reverted, the new test fails with `Parent resource ... not found in the resources!`; with the fix restored, the same test passes. Local checks run: ```bash mvn -q -P!install-git-hooks -pl vaadin-spring spotless:check mvn -q -P!install-git-hooks -pl vaadin-spring -Dtest=VaadinServletContextInitializerTest test mvn -q -P!install-git-hooks -pl vaadin-spring -Dtest=VaadinServletContextInitializerTest#customResourceLoader_classpathRootContainsUnicodeCombiningCharacter_resourcesAreMatched test mvn -q -P!install-git-hooks -pl vaadin-spring -am -DskipTests -Dexec.skip=true install ``` ## AI Disclosure Code drafted with OpenClaw/Codex for contributor review. Tests were added and run locally by the assistant. I reviewed the code and this description before opening the PR. Co-authored-by: Mikhail Shabarov <61410877+mshabarov@users.noreply.github.com>
1 parent 3fcd491 commit f4c473a

2 files changed

Lines changed: 66 additions & 13 deletions

File tree

vaadin-spring/src/main/java/com/vaadin/flow/spring/VaadinServletContextInitializer.java

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.io.IOException;
2727
import java.io.Serializable;
2828
import java.lang.annotation.Annotation;
29+
import java.net.URISyntaxException;
2930
import java.util.ArrayList;
3031
import java.util.Arrays;
3132
import java.util.Collection;
@@ -976,8 +977,7 @@ private static void collectHandleTypes(Class<?>[] handleTypes,
976977
* For npm we scan all packages. For performance reasons and due to problems
977978
* with atmosphere we skip known packaged from our resources collection.
978979
*/
979-
private static class CustomResourceLoader
980-
extends FilterableResourceResolver {
980+
static class CustomResourceLoader extends FilterableResourceResolver {
981981

982982
private final PrefixTree scanNever = new PrefixTree(DEFAULT_SCAN_NEVER);
983983

@@ -1048,15 +1048,8 @@ private Resource[] collectResources(String locationPattern)
10481048

10491049
for (Resource resource : super.getResources(locationPattern)) {
10501050
String originalPath = resource.getURL().getPath();
1051-
String path;
1052-
if (originalPath.startsWith("file:///resources!")) {
1053-
// It's a resource from a native build, remove the
1054-
// prefix from URL path
1055-
path = originalPath
1056-
.substring("file:///resources!".length());
1057-
} else {
1058-
path = originalPath;
1059-
}
1051+
final String path = getComparableResourcePath(resource,
1052+
originalPath);
10601053

10611054
if (isDevModeCacheUsed() && skipped.contains(originalPath)) {
10621055
continue;
@@ -1066,8 +1059,8 @@ private Resource[] collectResources(String locationPattern)
10661059
resources.add(resource);
10671060
// Restore root paths to ensure new resources are correctly
10681061
// validate and cached after a reload
1069-
if (originalPath.endsWith("/")) {
1070-
rootPaths.add(originalPath);
1062+
if (path.endsWith("/")) {
1063+
rootPaths.add(path);
10711064
}
10721065
} else {
10731066
if (path.endsWith(".jar!/")) {
@@ -1124,6 +1117,26 @@ private Resource[] collectResources(String locationPattern)
11241117
return resources.toArray(new Resource[0]);
11251118
}
11261119

1120+
private String getComparableResourcePath(Resource resource,
1121+
String fallbackPath) throws IOException {
1122+
try {
1123+
String path = resource.getURL().toURI().getPath();
1124+
return stripNativeImageResourcePrefix(
1125+
path == null ? fallbackPath : path);
1126+
} catch (IllegalArgumentException | URISyntaxException exception) {
1127+
return stripNativeImageResourcePrefix(fallbackPath);
1128+
}
1129+
}
1130+
1131+
private String stripNativeImageResourcePrefix(String path) {
1132+
if (path.startsWith("file:///resources!")) {
1133+
// It's a resource from a native build, remove the prefix from
1134+
// URL path.
1135+
return path.substring("file:///resources!".length());
1136+
}
1137+
return path;
1138+
}
1139+
11271140
private boolean isDevModeCacheUsed() {
11281141
return !filterOnlyByPackageProperties && devModeCachingEnabled;
11291142
}

vaadin-spring/src/test/java/com/vaadin/flow/spring/VaadinServletContextInitializerTest.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
import jakarta.servlet.ServletContextEvent;
2020
import jakarta.servlet.ServletContextListener;
2121

22+
import java.io.IOException;
23+
import java.net.URL;
24+
import java.net.URLClassLoader;
25+
import java.nio.file.Files;
26+
import java.nio.file.Path;
27+
import java.util.Arrays;
2228
import java.util.Collections;
2329
import java.util.HashMap;
2430
import java.util.Map;
@@ -29,13 +35,16 @@
2935
import org.junit.jupiter.api.AfterEach;
3036
import org.junit.jupiter.api.BeforeEach;
3137
import org.junit.jupiter.api.Test;
38+
import org.junit.jupiter.api.io.TempDir;
3239
import org.mockito.Mock;
3340
import org.mockito.MockedStatic;
3441
import org.mockito.Mockito;
3542
import org.mockito.MockitoAnnotations;
3643
import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
3744
import org.springframework.context.ApplicationContext;
3845
import org.springframework.core.env.Environment;
46+
import org.springframework.core.io.DefaultResourceLoader;
47+
import org.springframework.core.io.Resource;
3948

4049
import com.vaadin.flow.component.Component;
4150
import com.vaadin.flow.di.Lookup;
@@ -53,6 +62,7 @@
5362
import com.vaadin.flow.server.startup.ApplicationRouteRegistry;
5463
import com.vaadin.flow.server.startup.ServletDeployer;
5564

65+
import static org.assertj.core.api.Assertions.assertThat;
5666
import static org.junit.jupiter.api.Assertions.assertEquals;
5767

5868
class VaadinServletContextInitializerTest {
@@ -216,6 +226,36 @@ public int setErrorParameter(BeforeEnterEvent event,
216226
assertEquals(TestErrorView.class, navigationTarget);
217227
}
218228

229+
@Test
230+
void customResourceLoader_classpathRootContainsUnicodeCombiningCharacter_resourcesAreMatched(
231+
@TempDir Path tempDir) throws Exception {
232+
Path classesRoot = tempDir.resolve("François")
233+
.resolve("vaadin-c\u0327-repro").resolve("target/classes");
234+
Path applicationClass = classesRoot
235+
.resolve("com/example/application/Application.class");
236+
Files.createDirectories(applicationClass.getParent());
237+
Files.write(applicationClass, new byte[] { 0 });
238+
239+
try (URLClassLoader classLoader = new URLClassLoader(
240+
new URL[] { classesRoot.toUri().toURL() }, null)) {
241+
Resource[] resources = new VaadinServletContextInitializer.CustomResourceLoader(
242+
new DefaultResourceLoader(classLoader)).getResources(
243+
"classpath*:com/example/application/**/*.class");
244+
245+
assertThat(resources).as(Arrays.toString(resources)).anySatisfy(
246+
resource -> assertThat(getResourcePath(resource)).endsWith(
247+
"/com/example/application/Application.class"));
248+
}
249+
}
250+
251+
private static String getResourcePath(Resource resource) {
252+
try {
253+
return resource.getURI().getPath();
254+
} catch (IOException exception) {
255+
throw new IllegalStateException(exception);
256+
}
257+
}
258+
219259
private Runnable initRouteNotFoundMocksAndGetContextInitializedMockCall(
220260
VaadinServletContextInitializer vaadinServletContextInitializer)
221261
throws Exception {

0 commit comments

Comments
 (0)