Repository: classgraph/classgraph (Java)

Issue:
Paths with Spaces and Hash don't work when using Nested JARs
Lately we noticed an issue when starting a Spring Boot Executable JAR from a path that uses the archived artifacts of our CI system. When investigating it further we found out that basically everything we scan for on the classpath via ClassGraph is no longer found when running it from such a directory. Compared to an older branch this seemed to be the result of our upgrade from ClassGraph  `4.8.48` to `4.8.163`.

When taking a deeper look and checking the `verbose()` output of ClassGraph we saw that all nested classpath entries were discarded because of a _Malformed URI_.

You get an error message like:

```
2023-11-02T12:51:42.719+0100	ClassGraph	-- Skipping invalid classpath entry C:/Projects/_TMP/_User/junit4299279437227201409/directory with spaces #123/spring-boot-fully-executable-jar.jar!/BOOT-INF/lib/spring-boot-starter-actuator-1.5.9.RELEASE.jar : java.io.IOException: Malformed URI: C:/Projects/_TMP/_User/junit4299279437227201409/directory with spaces #123/spring-boot-fully-executable-jar.jar!/BOOT-INF/lib/spring-boot-starter-actuator-1.5.9.RELEASE.jar : java.net.URISyntaxException: Illegal character in opaque part at index 66: jar:file:C:/Projects/_TMP/_User/junit4299279437227201409/directory with spaces #123/spring-boot-fully-executable-jar.jar!/BOOT-INF/lib/spring-boot-starter-actuator-1.5.9.RELEASE.jar
```

The directories our CI system produces are in the form of `ci-build product #buildnumber`. So we played around a bit with that path and it seems the combination of the spaces and the hash symbol break the ClassGraph logic vor handling nested jars.

We checked the code and it seems that the base path of the file is handled as a `String` in the nested use case and only later again converted to a `URL` or as a fallback `URI`. 

Given the following path, both conversions will fail:

`jar:file:C:/Projects/_TMP/_User/junit4299279437227201409/directory with spaces #123/spring-boot-fully-executable-jar.jar!/BOOT-INF/lib/spring-core-4.3.13.RELEASE.jar`

The reason for this is that first the `URL` conversion will fail with `java.net.MalformedURLException: no !/ in spec` as it will not be happy about the hash symbol (or rather what comes behind it I guess). After this the `URI` conversion will fail with final error `java.net.URISyntaxException: Illegal character in opaque part at index 66: jar:file:...` where the index is pointing to the first space present in the path.


Discussion:
I tried to also bisect it between `4.8.48` and `4.8.163` but didn't really succeed as I had trouble with some intermediate commits. (though it seems to break initially between `4.8.141` and `4.8.142` But judging from the complete diff the related code has significantly changed between those two versions.

The PR contains a test case for different combinations of spaces and hash symbols. I could reproduce the issue on both Linux and Windows so it seems to not be specific.

Also the proposed fix is only what I could come up with the minimal knowledge about all the ClassGraph internals. If you have a better idea how/where to fix it in a better way I'm happy to adjust the fix.
@jwachter Thanks for the bug report, and for tracking down a fix!

Yes, the changes I made around that time were complex, and it's because it's a zoo out there in the classpath world. Fixing one thing inevitably breaks something else.

Speaking of which, I have a feeling that the change you made may break things for another user, but I think your change is appropriate. For this other user, they had the character trigram `%20` in path segments of files (i.e. in actual filenames on disk), already escaped.

I think your changes are optimal in the average case, however (and at the very least they avoid an exception), so I'll merge this change and push out a fix.
Released in 4.8.164. Thank you for this exemplary bug report and PR, complete with testcase!



Patch:
diff --git a/src/main/java/io/github/classgraph/Scanner.java b/src/main/java/io/github/classgraph/Scanner.java
index 857f863ec..5b99befe1 100644
--- a/src/main/java/io/github/classgraph/Scanner.java
+++ b/src/main/java/io/github/classgraph/Scanner.java
@@ -402,6 +402,9 @@ private static Object normalizeClasspathEntry(final Object classpathEntryObj) th
             final boolean isURL = JarUtils.URL_SCHEME_PATTERN.matcher(classpathEntStr).matches();
             final boolean isMultiSection = classpathEntStr.contains("!");
             if (isURL || isMultiSection) {
+                // Encode spaces and hash symbols in classpath entry as they potentially can be invalid when
+                // converted to a URL/URI
+                classpathEntStr = classpathEntStr.replace(" ", "%20").replace("#", "%23");
                 // Convert back to URL (or URI) if this has a URL scheme or if this is a multi-section
                 // path (which needs the "jar:file:" scheme)
                 if (!isURL) {


Test patch:
diff --git a/src/test/java/io/github/classgraph/issues/issue804/Issue804Test.java b/src/test/java/io/github/classgraph/issues/issue804/Issue804Test.java
new file mode 100644
index 000000000..372dc94d5
--- /dev/null
+++ b/src/test/java/io/github/classgraph/issues/issue804/Issue804Test.java
@@ -0,0 +1,66 @@
+package io.github.classgraph.issues.issue804;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import io.github.classgraph.ClassGraph;
+import io.github.classgraph.ScanResult;
+
+/**
+ * Issue 804.
+ */
+public class Issue804Test {
+
+	private static final String NESTED_EXAMPLE_CLASS = "org.springframework.util.ResourceUtils";
+
+	@Test
+	void scanningNestedJarsInPathsContainingSpacesShouldNeverFail(@TempDir Path tempDir) throws IOException {
+		Path targetJar = createSpringBootJarInExampleDirectory(tempDir, "directory with spaces");
+
+		try (ScanResult scanResult = scanJar(targetJar)) {
+			assertThat(scanResult.getClassInfo(NESTED_EXAMPLE_CLASS)).isNotNull();
+		}
+	}
+
+	@Test
+	void scanningNestedJarsInPathsContainingHashesShouldNeverFail(@TempDir Path tempDir) throws IOException {
+		Path targetJar = createSpringBootJarInExampleDirectory(tempDir, "directory-without-spaces#123");
+
+		try (ScanResult scanResult = scanJar(targetJar)) {
+			assertThat(scanResult.getClassInfo(NESTED_EXAMPLE_CLASS)).isNotNull();
+		}
+	}
+
+	@Test
+	void scanningNestedJarsInPathsContainingSpacesAndHashesShouldNeverFail(@TempDir Path tempDir) throws IOException {
+		Path targetJar = createSpringBootJarInExampleDirectory(tempDir, "directory with spaces #123");
+
+		try (ScanResult scanResult = scanJar(targetJar)) {
+			assertThat(scanResult.getClassInfo(NESTED_EXAMPLE_CLASS)).isNotNull();
+		}
+	}
+
+	private Path createSpringBootJarInExampleDirectory(Path temporaryDirectory, String directoryName)
+			throws IOException {
+		Path directoryWithSpaces = temporaryDirectory.resolve(directoryName);
+		Files.createDirectories(directoryWithSpaces);
+		Path nestedJar = directoryWithSpaces.resolve("spring-boot-fully-executable-jar.jar");
+		try (InputStream nestedJarsExample = Issue804Test.class.getClassLoader()
+				.getResourceAsStream("spring-boot-fully-executable-jar.jar")) {
+			Files.copy(nestedJarsExample, nestedJar);
+		}
+		return nestedJar;
+	}
+
+	private ScanResult scanJar(Path targetJar) {
+		return new ClassGraph().enableClassInfo().overrideClasspath(targetJar.toUri()).scan();
+	}
+
+}