Skip to content

Commit

Permalink
Let JarFileLocation work with custom ClassLoader URIs (#1131)
Browse files Browse the repository at this point in the history
Some `ClassLoader`s that work with repackaged JAR files return custom resource URIs to indicate custom class loading locations. For example, the `ClassLoader` in a packaged Spring Boot app returns the following URI for source package named example: `jar:file:/Path/to/my.jar!/BOOT-INF/classes!/example/`. Note the second "!/" to indicate a classpath root.

Prior to this commit, `JarFileLocation` was splitting paths to a resource at the first "!/" assuming the remainder of the string would depict the actual resource path. That remainder potentially containing a further "!/" would prevent the JAR entry matching in `FromJar.classFilesBeneath(…)` as the entries themselves do not contain the exclamation mark.

This changes the treatment of the URI in `JarFileLocation` to rather use the *last* "!/" as splitting point so that the remainder is a proper path within the `ClassLoader` and the matching in `FromJar.classFilesBeneath(…)` works properly.

Note that in composition with custom `ClassLoader`s frameworks like Spring Boot can also install custom URL handling. In this case ArchUnit can read a class file from a nested archive URL like `jar:file:/some/file.jar!/BOOT-INF/classes!/...` using the standard `URL#openStream()` method in a completely transparent way (compare setup in `SpringLocationsTest`).
  • Loading branch information
codecholeric authored Aug 9, 2023
2 parents b368a25 + 69e3afc commit 1781127
Show file tree
Hide file tree
Showing 11 changed files with 379 additions and 36 deletions.
16 changes: 16 additions & 0 deletions archunit-3rd-party-test/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id 'archunit.java-conventions'
}

ext.moduleName = 'com.tngtech.archunit.thirdpartytest'

dependencies {
testImplementation project(path: ':archunit', configuration: 'shadow')
testImplementation project(path: ':archunit', configuration: 'tests')
testImplementation dependency.springBootLoader
dependency.addGuava { dependencyNotation, config -> testImplementation(dependencyNotation, config) }
testImplementation dependency.log4j_slf4j
testImplementation dependency.junit4
testImplementation dependency.junit_dataprovider
testImplementation dependency.assertj
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.tngtech.archunit.core.importer;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Arrays;
import java.util.Iterator;
import java.util.function.Function;
import java.util.jar.JarFile;
import java.util.stream.Stream;

import com.tngtech.archunit.core.importer.testexamples.SomeEnum;
import com.tngtech.archunit.testutil.SystemPropertiesRule;
import com.tngtech.java.junit.dataprovider.DataProvider;
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
import com.tngtech.java.junit.dataprovider.UseDataProvider;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.loader.LaunchedURLClassLoader;
import org.springframework.boot.loader.archive.Archive;
import org.springframework.boot.loader.archive.JarFileArchive;

import static com.google.common.collect.Iterators.getOnlyElement;
import static com.google.common.collect.MoreCollectors.onlyElement;
import static com.google.common.collect.Streams.stream;
import static com.google.common.io.ByteStreams.toByteArray;
import static com.tngtech.archunit.core.importer.LocationTest.classFileEntry;
import static com.tngtech.archunit.core.importer.LocationTest.urlOfClass;
import static com.tngtech.archunit.core.importer.LocationsTest.unchecked;
import static com.tngtech.java.junit.dataprovider.DataProviders.testForEach;
import static org.assertj.core.api.Assertions.assertThat;

@RunWith(DataProviderRunner.class)
public class SpringLocationsTest {
/**
* Spring Boot configures some system properties that we want to reset afterward (e.g. custom URL stream handler)
*/
@Rule
public final SystemPropertiesRule systemPropertiesRule = new SystemPropertiesRule();

@DataProvider
public static Object[][] springBootJars() {
Function<Function<TestJarFile, TestJarFile>, TestJarFile> createSpringBootJar = setUpJarFile -> setUpJarFile.apply(new TestJarFile())
.withNestedClassFilesDirectory("BOOT-INF/classes")
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath());

return testForEach(
createSpringBootJar.apply(TestJarFile::withDirectoryEntries),
createSpringBootJar.apply(TestJarFile::withoutDirectoryEntries)
);
}

@Test
@UseDataProvider("springBootJars")
public void finds_locations_of_packages_from_Spring_Boot_ClassLoader_for_JARs(TestJarFile jarFileToTest) throws Exception {
try (JarFile jarFile = jarFileToTest.create()) {

configureSpringBootContextClassLoaderKnowingOnly(jarFile);

String jarUri = new File(jarFile.getName()).toURI().toString();
Location location = Locations.ofPackage(SomeEnum.class.getPackage().getName()).stream()
.filter(it -> it.contains(jarUri))
.collect(onlyElement());

byte[] expectedClassContent = toByteArray(urlOfClass(SomeEnum.class).openStream());
Stream<byte[]> actualClassContents = stream(location.asClassFileSource(new ImportOptions()))
.map(it -> unchecked(() -> toByteArray(it.openStream())));

boolean containsExpectedContent = actualClassContents.anyMatch(it -> Arrays.equals(it, expectedClassContent));
assertThat(containsExpectedContent)
.as("one of the found class files has the expected class file content")
.isTrue();
}
}

private static void configureSpringBootContextClassLoaderKnowingOnly(JarFile jarFile) throws IOException {
// This hooks in Spring Boot's own JAR URL protocol handler which knows how to handle URLs with
// multiple separators (e.g. "jar:file:/dir/some.jar!/BOOT-INF/classes!/pkg/some.class")
org.springframework.boot.loader.jar.JarFile.registerUrlProtocolHandler();

try (JarFileArchive jarFileArchive = new JarFileArchive(new File(jarFile.getName()))) {
JarFileArchive bootInfClassArchive = getNestedJarFileArchive(jarFileArchive, "BOOT-INF/classes/");

Thread.currentThread().setContextClassLoader(
new LaunchedURLClassLoader(false, bootInfClassArchive, new URL[]{bootInfClassArchive.getUrl()}, null)
);
}
}

@SuppressWarnings("SameParameterValue")
private static JarFileArchive getNestedJarFileArchive(JarFileArchive jarFileArchive, String path) throws IOException {
Iterator<Archive> archiveCandidates = jarFileArchive.getNestedArchives(entry -> entry.getName().equals(path), entry -> true);
return (JarFileArchive) getOnlyElement(archiveCandidates);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@
import static java.util.stream.Collectors.toList;

class ModuleLocationResolver implements LocationResolver {
private final FromClasspathAndUrlClassLoaders standardResolver = new FromClasspathAndUrlClassLoaders();

@Override
public UrlSource resolveClassPath() {
Iterable<URL> classpath = UrlSource.From.classPathSystemProperties();
Iterable<URL> classpath = standardResolver.resolveClassPath();
Set<ModuleReference> systemModuleReferences = ModuleFinder.ofSystem().findAll();
Set<ModuleReference> configuredModuleReferences = ModuleFinder.of(modulepath()).findAll();
Iterable<URL> modulepath = Stream.concat(systemModuleReferences.stream(), configuredModuleReferences.stream())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public void plugInLocationFactories(InitialConfiguration<Set<Location.Factory>>

@Override
public void plugInLocationResolver(InitialConfiguration<LocationResolver> locationResolver) {
locationResolver.set(new LocationResolver.Legacy());
locationResolver.set(new LocationResolver.FromClasspathAndUrlClassLoaders());
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import java.io.File;
import java.io.IOException;
import java.net.JarURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
Expand All @@ -31,6 +32,7 @@
import java.util.Enumeration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.jar.JarEntry;
Expand Down Expand Up @@ -269,8 +271,8 @@ private static URI newJarUri(URI uri) {
@Override
ClassFileSource asClassFileSource(ImportOptions importOptions) {
try {
String[] parts = uri.toString().split("!/", 2);
return new ClassFileSource.FromJar(new URL(parts[0] + "!/"), parts[1], importOptions);
ParsedUri parsedUri = ParsedUri.from(uri);
return new ClassFileSource.FromJar(new URL(parsedUri.base), parsedUri.path, importOptions);
} catch (IOException e) {
throw new LocationException(e);
}
Expand All @@ -288,28 +290,26 @@ public boolean isArchive() {

@Override
Collection<NormalizedResourceName> readResourceEntries() {
File file = getFileOfJar();
if (!file.exists()) {
return emptySet();
}

return readJarFileContent(file);
return getJarFile().map(this::readJarFileContent).orElse(emptySet());
}

private File getFileOfJar() {
return new File(URI.create(uri.toString()
.replaceAll("^" + SCHEME + ":", "")
.replaceAll("!/.*", "")));
private Optional<JarFile> getJarFile() {
try {
// Note: We can't use a composed JAR URL like `jar:file:/path/to/file.jar!/com/example`, because opening the connection
// fails with an exception if the directory entry for this path is missing (which is possible, even if there is
// a class `com.example.SomeClass` in the JAR file).
String baseUri = ParsedUri.from(uri).base;
JarURLConnection jarUrlConnection = (JarURLConnection) new URL(baseUri).openConnection();
return Optional.of(jarUrlConnection.getJarFile());
} catch (IOException e) {
return Optional.empty();
}
}

private Collection<NormalizedResourceName> readJarFileContent(File fileOfJar) {
private Collection<NormalizedResourceName> readJarFileContent(JarFile jarFile) {
ImmutableList.Builder<NormalizedResourceName> result = ImmutableList.builder();
String prefix = uri.toString().replaceAll(".*!/", "");
try (JarFile jarFile = new JarFile(fileOfJar)) {
result.addAll(readEntries(prefix, jarFile));
} catch (IOException e) {
throw new LocationException(e);
}
String prefix = ParsedUri.from(uri).path;
result.addAll(readEntries(prefix, jarFile));
return result.build();
}

Expand All @@ -324,6 +324,22 @@ private List<NormalizedResourceName> readEntries(String prefix, JarFile jarFile)
}
return result;
}

private static class ParsedUri {
final String base;
final String path;

private ParsedUri(String base, String path) {
this.base = base;
this.path = path;
}

static ParsedUri from(NormalizedUri uri) {
String uriString = uri.toString();
int entryPathStartIndex = uriString.lastIndexOf("!/") + 2;
return new ParsedUri(uriString.substring(0, entryPathStartIndex), uriString.substring(entryPathStartIndex));
}
}
}

private static class FilePathLocation extends Location {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface LocationResolver {
UrlSource resolveClassPath();

@Internal
class Legacy implements LocationResolver {
class FromClasspathAndUrlClassLoaders implements LocationResolver {
@Override
public UrlSource resolveClassPath() {
ImmutableList.Builder<URL> result = ImmutableList.builder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,12 @@ private Path absolutePathOf(Class<?> clazz) throws URISyntaxException {
return new File(urlOfClass(clazz).toURI()).getAbsoluteFile().toPath();
}

private URI jarUriOfEntry(JarFile jarFile, String entry) {
static URI jarUriOfEntry(JarFile jarFile, String entry) {
return jarUriOfEntry(jarFile, NormalizedResourceName.from(entry));
}

private URI jarUriOfEntry(JarFile jarFile, NormalizedResourceName entry) {
return URI.create("jar:" + new File(jarFile.getName()).toURI().toString() + "!/" + entry);
private static URI jarUriOfEntry(JarFile jarFile, NormalizedResourceName entry) {
return URI.create("jar:" + new File(jarFile.getName()).toURI() + "!/" + entry);
}

@Test
Expand Down Expand Up @@ -342,7 +342,7 @@ private static InputStream streamOfClass(Class<?> clazz) {
return clazz.getResourceAsStream(classFileResource(clazz));
}

private static NormalizedResourceName classFileEntry(Class<?> clazz) {
static NormalizedResourceName classFileEntry(Class<?> clazz) {
return NormalizedResourceName.from(classFileResource(clazz));
}

Expand Down
Loading

0 comments on commit 1781127

Please sign in to comment.