`PathMatchingResourcePatternResolver` misses jars on `java.class.path` whose path contains `%`
Affects: spring-core 7.0.9 and 6.2.19 (reproduced), code unchanged on main as of 2026-09-15. JDK 21, macOS.
ClassPathManifestEntry.fixPath escapes # (see #26104) but not %. asJarFileResource builds jar:file:<path>!/ from the raw file path, and opening that URL decodes the path again. A % in a directory name therefore makes the resolver look for a different file:
%followed by two hex digits (host%3A8099) is decoded tohost:8099, so the jar is not found.doFindPathMatchingJarResourcesswallows theNoSuchFileException, and the jar is silently missing fromclasspath*:results rooted at the class path root, such asclasspath*:**/*.class.- Any other
%(100%) makesgetResourcesthrowIllegalArgumentException: Malformed escape pair.
Both happen only for jar roots from addClassPathManifestEntries, i.e. jars on the java.class.path of the system class loader. That is how IDEs and build tools usually launch an application.
How we hit it: Coursier, the resolver used by sbt and Mill, percent-encodes the host in its cache path. Jars from a repository with a port (http://localhost:8099/repository/…) end up in ~/Library/Caches/Coursier/v1/http/localhost%3A8099/…. Vaadin's dev mode scans the whole class path. Its FilterableResourceResolver extends PathMatchingResourcePatternResolver and opens the same jar root URL without catching, so the application fails at startup:
Caused by: java.nio.file.NoSuchFileException: /Users/…/Library/Caches/Coursier/v1/http/localhost:8099/repository/…/togglebutton-3.0.0.jar
at java.base/sun.net.www.protocol.jar.JarURLConnection.getJarFile(JarURLConnection.java:71)
at com.vaadin.flow.spring.io.FilterableResourceResolver.readPackageProperties(FilterableResourceResolver.java:417)
at com.vaadin.flow.spring.io.FilterableResourceResolver.cachePackageProperties(FilterableResourceResolver.java:329)
at com.vaadin.flow.spring.io.FilterableResourceResolver.doFindPathMatchingJarResources(FilterableResourceResolver.java:207)
at org.springframework.core.io.support.PathMatchingResourcePatternResolver.findPathMatchingResources(PathMatchingResourcePatternResolver.java:779)
Reproducer (bash or zsh; SPRING_CORE and COMMONS_LOGGING point to spring-core-7.0.9.jar and commons-logging-1.3.5.jar):
mkdir -p repro/content/demo 'repro/lib/plain' 'repro/lib/host%3A8099' 'repro/lib/100%' && cd repro
echo hello > content/demo/hello.txt
jar cf lib/plain/demo.jar -C content demo
cp lib/plain/demo.jar 'lib/host%3A8099/demo.jar'
cp lib/plain/demo.jar 'lib/100%/demo.jar'
cat > Repro.java <<'EOF'
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
public class Repro {
public static void main(String[] args) throws Exception {
Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath*:**/hello.txt");
System.out.println(resources.length + " found");
for (Resource resource : resources) {
System.out.println(resource.getURL());
}
}
}
EOF
javac -cp "${SPRING_CORE}:${COMMONS_LOGGING}" -d classes Repro.java
for jar in lib/plain/demo.jar 'lib/host%3A8099/demo.jar' 'lib/100%/demo.jar'; do
echo "== ${jar}"
java -cp "classes:${SPRING_CORE}:${COMMONS_LOGGING}:${jar}" Repro 2>&1 | head -3
done
Output:
== lib/plain/demo.jar
1 found
jar:file:/…/repro/lib/plain/demo.jar!/demo/hello.txt
== lib/host%3A8099/demo.jar
0 found
== lib/100%/demo.jar
Exception in thread "main" java.lang.IllegalArgumentException: Malformed escape pair: /…/repro/lib/100%/demo.jar
at java.base/sun.net.www.ParseUtil.decode(ParseUtil.java:207)
at java.base/sun.net.www.protocol.file.Handler.openConnection(Handler.java:69)
The IllegalArgumentException leaves Spring through doFindPathMatchingJarResources(PathMatchingResourcePatternResolver.java:903) ← findPathMatchingResources(:779) ← getResources(:375).
Expected: 1 found for all three jars.
Suggested fix: escape % before # in ClassPathManifestEntry.fixPath:
path = StringUtils.replace(path, "%", "%25");
// Since '#' can appear in directories/filenames, java.net.URL should not treat it as a fragment
return StringUtils.replace(path, "#", "%23");
With this change applied to 7.0.9, the reproducer finds the resource in all three jars, for example jar:file:/…/repro/lib/host%253A8099/demo.jar!/demo/hello.txt. That is the same URL the JDK returns from ClassLoader.getResource("demo/hello.txt") for that jar.
Source: spring-projects/spring-framework