Skip to content

Commit 69e3afc

Browse files
odrotbohmcodecholeric
authored andcommitted
Let JarFileLocation work with custom ClassLoader URIs.
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 <odrotbohm@vmware.com>
1 parent 3334633 commit 69e3afc

7 files changed

Lines changed: 227 additions & 36 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
plugins {
2+
id 'archunit.java-conventions'
3+
}
4+
5+
ext.moduleName = 'com.tngtech.archunit.thirdpartytest'
6+
7+
dependencies {
8+
testImplementation project(path: ':archunit', configuration: 'shadow')
9+
testImplementation project(path: ':archunit', configuration: 'tests')
10+
testImplementation dependency.springBootLoader
11+
dependency.addGuava { dependencyNotation, config -> testImplementation(dependencyNotation, config) }
12+
testImplementation dependency.log4j_slf4j
13+
testImplementation dependency.junit4
14+
testImplementation dependency.junit_dataprovider
15+
testImplementation dependency.assertj
16+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.tngtech.archunit.core.importer;
2+
3+
import java.io.File;
4+
import java.io.IOException;
5+
import java.net.URL;
6+
import java.util.Arrays;
7+
import java.util.Iterator;
8+
import java.util.function.Function;
9+
import java.util.jar.JarFile;
10+
import java.util.stream.Stream;
11+
12+
import com.tngtech.archunit.core.importer.testexamples.SomeEnum;
13+
import com.tngtech.archunit.testutil.SystemPropertiesRule;
14+
import com.tngtech.java.junit.dataprovider.DataProvider;
15+
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
16+
import com.tngtech.java.junit.dataprovider.UseDataProvider;
17+
import org.junit.Rule;
18+
import org.junit.Test;
19+
import org.junit.runner.RunWith;
20+
import org.springframework.boot.loader.LaunchedURLClassLoader;
21+
import org.springframework.boot.loader.archive.Archive;
22+
import org.springframework.boot.loader.archive.JarFileArchive;
23+
24+
import static com.google.common.collect.Iterators.getOnlyElement;
25+
import static com.google.common.collect.MoreCollectors.onlyElement;
26+
import static com.google.common.collect.Streams.stream;
27+
import static com.google.common.io.ByteStreams.toByteArray;
28+
import static com.tngtech.archunit.core.importer.LocationTest.classFileEntry;
29+
import static com.tngtech.archunit.core.importer.LocationTest.urlOfClass;
30+
import static com.tngtech.archunit.core.importer.LocationsTest.unchecked;
31+
import static com.tngtech.java.junit.dataprovider.DataProviders.testForEach;
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
34+
@RunWith(DataProviderRunner.class)
35+
public class SpringLocationsTest {
36+
/**
37+
* Spring Boot configures some system properties that we want to reset afterward (e.g. custom URL stream handler)
38+
*/
39+
@Rule
40+
public final SystemPropertiesRule systemPropertiesRule = new SystemPropertiesRule();
41+
42+
@DataProvider
43+
public static Object[][] springBootJars() {
44+
Function<Function<TestJarFile, TestJarFile>, TestJarFile> createSpringBootJar = setUpJarFile -> setUpJarFile.apply(new TestJarFile())
45+
.withNestedClassFilesDirectory("BOOT-INF/classes")
46+
.withEntry(classFileEntry(SomeEnum.class).toAbsolutePath());
47+
48+
return testForEach(
49+
createSpringBootJar.apply(TestJarFile::withDirectoryEntries),
50+
createSpringBootJar.apply(TestJarFile::withoutDirectoryEntries)
51+
);
52+
}
53+
54+
@Test
55+
@UseDataProvider("springBootJars")
56+
public void finds_locations_of_packages_from_Spring_Boot_ClassLoader_for_JARs(TestJarFile jarFileToTest) throws Exception {
57+
try (JarFile jarFile = jarFileToTest.create()) {
58+
59+
configureSpringBootContextClassLoaderKnowingOnly(jarFile);
60+
61+
String jarUri = new File(jarFile.getName()).toURI().toString();
62+
Location location = Locations.ofPackage(SomeEnum.class.getPackage().getName()).stream()
63+
.filter(it -> it.contains(jarUri))
64+
.collect(onlyElement());
65+
66+
byte[] expectedClassContent = toByteArray(urlOfClass(SomeEnum.class).openStream());
67+
Stream<byte[]> actualClassContents = stream(location.asClassFileSource(new ImportOptions()))
68+
.map(it -> unchecked(() -> toByteArray(it.openStream())));
69+
70+
boolean containsExpectedContent = actualClassContents.anyMatch(it -> Arrays.equals(it, expectedClassContent));
71+
assertThat(containsExpectedContent)
72+
.as("one of the found class files has the expected class file content")
73+
.isTrue();
74+
}
75+
}
76+
77+
private static void configureSpringBootContextClassLoaderKnowingOnly(JarFile jarFile) throws IOException {
78+
// This hooks in Spring Boot's own JAR URL protocol handler which knows how to handle URLs with
79+
// multiple separators (e.g. "jar:file:/dir/some.jar!/BOOT-INF/classes!/pkg/some.class")
80+
org.springframework.boot.loader.jar.JarFile.registerUrlProtocolHandler();
81+
82+
try (JarFileArchive jarFileArchive = new JarFileArchive(new File(jarFile.getName()))) {
83+
JarFileArchive bootInfClassArchive = getNestedJarFileArchive(jarFileArchive, "BOOT-INF/classes/");
84+
85+
Thread.currentThread().setContextClassLoader(
86+
new LaunchedURLClassLoader(false, bootInfClassArchive, new URL[]{bootInfClassArchive.getUrl()}, null)
87+
);
88+
}
89+
}
90+
91+
@SuppressWarnings("SameParameterValue")
92+
private static JarFileArchive getNestedJarFileArchive(JarFileArchive jarFileArchive, String path) throws IOException {
93+
Iterator<Archive> archiveCandidates = jarFileArchive.getNestedArchives(entry -> entry.getName().equals(path), entry -> true);
94+
return (JarFileArchive) getOnlyElement(archiveCandidates);
95+
}
96+
}

archunit/src/main/java/com/tngtech/archunit/core/importer/Location.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,9 @@ private ParsedUri(String base, String path) {
335335
}
336336

337337
static ParsedUri from(NormalizedUri uri) {
338-
String[] parts = uri.toString().split("!/", 2);
339-
return new ParsedUri(parts[0] + "!/", parts[1]);
338+
String uriString = uri.toString();
339+
int entryPathStartIndex = uriString.lastIndexOf("!/") + 2;
340+
return new ParsedUri(uriString.substring(0, entryPathStartIndex), uriString.substring(entryPathStartIndex));
340341
}
341342
}
342343
}

archunit/src/test/java/com/tngtech/archunit/core/importer/LocationsTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ private static <T> Stream<T> stream(Iterable<T> iterable) {
189189
return StreamSupport.stream(iterable.spliterator(), false);
190190
}
191191

192-
private <T> T unchecked(ThrowingSupplier<T> supplier) {
192+
static <T> T unchecked(ThrowingSupplier<T> supplier) {
193193
try {
194194
return supplier.get();
195195
} catch (Exception e) {
@@ -198,7 +198,7 @@ private <T> T unchecked(ThrowingSupplier<T> supplier) {
198198
}
199199

200200
@FunctionalInterface
201-
private interface ThrowingSupplier<T> {
201+
interface ThrowingSupplier<T> {
202202
T get() throws Exception;
203203
}
204204

archunit/src/test/java/com/tngtech/archunit/core/importer/TestJarFile.java

Lines changed: 108 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
import java.io.File;
44
import java.io.IOException;
55
import java.util.HashSet;
6+
import java.util.Objects;
7+
import java.util.Optional;
68
import java.util.Set;
9+
import java.util.function.Function;
710
import java.util.function.Supplier;
811
import java.util.jar.Attributes;
912
import java.util.jar.JarFile;
1013
import java.util.jar.JarOutputStream;
1114
import java.util.jar.Manifest;
15+
import java.util.stream.Stream;
1216
import java.util.zip.ZipEntry;
1317

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

2125
class TestJarFile {
2226
private final Manifest manifest;
27+
private Optional<String> nestedClassFilesDirectory = Optional.empty();
2328
private final Set<String> entries = new HashSet<>();
2429
private boolean withDirectoryEntries = false;
2530

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

51+
public TestJarFile withNestedClassFilesDirectory(String relativePath) {
52+
nestedClassFilesDirectory = Optional.of(relativePath);
53+
return this;
54+
}
55+
4656
TestJarFile withEntry(String entry) {
4757
// ZIP entries must not start with a '/' (compare ZIP spec https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT -> 4.4.17.1)
4858
entries.add(entry.replaceAll("^/", ""));
@@ -67,54 +77,50 @@ private String createAndReturnName(Supplier<JarFile> createJarFile) {
6777
}
6878

6979
JarFile create(File jarFile) {
70-
Set<String> allEntries = withDirectoryEntries ? ensureDirectoryEntries(entries) : entries;
80+
Stream<TestJarEntry> testJarEntries = entries.stream()
81+
.map(entry -> new TestJarEntry(entry, nestedClassFilesDirectory));
82+
83+
Stream<TestJarEntry> allEntries = withDirectoryEntries
84+
? ensureDirectoryEntries(testJarEntries)
85+
: ensureNestedClassFilesDirectoryEntries(testJarEntries);
86+
7187
try (JarOutputStream jarOut = new JarOutputStream(newOutputStream(jarFile.toPath()), manifest)) {
72-
for (String entry : allEntries) {
73-
write(jarOut, entry);
74-
}
88+
allEntries.distinct().forEach(entry -> write(jarOut, entry));
7589
} catch (IOException e) {
7690
throw new RuntimeException(e);
7791
}
7892
return newJarFile(jarFile);
7993
}
8094

81-
private Set<String> ensureDirectoryEntries(Set<String> entries) {
82-
Set<String> result = new HashSet<>();
83-
entries.forEach(entry -> {
84-
result.addAll(createDirectoryEntries(entry));
85-
result.add(entry);
86-
});
87-
return result;
95+
private Stream<TestJarEntry> ensureNestedClassFilesDirectoryEntries(Stream<TestJarEntry> entries) {
96+
return createAdditionalEntries(entries, TestJarEntry::getDirectoriesInPathOfNestedClassFilesDirectory);
8897
}
8998

90-
private static Set<String> createDirectoryEntries(String entry) {
91-
Set<String> result = new HashSet<>();
92-
int checkedUpToIndex = -1;
93-
do {
94-
checkedUpToIndex = entry.indexOf("/", checkedUpToIndex + 1);
95-
if (checkedUpToIndex != -1) {
96-
result.add(entry.substring(0, checkedUpToIndex + 1));
97-
}
98-
} while (checkedUpToIndex != -1);
99-
return result;
99+
private Stream<TestJarEntry> ensureDirectoryEntries(Stream<TestJarEntry> entries) {
100+
return createAdditionalEntries(entries, TestJarEntry::getDirectoriesInPath);
101+
}
102+
103+
private static Stream<TestJarEntry> createAdditionalEntries(Stream<TestJarEntry> entries, Function<TestJarEntry, Stream<TestJarEntry>> createAdditionalEntries) {
104+
return entries.flatMap(it -> Stream.concat(createAdditionalEntries.apply(it), Stream.of(it)));
100105
}
101106

102107
String createAndReturnName(File jarFile) {
103108
return createAndReturnName(() -> create(jarFile));
104109
}
105110

106-
private void write(JarOutputStream jarOut, String entry) throws IOException {
107-
checkArgument(!entry.startsWith("/"),
108-
"ZIP entries must not start with a '/' (compare ZIP spec https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT -> 4.4.17.1)");
109-
110-
String absoluteResourcePath = "/" + entry;
111+
private void write(JarOutputStream jarOut, TestJarEntry entry) {
112+
try {
113+
ZipEntry zipEntry = entry.toZipEntry();
114+
jarOut.putNextEntry(zipEntry);
111115

112-
ZipEntry zipEntry = new ZipEntry(entry);
113-
jarOut.putNextEntry(zipEntry);
114-
if (!zipEntry.isDirectory() && getClass().getResource(absoluteResourcePath) != null) {
115-
jarOut.write(toByteArray(getClass().getResourceAsStream(absoluteResourcePath)));
116+
String originResourcePath = "/" + entry.entry;
117+
if (!zipEntry.isDirectory() && getClass().getResource(originResourcePath) != null) {
118+
jarOut.write(toByteArray(getClass().getResourceAsStream(originResourcePath)));
119+
}
120+
jarOut.closeEntry();
121+
} catch (IOException e) {
122+
throw new RuntimeException(e);
116123
}
117-
jarOut.closeEntry();
118124
}
119125

120126
private JarFile newJarFile(File file) {
@@ -124,4 +130,75 @@ private JarFile newJarFile(File file) {
124130
throw new RuntimeException(e);
125131
}
126132
}
133+
134+
private static class TestJarEntry {
135+
private final String entry;
136+
private final String nestedClassFilesDirectory;
137+
138+
TestJarEntry(String entry, Optional<String> nestedClassFilesDirectory) {
139+
this(
140+
entry,
141+
nestedClassFilesDirectory
142+
.map(it -> it.endsWith("/") ? it : it + "/")
143+
.orElse("")
144+
);
145+
}
146+
147+
private TestJarEntry(String entry, String nestedClassFilesDirectory) {
148+
checkArgument(!entry.startsWith("/"),
149+
"ZIP entries must not start with a '/' (compare ZIP spec https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT -> 4.4.17.1)");
150+
checkArgument(!nestedClassFilesDirectory.startsWith("/"),
151+
"Nested class files dir must be relative (i.e. not start with a '/')");
152+
153+
this.entry = entry;
154+
this.nestedClassFilesDirectory = nestedClassFilesDirectory;
155+
}
156+
157+
ZipEntry toZipEntry() {
158+
return new ZipEntry(nestedClassFilesDirectory + entry);
159+
}
160+
161+
Stream<TestJarEntry> getDirectoriesInPath() {
162+
Stream<TestJarEntry> fromClassEntries = getDirectoriesInPath(entry).stream()
163+
.map(it -> new TestJarEntry(it, nestedClassFilesDirectory));
164+
Stream<TestJarEntry> fromNestedClassFilesDir = getDirectoriesInPathOfNestedClassFilesDirectory();
165+
return Stream.concat(fromClassEntries, fromNestedClassFilesDir);
166+
}
167+
168+
Stream<TestJarEntry> getDirectoriesInPathOfNestedClassFilesDirectory() {
169+
return getDirectoriesInPath(nestedClassFilesDirectory).stream()
170+
.map(it -> new TestJarEntry(it, ""));
171+
}
172+
173+
private Set<String> getDirectoriesInPath(String entryPath) {
174+
Set<String> result = new HashSet<>();
175+
int checkedUpToIndex = -1;
176+
do {
177+
checkedUpToIndex = entryPath.indexOf("/", checkedUpToIndex + 1);
178+
if (checkedUpToIndex != -1) {
179+
result.add(entryPath.substring(0, checkedUpToIndex + 1));
180+
}
181+
} while (checkedUpToIndex != -1);
182+
return result;
183+
}
184+
185+
@Override
186+
public boolean equals(Object o) {
187+
if (this == o) {
188+
return true;
189+
}
190+
if (o == null || getClass() != o.getClass()) {
191+
return false;
192+
}
193+
194+
TestJarEntry that = (TestJarEntry) o;
195+
return Objects.equals(entry, that.entry)
196+
&& Objects.equals(nestedClassFilesDirectory, that.nestedClassFilesDirectory);
197+
}
198+
199+
@Override
200+
public int hashCode() {
201+
return Objects.hash(entry, nestedClassFilesDirectory);
202+
}
203+
}
127204
}

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ ext {
6868
// Dependencies for example projects / tests
6969
javaxAnnotationApi : [group: 'javax.annotation', name: 'javax.annotation-api', version: '1.3.2'],
7070
springBeans : [group: 'org.springframework', name: 'spring-beans', version: '5.3.23'],
71+
springBootLoader : [group: 'org.springframework.boot', name: 'spring-boot-loader', version: '2.7.13'],
7172
jakartaInject : [group: 'jakarta.inject', name: 'jakarta.inject-api', version: '1.0'],
7273
jakartaAnnotations : [group: 'jakarta.annotation', name: 'jakarta.annotation-api', version: '1.3.5'],
7374
guice : [group: 'com.google.inject', name: 'guice', version: '5.1.0'],

settings.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44

55
rootProject.name = 'archunit-root'
66

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

0 commit comments

Comments
 (0)