Skip to content

Commit

Permalink
Let JarFileLocation work with custom ClassLoader URIs.
Browse files Browse the repository at this point in the history
Some ClassLoaders 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's 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 commit 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`).

Signed-off-by: Oliver Drotbohm <[email protected]>
  • Loading branch information
odrotbohm authored and codecholeric committed Aug 8, 2023
1 parent 3334633 commit 69e3afc
Show file tree
Hide file tree
Showing 7 changed files with 227 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 @@ -335,8 +335,9 @@ private ParsedUri(String base, String path) {
}

static ParsedUri from(NormalizedUri uri) {
String[] parts = uri.toString().split("!/", 2);
return new ParsedUri(parts[0] + "!/", parts[1]);
String uriString = uri.toString();
int entryPathStartIndex = uriString.lastIndexOf("!/") + 2;
return new ParsedUri(uriString.substring(0, entryPathStartIndex), uriString.substring(entryPathStartIndex));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,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 @@ -198,7 +198,7 @@ private <T> T unchecked(ThrowingSupplier<T> supplier) {
}

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

Expand Down
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 69e3afc

Please sign in to comment.