Skip to content

Commit

Permalink
improve tests for scanning packages with custom ClassLoader
Browse files Browse the repository at this point in the history
One way how we tackle scanning class files from the classpath is asking the context `ClassLoader` for `getResources(..)`. We now add an explicit test for this. We also document by a test that `getResources(..)` doesn't do what we want if the directory entries are missing from a JAR and we use some `ClassLoader` derived from `URLClassLoader`. When creating JARs we can choose if we want to add ZIP entries for the folders as well or skip them and only add entries for the actual class files. But in case we're not adding those directory entries, any `URLClassLoader.getResources(..)` will return an empty result when asked for this directory. This unfortunately makes the behavior quite inconsistent. We have some mitigation in place to also analyze the classpath and scan through the JARs on the classpath with a prefix logic that ignores if the entries for the directory are present. But in case we really only have a customized `ClassLoader` without any directory entries in a JAR there is not much the `ClassLoader` API allows to do.

Signed-off-by: Peter Gafert <peter.gafert@tngtech.com>
  • Loading branch information
codecholeric committed Aug 8, 2023
1 parent 3fc943b commit f364fa9
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 5 deletions.
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
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
package com.tngtech.archunit.core.importer;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Arrays;
import java.util.Collection;
import java.util.Set;
import java.util.jar.JarFile;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import com.google.common.collect.ImmutableList;
import com.tngtech.archunit.core.importer.testexamples.SomeEnum;
import com.tngtech.java.junit.dataprovider.DataProvider;
import org.junit.Rule;
import org.junit.Test;

import static com.google.common.collect.Iterables.getOnlyElement;
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.jarUriOfEntry;
import static com.tngtech.archunit.core.importer.LocationTest.urlOfClass;
import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -54,6 +65,7 @@ public void locations_of_packages_within_JAR_URIs() throws Exception {
* Jar file didn't have an entry for the respective folder (e.g. java.io vs /java/io).
*/
@Test
@SuppressWarnings("EmptyTryBlock")
public void locations_of_packages_within_JAR_URIs_that_do_not_contain_package_folder() throws Exception {
independentClasspathRule.configureClasspath();

Expand All @@ -71,6 +83,55 @@ public void locations_of_packages_within_JAR_URIs_that_do_not_contain_package_fo
.hasSize(independentClasspathRule.getNamesOfClasses().size());
}

@Test
@SuppressWarnings("OptionalGetWithoutIsPresent")
public void locations_of_packages_from_custom_ClassLoader_for_JARs_with_directory_entries() throws IOException {
JarFile jarFile = new TestJarFile()
.withDirectoryEntries()
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath())
.create();
URL jarUrl = getJarUrlOf(jarFile);

Thread.currentThread().setContextClassLoader(new URLClassLoader(new URL[]{jarUrl}, null));

Location location = Locations.ofPackage(SomeEnum.class.getPackage().getName()).stream()
.filter(it -> it.contains(jarUrl.toString()))
.findFirst()
.get();

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 actual class files has the expected class file content")
.isTrue();
}

/**
* This is a known limitation for now: If the JAR file doesn't contain directory entries, then asking
* the {@link ClassLoader} for all resources within a directory (which happens when we look for a package)
* will not return anything.
* For this we have some mitigations to additionally search the classpath, but in case this really is
* a highly customized {@link ClassLoader} that doesn't expose any URLs there is not much more we can do.
*/
@Test
public void locations_of_packages_from_custom_ClassLoader_for_JARs_without_directory_entries() throws IOException {
JarFile jarFile = new TestJarFile()
.withoutDirectoryEntries()
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath())
.create();
URL jarUrl = getJarUrlOf(jarFile);

Thread.currentThread().setContextClassLoader(new CustomClassLoader(jarUrl));

String packageName = SomeEnum.class.getPackage().getName();
assertThat(Locations.ofPackage(packageName))
.as("Locations of package '%s'", packageName)
.noneMatch(it -> it.contains(jarUrl.toString()));
}

@Test
public void locations_of_packages_from_mixed_URIs() {
Set<Location> locations = Locations.ofPackage("com.tngtech");
Expand Down Expand Up @@ -104,6 +165,10 @@ public void locations_in_classpath() throws Exception {
);
}

private static URL getJarUrlOf(JarFile jarFile) throws MalformedURLException {
return jarUriOfEntry(jarFile, "").toURL();
}

private Iterable<URI> urisOf(Collection<Location> locations) {
return locations.stream().map(Location::asURI).collect(toSet());
}
Expand All @@ -119,4 +184,33 @@ private URI uriOfFolderOf(Class<?> clazz) throws Exception {
String urlAsString = urlOfClass(clazz).toExternalForm();
return new URL(urlAsString.substring(0, urlAsString.lastIndexOf("/")) + "/").toURI();
}

private static <T> Stream<T> stream(Iterable<T> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}

private <T> T unchecked(ThrowingSupplier<T> supplier) {
try {
return supplier.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@FunctionalInterface
private interface ThrowingSupplier<T> {
T get() throws Exception;
}

private static class CustomClassLoader extends URLClassLoader {
CustomClassLoader(URL... urls) {
super(urls, null);
}

@Override
public URL[] getURLs() {
// Simulate some non-standard ClassLoader by not exposing any URLs we could retrieve from the outside
return new URL[0];
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,23 @@
class TestJarFile {
private final Manifest manifest;
private final Set<String> entries = new HashSet<>();
private boolean withDirectoryEntries = false;

TestJarFile() {
manifest = new Manifest();
manifest.getMainAttributes().put(MANIFEST_VERSION, "1.0");
}

public TestJarFile withDirectoryEntries() {
withDirectoryEntries = true;
return this;
}

public TestJarFile withoutDirectoryEntries() {
withDirectoryEntries = false;
return this;
}

TestJarFile withManifestAttribute(Attributes.Name name, String value) {
manifest.getMainAttributes().put(name, value);
return this;
Expand Down Expand Up @@ -56,8 +67,9 @@ private String createAndReturnName(Supplier<JarFile> createJarFile) {
}

JarFile create(File jarFile) {
Set<String> allEntries = withDirectoryEntries ? ensureDirectoryEntries(entries) : entries;
try (JarOutputStream jarOut = new JarOutputStream(newOutputStream(jarFile.toPath()), manifest)) {
for (String entry : entries) {
for (String entry : allEntries) {
write(jarOut, entry);
}
} catch (IOException e) {
Expand All @@ -66,6 +78,27 @@ JarFile create(File jarFile) {
return newJarFile(jarFile);
}

private Set<String> ensureDirectoryEntries(Set<String> entries) {
Set<String> result = new HashSet<>();
entries.forEach(entry -> {
result.addAll(createDirectoryEntries(entry));
result.add(entry);
});
return result;
}

private static Set<String> createDirectoryEntries(String entry) {
Set<String> result = new HashSet<>();
int checkedUpToIndex = -1;
do {
checkedUpToIndex = entry.indexOf("/", checkedUpToIndex + 1);
if (checkedUpToIndex != -1) {
result.add(entry.substring(0, checkedUpToIndex + 1));
}
} while (checkedUpToIndex != -1);
return result;
}

String createAndReturnName(File jarFile) {
return createAndReturnName(() -> create(jarFile));
}
Expand Down

0 comments on commit f364fa9

Please sign in to comment.