Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let JarFileLocation work with custom ClassLoader URIs #1131

Merged
merged 6 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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