Skip to content

Commit

Permalink
fixup! Let JarFileLocation work with custom ClassLoader URIs.
Browse files Browse the repository at this point in the history
  • Loading branch information
codecholeric committed Aug 5, 2023
1 parent a5cf00c commit 34009ca
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 37 deletions.
15 changes: 15 additions & 0 deletions archunit-3rd-party-test/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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.assertj
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.tngtech.archunit.core.importer;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.util.Arrays;
import java.util.Iterator;
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 org.junit.Rule;
import org.junit.Test;
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.archunit.core.importer.UrlSourceTest.JAVA_CLASS_PATH_PROP;
import static org.assertj.core.api.Assertions.assertThat;

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();

@Test
public void finds_locations_of_packages_from_Spring_Boot_ClassLoader_for_JARs_with_directory_entries() throws Exception {
try (JarFile jarFile = new TestJarFile()
.withDirectoryEntries()
.withNestedClassFilesDirectory("BOOT-INF/classes")
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath())
.create()) {

configureSpringBootContextClassLoaderKnowingOnly(jarFile);

Location location = Locations.ofPackage(SomeEnum.class.getPackage().getName()).stream()
.filter(it -> it.contains(jarFile.getName()))
.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();
}
}

/**
* This is not "desired" behavior, but just to document the state as it is right now. I.e. if there was a
* Spring Boot JAR that only has a {@code BOOT-INF/classes/} directory entry, but no further directory entries
* for the packages contained, then we wouldn't find the classes in this package right now.
* Since we don't know if this really happens out there in the wild we keep it like this for now and see if
* this problem will ever occur (because to solve it is likely not trivial at all and might in the end require
* a custom extension point for frameworks).
* Note that we don't need to test the case where {@code BOOT-INF/classes/} has no directory entry,
* because in that case deriving the nested archive already fails with an exception. So it's a valid assumption
* that this directory entry always exists out in the wild.
*/
@Test
public void does_not_find_locations_of_packages_from_Spring_Boot_ClassLoader_for_JARs_without_directory_entries() throws Exception {
try (JarFile jarFile = new TestJarFile()
.withoutDirectoryEntries()
.withNestedClassFilesDirectory("BOOT-INF/classes")
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath())
.create()) {

configureSpringBootContextClassLoaderKnowingOnly(jarFile);

// Also configure classpath to match, so we principally would have a chance to find the class file there
System.setProperty(JAVA_CLASS_PATH_PROP, uriOf(jarFile));

// We still don't find any class files, because reading the JAR from the classpath gives us
// resource entries of the form `BOOT-INF/classes/...` which don't match the expected package name
// `com.tngtech...`. Without telling ArchUnit somehow that there is a nested archive
// within `BOOT-INF/classes` there doesn't seem to be any robust solution for this
// (unless the classpath would contain a URL of the form `jar:file:/.../some.jar!/BOOT-INF/classes!/`
// with two separators, then the customized URL handling would kick in. But realistic usage AFAIS just adds
// the base JAR URL and then lets the Spring Boot `JarLauncher` handle the nested archive logic).
Stream<Location> locationsInMatchingJarFile = Locations.ofPackage(SomeEnum.class.getPackage().getName()).stream()
.filter(it -> it.contains(jarFile.getName()));
assertThat(locationsInMatchingJarFile).isEmpty();
}
}

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);
}

private static String uriOf(JarFile jarFile) {
return URI.create("jar:" + new File(jarFile.getName()).toURI()).toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,9 @@ private static URI newJarUri(URI uri) {
ClassFileSource asClassFileSource(ImportOptions importOptions) {
try {
String uriString = uri.toString();
int index = uriString.lastIndexOf("!/");
String[] parts = { uriString.substring(0, index), uriString.substring(index + 2) };
return new ClassFileSource.FromJar(new URL(parts[0] + "!/"), parts[1], importOptions);
int entryPathStartIndex = uriString.lastIndexOf("!/") + 2;
String[] parts = {uriString.substring(0, entryPathStartIndex), uriString.substring(entryPathStartIndex)};
return new ClassFileSource.FromJar(new URL(parts[0]), parts[1], importOptions);
} catch (IOException e) {
throw new LocationException(e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ private static <T> Stream<T> stream(Iterable<T> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}

private <T> T unchecked(ThrowingSupplier<T> supplier) {
static <T> T unchecked(ThrowingSupplier<T> supplier) {
try {
return supplier.get();
} catch (Exception e) {
Expand All @@ -197,7 +197,7 @@ private <T> T unchecked(ThrowingSupplier<T> supplier) {
}

@FunctionalInterface
private interface ThrowingSupplier<T> {
interface ThrowingSupplier<T> {
T get() throws Exception;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;

import com.tngtech.archunit.testutil.TestUtils;
Expand All @@ -20,6 +24,7 @@

class TestJarFile {
private final Manifest manifest;
private Optional<String> nestedClassFilesDirectory = Optional.empty();
private final Set<String> entries = new HashSet<>();
private boolean withDirectoryEntries = false;

Expand All @@ -43,6 +48,11 @@ TestJarFile withManifestAttribute(Attributes.Name name, String value) {
return this;
}

public TestJarFile withNestedClassFilesDirectory(String relativePath) {
nestedClassFilesDirectory = Optional.of(relativePath);
return this;
}

TestJarFile withEntry(String entry) {
// ZIP entries must not start with a '/' (compare ZIP spec https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT -> 4.4.17.1)
entries.add(entry.replaceAll("^/", ""));
Expand All @@ -67,54 +77,50 @@ private String createAndReturnName(Supplier<JarFile> createJarFile) {
}

JarFile create(File jarFile) {
Set<String> allEntries = withDirectoryEntries ? ensureDirectoryEntries(entries) : entries;
Stream<TestJarEntry> testJarEntries = entries.stream()
.map(entry -> new TestJarEntry(entry, nestedClassFilesDirectory));

Stream<TestJarEntry> allEntries = withDirectoryEntries
? ensureDirectoryEntries(testJarEntries)
: ensureNestedClassFilesDirectoryEntries(testJarEntries);

try (JarOutputStream jarOut = new JarOutputStream(newOutputStream(jarFile.toPath()), manifest)) {
for (String entry : allEntries) {
write(jarOut, entry);
}
allEntries.distinct().forEach(entry -> write(jarOut, entry));
} catch (IOException e) {
throw new RuntimeException(e);
}
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 Stream<TestJarEntry> ensureNestedClassFilesDirectoryEntries(Stream<TestJarEntry> entries) {
return createAdditionalEntries(entries, TestJarEntry::getDirectoriesInPathOfNestedClassFilesDirectory);
}

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;
private Stream<TestJarEntry> ensureDirectoryEntries(Stream<TestJarEntry> entries) {
return createAdditionalEntries(entries, TestJarEntry::getDirectoriesInPath);
}

private static Stream<TestJarEntry> createAdditionalEntries(Stream<TestJarEntry> entries, Function<TestJarEntry, Stream<TestJarEntry>> createAdditionalEntries) {
return entries.flatMap(it -> Stream.concat(createAdditionalEntries.apply(it), Stream.of(it)));
}

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

private void write(JarOutputStream jarOut, String entry) throws IOException {
checkArgument(!entry.startsWith("/"),
"ZIP entries must not start with a '/' (compare ZIP spec https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT -> 4.4.17.1)");

String absoluteResourcePath = "/" + entry;
private void write(JarOutputStream jarOut, TestJarEntry entry) {
try {
ZipEntry zipEntry = entry.toZipEntry();
jarOut.putNextEntry(zipEntry);

ZipEntry zipEntry = new ZipEntry(entry);
jarOut.putNextEntry(zipEntry);
if (!zipEntry.isDirectory() && getClass().getResource(absoluteResourcePath) != null) {
jarOut.write(toByteArray(getClass().getResourceAsStream(absoluteResourcePath)));
String originResourcePath = "/" + entry.entry;
if (!zipEntry.isDirectory() && getClass().getResource(originResourcePath) != null) {
jarOut.write(toByteArray(getClass().getResourceAsStream(originResourcePath)));
}
jarOut.closeEntry();
} catch (IOException e) {
throw new RuntimeException(e);
}
jarOut.closeEntry();
}

private JarFile newJarFile(File file) {
Expand All @@ -124,4 +130,75 @@ private JarFile newJarFile(File file) {
throw new RuntimeException(e);
}
}

private static class TestJarEntry {
private final String entry;
private final String nestedClassFilesDirectory;

TestJarEntry(String entry, Optional<String> nestedClassFilesDirectory) {
this(
entry,
nestedClassFilesDirectory
.map(it -> it.endsWith("/") ? it : it + "/")
.orElse("")
);
}

private TestJarEntry(String entry, String nestedClassFilesDirectory) {
checkArgument(!entry.startsWith("/"),
"ZIP entries must not start with a '/' (compare ZIP spec https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT -> 4.4.17.1)");
checkArgument(!nestedClassFilesDirectory.startsWith("/"),
"Nested class files dir must be relative (i.e. not start with a '/')");

this.entry = entry;
this.nestedClassFilesDirectory = nestedClassFilesDirectory;
}

ZipEntry toZipEntry() {
return new ZipEntry(nestedClassFilesDirectory + entry);
}

Stream<TestJarEntry> getDirectoriesInPath() {
Stream<TestJarEntry> fromClassEntries = getDirectoriesInPath(entry).stream()
.map(it -> new TestJarEntry(it, nestedClassFilesDirectory));
Stream<TestJarEntry> fromNestedClassFilesDir = getDirectoriesInPathOfNestedClassFilesDirectory();
return Stream.concat(fromClassEntries, fromNestedClassFilesDir);
}

Stream<TestJarEntry> getDirectoriesInPathOfNestedClassFilesDirectory() {
return getDirectoriesInPath(nestedClassFilesDirectory).stream()
.map(it -> new TestJarEntry(it, ""));
}

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

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}

TestJarEntry that = (TestJarEntry) o;
return Objects.equals(entry, that.entry)
&& Objects.equals(nestedClassFilesDirectory, that.nestedClassFilesDirectory);
}

@Override
public int hashCode() {
return Objects.hash(entry, nestedClassFilesDirectory);
}
}
}
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ ext {
// Dependencies for example projects / tests
javaxAnnotationApi : [group: 'javax.annotation', name: 'javax.annotation-api', version: '1.3.2'],
springBeans : [group: 'org.springframework', name: 'spring-beans', version: '5.3.23'],
springBootLoader : [group: 'org.springframework.boot', name: 'spring-boot-loader', version: '2.7.13'],
jakartaInject : [group: 'jakarta.inject', name: 'jakarta.inject-api', version: '1.0'],
jakartaAnnotations : [group: 'jakarta.annotation', name: 'jakarta.annotation-api', version: '1.3.5'],
guice : [group: 'com.google.inject', name: 'guice', version: '5.1.0'],
Expand Down
2 changes: 1 addition & 1 deletion settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins {

rootProject.name = 'archunit-root'

include 'archunit', 'archunit-integration-test', 'archunit-java-modules-test',
include 'archunit', 'archunit-integration-test', 'archunit-java-modules-test', 'archunit-3rd-party-test',
'archunit-junit', 'archunit-junit4', 'archunit-junit5-api', 'archunit-junit5-engine-api', 'archunit-junit5-engine', 'archunit-junit5',
'archunit-example:example-plain', 'archunit-example:example-junit4', 'archunit-example:example-junit5', 'archunit-maven-test', 'docs'

Expand Down

0 comments on commit 34009ca

Please sign in to comment.