Skip to content

Commit 110fee4

Browse files
committed
Fix PathMatchingResourcePatternResolver manifest classpath discovery
Update `PathMatchingResourcePatternResolver` so that in addition to searching the `java.class.path` system property for classpath enties, it also searches the `MANIFEST.MF` files from within those jars. Prior to this commit, the `addClassPathManifestEntries()` method expected that the JVM had added `Class-Path` manifest entries to the `java.class.path` system property, however, this did not always happen. The updated code now performs a deep search by loading `MANIFEST.MF` files from jars discovered from the system property. To deal with potential performance issue, loaded results are also now cached. The updated code has been tested with Spring Boot 3.3 jars extracted using `java -Djarmode=tools`. See gh-
1 parent f991c19 commit 110fee4

File tree

3 files changed

+255
-36
lines changed

3 files changed

+255
-36
lines changed

spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java

Lines changed: 101 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,18 @@
3939
import java.nio.file.Path;
4040
import java.util.Collections;
4141
import java.util.Enumeration;
42+
import java.util.HashSet;
4243
import java.util.LinkedHashSet;
4344
import java.util.Map;
4445
import java.util.Objects;
4546
import java.util.Set;
47+
import java.util.StringTokenizer;
4648
import java.util.function.Predicate;
49+
import java.util.jar.Attributes;
50+
import java.util.jar.Attributes.Name;
4751
import java.util.jar.JarEntry;
4852
import java.util.jar.JarFile;
53+
import java.util.jar.Manifest;
4954
import java.util.stream.Collectors;
5055
import java.util.stream.Stream;
5156
import java.util.zip.ZipException;
@@ -227,6 +232,9 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol
227232
private static final Predicate<ResolvedModule> isNotSystemModule =
228233
resolvedModule -> !systemModuleNames.contains(resolvedModule.name());
229234

235+
@Nullable
236+
private static Set<ClassPathManifestEntry> classPathManifestEntriesCache;
237+
230238
@Nullable
231239
private static Method equinoxResolveMethod;
232240

@@ -505,25 +513,30 @@ protected void addAllClassLoaderJarRoots(@Nullable ClassLoader classLoader, Set<
505513
* @since 4.3
506514
*/
507515
protected void addClassPathManifestEntries(Set<Resource> result) {
516+
Set<ClassPathManifestEntry> entries = classPathManifestEntriesCache;
517+
if (entries == null) {
518+
entries = getClassPathManifestEntries();
519+
classPathManifestEntriesCache = entries;
520+
}
521+
for (ClassPathManifestEntry entry : entries) {
522+
if (!result.contains(entry.resource()) &&
523+
(entry.alternative() != null && !result.contains(entry.alternative()))) {
524+
result.add(entry.resource());
525+
}
526+
}
527+
}
528+
529+
private Set<ClassPathManifestEntry> getClassPathManifestEntries() {
530+
Set<ClassPathManifestEntry> manifestEntries = new HashSet<>();
531+
Set<File> seen = new HashSet<>();
508532
try {
509-
String javaClassPathProperty = System.getProperty("java.class.path");
510-
for (String path : StringUtils.delimitedListToStringArray(javaClassPathProperty, File.pathSeparator)) {
533+
String paths = System.getProperty("java.class.path");
534+
for (String path : StringUtils.delimitedListToStringArray(paths, File.pathSeparator)) {
511535
try {
512-
String filePath = new File(path).getAbsolutePath();
513-
int prefixIndex = filePath.indexOf(':');
514-
if (prefixIndex == 1) {
515-
// Possibly a drive prefix on Windows (for example, "c:"), so we prepend a slash
516-
// and convert the drive letter to uppercase for consistent duplicate detection.
517-
filePath = "/" + StringUtils.capitalize(filePath);
518-
}
519-
// Since '#' can appear in directories/filenames, java.net.URL should not treat it as a fragment
520-
filePath = StringUtils.replace(filePath, "#", "%23");
521-
// Build URL that points to the root of the jar file
522-
UrlResource jarResource = new UrlResource(ResourceUtils.JAR_URL_PREFIX +
523-
ResourceUtils.FILE_URL_PREFIX + filePath + ResourceUtils.JAR_URL_SEPARATOR);
524-
// Potentially overlapping with URLClassLoader.getURLs() result in addAllClassLoaderJarRoots().
525-
if (!result.contains(jarResource) && !hasDuplicate(filePath, result) && jarResource.exists()) {
526-
result.add(jarResource);
536+
File jar = new File(path).getAbsoluteFile();
537+
if (jar.isFile() && seen.add(jar)) {
538+
manifestEntries.add(ClassPathManifestEntry.of(jar));
539+
manifestEntries.addAll(getClassPathManifestEntriesFromJar(jar));
527540
}
528541
}
529542
catch (MalformedURLException ex) {
@@ -533,34 +546,39 @@ protected void addClassPathManifestEntries(Set<Resource> result) {
533546
}
534547
}
535548
}
549+
return Collections.unmodifiableSet(manifestEntries);
536550
}
537551
catch (Exception ex) {
538552
if (logger.isDebugEnabled()) {
539553
logger.debug("Failed to evaluate 'java.class.path' manifest entries: " + ex);
540554
}
555+
return Collections.emptySet();
541556
}
542557
}
543558

544-
/**
545-
* Check whether the given file path has a duplicate but differently structured entry
546-
* in the existing result, i.e. with or without a leading slash.
547-
* @param filePath the file path (with or without a leading slash)
548-
* @param result the current result
549-
* @return {@code true} if there is a duplicate (i.e. to ignore the given file path),
550-
* {@code false} to proceed with adding a corresponding resource to the current result
551-
*/
552-
private boolean hasDuplicate(String filePath, Set<Resource> result) {
553-
if (result.isEmpty()) {
554-
return false;
555-
}
556-
String duplicatePath = (filePath.startsWith("/") ? filePath.substring(1) : "/" + filePath);
557-
try {
558-
return result.contains(new UrlResource(ResourceUtils.JAR_URL_PREFIX + ResourceUtils.FILE_URL_PREFIX +
559-
duplicatePath + ResourceUtils.JAR_URL_SEPARATOR));
559+
private Set<ClassPathManifestEntry> getClassPathManifestEntriesFromJar(File jar) throws IOException {
560+
File parent = jar.getAbsoluteFile().getParentFile();
561+
try (JarFile jarFile = new JarFile(jar)) {
562+
Manifest manifest = jarFile.getManifest();
563+
Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null;
564+
String classPath = (attributes != null) ? attributes.getValue(Name.CLASS_PATH) : null;
565+
Set<ClassPathManifestEntry> manifestEntries = new HashSet<>();
566+
if (StringUtils.hasLength(classPath)) {
567+
StringTokenizer tokenizer = new StringTokenizer(classPath);
568+
while (tokenizer.hasMoreTokens()) {
569+
File candidate = new File(parent, tokenizer.nextToken());
570+
if (candidate.isFile() && candidate.getCanonicalPath().contains(parent.getCanonicalPath())) {
571+
manifestEntries.add(ClassPathManifestEntry.of(candidate));
572+
}
573+
}
574+
}
575+
return Collections.unmodifiableSet(manifestEntries);
560576
}
561-
catch (MalformedURLException ex) {
562-
// Ignore: just for testing against duplicate.
563-
return false;
577+
catch (Exception ex) {
578+
if (logger.isDebugEnabled()) {
579+
logger.debug("Failed to load manifest entries from jar file '" + jar + "': " + ex);
580+
}
581+
return Collections.emptySet();
564582
}
565583
}
566584

@@ -1062,4 +1080,51 @@ public String toString() {
10621080
}
10631081
}
10641082

1083+
1084+
/**
1085+
* A single {@code Class-Path} manifest entry.
1086+
*/
1087+
private record ClassPathManifestEntry(Resource resource, @Nullable Resource alternative) {
1088+
1089+
private static final String JARFILE_URL_PREFIX = ResourceUtils.JAR_URL_PREFIX + ResourceUtils.FILE_URL_PREFIX;
1090+
1091+
static ClassPathManifestEntry of(File file) throws MalformedURLException {
1092+
String path = fixPath(file.getAbsolutePath());
1093+
Resource resource = asJarFileResource(path);
1094+
Resource alternative = createAlternative(path);
1095+
return new ClassPathManifestEntry(resource, alternative);
1096+
}
1097+
1098+
private static String fixPath(String path) {
1099+
int prefixIndex = path.indexOf(':');
1100+
if (prefixIndex == 1) {
1101+
// Possibly a drive prefix on Windows (for example, "c:"), so we prepend a slash
1102+
// and convert the drive letter to uppercase for consistent duplicate detection.
1103+
path = "/" + StringUtils.capitalize(path);
1104+
}
1105+
// Since '#' can appear in directories/filenames, java.net.URL should not treat it as a fragment
1106+
return StringUtils.replace(path, "#", "%23");
1107+
}
1108+
1109+
/**
1110+
* Return a alternative form of the resource, i.e. with or without a leading slash.
1111+
* @param path the file path (with or without a leading slash)
1112+
* @return the alternative form or {@code null}
1113+
*/
1114+
@Nullable
1115+
private static Resource createAlternative(String path) {
1116+
try {
1117+
String alternativePath = path.startsWith("/") ? path.substring(1) : "/" + path;
1118+
return asJarFileResource(alternativePath);
1119+
}
1120+
catch (MalformedURLException ex) {
1121+
return null;
1122+
}
1123+
}
1124+
1125+
private static Resource asJarFileResource(String path)
1126+
throws MalformedURLException {
1127+
return new UrlResource(JARFILE_URL_PREFIX + path + ResourceUtils.JAR_URL_SEPARATOR);
1128+
}
1129+
}
10651130
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.core.io.support;
18+
19+
import java.io.IOException;
20+
import java.util.List;
21+
22+
/**
23+
* Class packaged into a temporary jar to test
24+
* {@link PathMatchingResourcePatternResolver} detection of classpath manifest
25+
* entries.
26+
*
27+
* @author Phillip Webb
28+
*/
29+
public class ClassPathManifestEntriesTestApplication {
30+
31+
public static void main(String[] args) throws IOException {
32+
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
33+
System.out.println("!!!!" + List.of(resolver.getResources("classpath*:/**/*.txt")));
34+
}
35+
36+
}

spring-core/src/test/java/org/springframework/core/io/support/PathMatchingResourcePatternResolverTests.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,44 @@
1616

1717
package org.springframework.core.io.support;
1818

19+
import java.io.File;
1920
import java.io.FileNotFoundException;
21+
import java.io.FileOutputStream;
2022
import java.io.IOException;
23+
import java.io.InputStream;
2124
import java.io.UncheckedIOException;
25+
import java.net.JarURLConnection;
26+
import java.net.URISyntaxException;
2227
import java.net.URL;
2328
import java.net.URLClassLoader;
29+
import java.net.URLConnection;
30+
import java.nio.charset.StandardCharsets;
31+
import java.nio.file.Files;
2432
import java.nio.file.Path;
2533
import java.nio.file.Paths;
2634
import java.util.Arrays;
35+
import java.util.Enumeration;
2736
import java.util.List;
37+
import java.util.jar.Attributes;
38+
import java.util.jar.Attributes.Name;
39+
import java.util.jar.JarEntry;
40+
import java.util.jar.JarFile;
41+
import java.util.jar.JarOutputStream;
42+
import java.util.jar.Manifest;
2843
import java.util.stream.Collectors;
44+
import java.util.zip.ZipEntry;
2945

46+
import org.apache.commons.logging.LogFactory;
3047
import org.junit.jupiter.api.Nested;
3148
import org.junit.jupiter.api.Test;
49+
import org.junit.jupiter.api.io.TempDir;
3250

3351
import org.springframework.core.io.DefaultResourceLoader;
3452
import org.springframework.core.io.FileSystemResource;
3553
import org.springframework.core.io.Resource;
54+
import org.springframework.util.ClassUtils;
55+
import org.springframework.util.FileSystemUtils;
56+
import org.springframework.util.StreamUtils;
3657
import org.springframework.util.StringUtils;
3758

3859
import static org.assertj.core.api.Assertions.assertThat;
@@ -277,6 +298,103 @@ void rootPatternRetrievalInJarFiles() throws IOException {
277298
}
278299
}
279300

301+
@Nested
302+
class ClassPathManifestEntries {
303+
304+
@TempDir
305+
Path temp;
306+
307+
@Test
308+
void javaDashJarFindsClassPathManifestEntries() throws Exception {
309+
Path lib = this.temp.resolve("lib");
310+
Files.createDirectories(lib);
311+
writeAssetJar(lib.resolve("asset.jar"));
312+
writeApplicationJar(this.temp.resolve("app.jar"));
313+
String java = ProcessHandle.current().info().command().get();
314+
Process process = new ProcessBuilder(java, "-jar", "app.jar")
315+
.directory(this.temp.toFile())
316+
.start();
317+
assertThat(process.waitFor()).isZero();
318+
String result = StreamUtils.copyToString(process.getInputStream(), StandardCharsets.UTF_8);
319+
assertThat(result.replace("\\", "/")).contains("!!!!").contains("/lib/asset.jar!/assets/file.txt");
320+
}
321+
322+
private void writeAssetJar(Path path) throws Exception {
323+
try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(path.toFile()))) {
324+
jar.putNextEntry(new ZipEntry("assets/"));
325+
jar.closeEntry();
326+
jar.putNextEntry(new ZipEntry("assets/file.txt"));
327+
StreamUtils.copy("test", StandardCharsets.UTF_8, jar);
328+
jar.closeEntry();
329+
}
330+
}
331+
332+
private void writeApplicationJar(Path path) throws Exception {
333+
Manifest manifest = new Manifest();
334+
Attributes mainAttributes = manifest.getMainAttributes();
335+
mainAttributes.put(Name.CLASS_PATH, buildSpringClassPath() + "lib/asset.jar");
336+
mainAttributes.put(Name.MAIN_CLASS, ClassPathManifestEntriesTestApplication.class.getName());
337+
mainAttributes.put(Name.MANIFEST_VERSION, "1.0");
338+
try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(path.toFile()), manifest)) {
339+
String appClassResource = ClassUtils.convertClassNameToResourcePath(
340+
ClassPathManifestEntriesTestApplication.class.getName())
341+
+ ClassUtils.CLASS_FILE_SUFFIX;
342+
String folder = "";
343+
for (String name : appClassResource.split("/")) {
344+
if (!name.endsWith(ClassUtils.CLASS_FILE_SUFFIX)) {
345+
folder += name + "/";
346+
jar.putNextEntry(new ZipEntry(folder));
347+
jar.closeEntry();
348+
}
349+
else {
350+
jar.putNextEntry(new ZipEntry(folder + name));
351+
try (InputStream in = getClass().getResourceAsStream(name)) {
352+
in.transferTo(jar);
353+
}
354+
jar.closeEntry();
355+
}
356+
}
357+
}
358+
}
359+
360+
private String buildSpringClassPath() throws Exception {
361+
return copyClasses(PathMatchingResourcePatternResolver.class, "spring-core")
362+
+ copyClasses(LogFactory.class, "commons-logging");
363+
}
364+
365+
private String copyClasses(Class<?> sourceClass, String destinationName)
366+
throws URISyntaxException, IOException {
367+
Path destination = this.temp.resolve(destinationName);
368+
String resourcePath = ClassUtils.convertClassNameToResourcePath(sourceClass.getName())
369+
+ ClassUtils.CLASS_FILE_SUFFIX;
370+
URL resource = getClass().getClassLoader().getResource(resourcePath);
371+
URL url = new URL(resource.toString().replace(resourcePath, ""));
372+
URLConnection connection = url.openConnection();
373+
if (connection instanceof JarURLConnection jarUrlConnection) {
374+
try (JarFile jarFile = jarUrlConnection.getJarFile()) {
375+
Enumeration<JarEntry> entries = jarFile.entries();
376+
while (entries.hasMoreElements()) {
377+
JarEntry entry = entries.nextElement();
378+
if (!entry.isDirectory()) {
379+
Path entryPath = destination.resolve(entry.getName());
380+
try (InputStream in = jarFile.getInputStream(entry)) {
381+
Files.createDirectories(entryPath.getParent());
382+
Files.copy(in, destination.resolve(entry.getName()));
383+
}
384+
}
385+
}
386+
}
387+
}
388+
else {
389+
File source = new File(url.toURI());
390+
Files.createDirectories(destination);
391+
FileSystemUtils.copyRecursively(source, destination.toFile());
392+
}
393+
return destinationName + "/ ";
394+
}
395+
396+
}
397+
280398

281399
private void assertFilenames(String pattern, String... filenames) {
282400
assertFilenames(pattern, false, filenames);

0 commit comments

Comments
 (0)