From 9bf4b2e0d3daad0f953b4ee9eb502d94418ef88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Wed, 8 Nov 2023 15:17:37 +0100 Subject: [PATCH] Hacking --- .../jarmode/layertools/ExtractJarCommand.java | 167 ++++++++++++++++ .../layertools/IndexedJarStructure.java | 166 ++++++++++++++++ .../boot/jarmode/layertools/JarStructure.java | 62 ++++++ .../jarmode/layertools/LayerToolsJarMode.java | 1 + .../layertools/ExtractJarCommandTests.java | 168 ++++++++++++++++ .../layertools/IndexedJarStructureTests.java | 181 ++++++++++++++++++ .../jarmode/layertools/test-classpath.idx | 2 + 7 files changed, 747 insertions(+) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/ExtractJarCommand.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/IndexedJarStructure.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/JarStructure.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractJarCommandTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/IndexedJarStructureTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/resources/org/springframework/boot/jarmode/layertools/test-classpath.idx diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/ExtractJarCommand.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/ExtractJarCommand.java new file mode 100644 index 000000000000..4b66f3bc79be --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/ExtractJarCommand.java @@ -0,0 +1,167 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.jarmode.layertools; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributeView; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes.Name; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.springframework.boot.jarmode.layertools.JarStructure.Entry; +import org.springframework.util.Assert; +import org.springframework.util.FileSystemUtils; +import org.springframework.util.StreamUtils; + +/** + * Hacking. + * + * @author Stephane Nicoll + */ +class ExtractJarCommand extends Command { + + static final Option DESTINATION_OPTION = Option.of("destination", "string", "The destination to extract files to"); + + private final Context context; + + private final JarStructure jarStructure; + + ExtractJarCommand(Context context) { + this(context, IndexedJarStructure.get(context)); + } + + ExtractJarCommand(Context context, JarStructure jarStructure) { + super("extract2", "Extracts the application in optimized structure", Options.of(DESTINATION_OPTION), + Parameters.none()); + this.context = context; + this.jarStructure = jarStructure; + } + + @Override + protected void run(Map options, List parameters) { + try { + Path destination = options.containsKey(DESTINATION_OPTION) ? Paths.get(options.get(DESTINATION_OPTION)) + : this.context.getWorkingDir().toPath(); + Path application = destination.resolve("application"); + mkDirs(application); + Path dependencies = destination.resolve("dependencies"); + mkDirs(dependencies); + try (ZipInputStream zip = new ZipInputStream(new FileInputStream(this.context.getArchiveFile()))) { + ZipEntry zipEntry = zip.getNextEntry(); + Assert.state(zipEntry != null, "File '" + this.context.getArchiveFile().toString() + + "' is not compatible with layertools; ensure jar file is valid and launch script is not enabled"); + while (zipEntry != null) { + Entry resolvedEntry = this.jarStructure.resolve(zipEntry); + if (resolvedEntry != null) { + String location = (resolvedEntry.library() ? "dependencies/" + resolvedEntry.location() + : "application/" + resolvedEntry.location()); + write(zip, zipEntry, destination, location); + } + zipEntry = zip.getNextEntry(); + } + } + // Create the application jar + Path applicationJarFile = createApplicationJar(destination, application); + + // create the run-app.jar + createRunAppJar(destination, applicationJarFile); + + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + + } + + private Path createApplicationJar(Path destination, Path application) throws IOException { + String archiveName = this.context.getArchiveFile().getName(); + Path archive = createArchiveFromDirectory(destination, application, archiveName); + FileSystemUtils.deleteRecursively(application); + mkDirs(application); + return Files.move(archive, application.resolve(archive.getFileName())); + } + + // Hacking + private Path createArchiveFromDirectory(Path destination, Path applicationDirectory, String jarName) + throws IOException { + Process process = new ProcessBuilder().inheritIO() + .directory(destination.toFile()) + .command("jar", "cf", jarName, "-C", applicationDirectory.getFileName().toString(), ".") + .start(); + try { + int exit = process.waitFor(); + if (exit != 0) { + throw new IllegalStateException("Jar file creation failed"); + } + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + return destination.resolve(jarName); + } + + private void createRunAppJar(Path destination, Path applicationJarFile) throws IOException { + Manifest manifest = this.jarStructure.createRunJarManifest((dependency) -> "dependencies/" + dependency); + String libs = manifest.getMainAttributes().getValue(Name.CLASS_PATH); + String applicationJar = destination.relativize(applicationJarFile).toString(); + manifest.getMainAttributes().put(Name.CLASS_PATH, String.join(" ", applicationJar, libs)); + Path runJar = destination.resolve("run-app.jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(runJar), manifest)) { + // EMPTY + } + } + + private void write(ZipInputStream zip, ZipEntry entry, Path destination, String name) throws IOException { + String canonicalOutputPath = destination.toFile().getCanonicalPath() + File.separator; + Path file = destination.resolve(name); + String canonicalEntryPath = file.toFile().getCanonicalPath(); + Assert.state(canonicalEntryPath.startsWith(canonicalOutputPath), + () -> "Entry '" + entry.getName() + "' would be written to '" + canonicalEntryPath + + "'. This is outside the output location of '" + canonicalOutputPath + + "'. Verify the contents of your archive."); + mkParentDirs(file); + try (OutputStream out = Files.newOutputStream(file)) { + StreamUtils.copy(zip, out); + } + try { + Files.getFileAttributeView(file, BasicFileAttributeView.class) + .setTimes(entry.getLastModifiedTime(), entry.getLastAccessTime(), entry.getCreationTime()); + } + catch (IOException ex) { + // File system does not support setting time attributes. Continue. + } + } + + private void mkParentDirs(Path file) throws IOException { + mkDirs(file.getParent()); + } + + private void mkDirs(Path file) throws IOException { + Files.createDirectories(file); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/IndexedJarStructure.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/IndexedJarStructure.java new file mode 100644 index 000000000000..f335ca2c67e6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/IndexedJarStructure.java @@ -0,0 +1,166 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.jarmode.layertools; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.NoSuchFileException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.UnaryOperator; +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; + +import org.springframework.util.Assert; +import org.springframework.util.StreamUtils; +import org.springframework.util.StringUtils; + +/** + * {@link JarStructure} implementation backed by a {@code classpath.idx} file. + * + * @author Stephane Nicoll + */ +class IndexedJarStructure implements JarStructure { + + private final Manifest originalManifest; + + private final String libLocation; + + private final String classesLocation; + + private final List classpathEntries; + + IndexedJarStructure(Manifest originalManifest, String indexFile) { + this.originalManifest = originalManifest; + this.libLocation = getLocation(originalManifest, "Spring-Boot-Lib"); + this.classesLocation = getLocation(originalManifest, "Spring-Boot-Classes"); + this.classpathEntries = readIndexFile(indexFile); + } + + private static String getLocation(Manifest manifest, String attribute) { + String location = getMandatoryAttribute(manifest, attribute); + if (!location.endsWith("/")) { + location = location + "/"; + } + return location; + } + + private static List readIndexFile(String indexFile) { + String[] lines = Arrays.stream(indexFile.split("\n")) + .map((line) -> line.replace("\r", "")) + .filter(StringUtils::hasText) + .toArray(String[]::new); + List classpathEntries = new ArrayList<>(); + for (String line : lines) { + if (line.startsWith("- ")) { + classpathEntries.add(line.substring(3, line.length() - 1)); + } + else { + throw new IllegalStateException("Classpath index file is malformed"); + } + } + Assert.state(!classpathEntries.isEmpty(), "Empty classpath index file loaded"); + return classpathEntries; + } + + @Override + public Entry resolve(ZipEntry entry) { + if (entry.isDirectory()) { + return null; + } + String name = entry.getName(); + if (this.classpathEntries.contains(name)) { + return new Entry(name, toStructureDependency(name), true); + } + else if (name.startsWith(this.classesLocation)) { + return new Entry(name, name.substring(this.classesLocation.length()), false); + } + return null; + } + + @Override + public Manifest createRunJarManifest(UnaryOperator libEntry) { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Name.MANIFEST_VERSION, "1.0"); + attributes.put(Name.MAIN_CLASS, getMandatoryAttribute(this.originalManifest, "Start-Class")); + attributes.put(Name.CLASS_PATH, + this.classpathEntries.stream() + .map(this::toStructureDependency) + .map(libEntry) + .collect(Collectors.joining(" "))); + return manifest; + } + + @Override + public Manifest createApplicationJarManifest() { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Name.MANIFEST_VERSION, "1.0"); + copy(this.originalManifest, manifest, "Implementation-Title"); + copy(this.originalManifest, manifest, "Implementation-Version"); + return manifest; + } + + private static void copy(Manifest left, Manifest right, String attribute) { + String value = left.getMainAttributes().getValue(attribute); + if (value != null) { + right.getMainAttributes().putValue(attribute, value); + } + } + + private String toStructureDependency(String libEntryName) { + Assert.state(libEntryName.startsWith(this.libLocation), "Invalid library location " + libEntryName); + return libEntryName.substring(this.libLocation.length()); + } + + private static String getMandatoryAttribute(Manifest manifest, String attribute) { + String value = manifest.getMainAttributes().getValue(attribute); + if (value == null) { + throw new IllegalStateException("Manifest attribute '" + attribute + "' is mandatory"); + } + return value; + } + + static IndexedJarStructure get(Context context) { + try { + try (JarFile jarFile = new JarFile(context.getArchiveFile())) { + Manifest manifest = jarFile.getManifest(); + String location = getMandatoryAttribute(manifest, "Spring-Boot-Classpath-Index"); + ZipEntry entry = jarFile.getEntry(location); + if (entry != null) { + String indexFile = StreamUtils.copyToString(jarFile.getInputStream(entry), StandardCharsets.UTF_8); + return new IndexedJarStructure(manifest, indexFile); + } + } + return null; + } + catch (FileNotFoundException | NoSuchFileException ex) { + return null; + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/JarStructure.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/JarStructure.java new file mode 100644 index 000000000000..1cc0d01cacfd --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/JarStructure.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.jarmode.layertools; + +import java.util.function.UnaryOperator; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +/** + * Provide information about a fat jar structure that is meant to be extracted. + * + * @author Stephane Nicoll + */ +interface JarStructure { + + /** + * Resolve the specified {@link ZipEntry}, return {@code null} if the entry should not + * be handled. + * @param entry the entry to handle + * @return the resolved {@link Entry} + */ + Entry resolve(ZipEntry entry); + + /** + * Create the {@link Manifest} for the {@code run-jar} jar, applying the specified + * operator on each classpath entry. + * @param libEntry the operator to apply on each classpath entry + * @return the manifest to use for the {@code run-jar} jar + */ + Manifest createRunJarManifest(UnaryOperator libEntry); + + /** + * Create the {@link Manifest} for the jar that is created with classes and resources. + * @return the manifest to use for the {@code application} jar + */ + Manifest createApplicationJarManifest(); + + /** + * An entry to handle in the exploded structure. + * + * @param originalLocation the original location + * @param location the relative location + * @param library whether the entry refers to a library or a classpath resource + */ + record Entry(String originalLocation, String location, boolean library) { + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/LayerToolsJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/LayerToolsJarMode.java index 7ac5c2d8ae9a..3d8a026a14c3 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/LayerToolsJarMode.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/main/java/org/springframework/boot/jarmode/layertools/LayerToolsJarMode.java @@ -108,6 +108,7 @@ static List getCommands(Context context) { List commands = new ArrayList<>(); commands.add(new ListCommand(context)); commands.add(new ExtractCommand(context)); + commands.add(new ExtractJarCommand(context)); return Collections.unmodifiableList(commands); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractJarCommandTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractJarCommandTests.java new file mode 100644 index 000000000000..a5f4389f7f09 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/ExtractJarCommandTests.java @@ -0,0 +1,168 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.jarmode.layertools; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Tests for {@link ExtractJarCommand}. + * + * @author Stephane Nicoll + */ +@ExtendWith(MockitoExtension.class) +class ExtractJarCommandTests { + + private static final FileTime CREATION_TIME = FileTime.from(Instant.now().minus(3, ChronoUnit.DAYS)); + + private static final FileTime LAST_MODIFIED_TIME = FileTime.from(Instant.now().minus(2, ChronoUnit.DAYS)); + + private static final FileTime LAST_ACCESS_TIME = FileTime.from(Instant.now().minus(1, ChronoUnit.DAYS)); + + private static final String DEMO_APPLICATION = """ + package com.example; + + public class DemoApplication {} + """; + + @TempDir + File temp; + + @Mock + private Context context; + + private File jarFile; + + private File extract; + + @BeforeEach + void setup() throws Exception { + this.jarFile = createJarFile("test.jar"); + this.extract = new File(this.temp, "extract"); + this.extract.mkdir(); + } + + @Test + void runCreatesExplodedStructure() { + given(this.context.getArchiveFile()).willReturn(this.jarFile); + given(this.context.getWorkingDir()).willReturn(this.extract); + ExtractJarCommand command = new ExtractJarCommand(this.context); + command.run(Collections.emptyMap(), Collections.emptyList()); + + assertThat(this.extract.list()).containsOnly("application", "dependencies", "run-app.jar"); + assertThat(new File(this.extract, "application/test.jar")).exists(); + assertThat(new File(this.extract, "application/com/example/DemoApplication.class")).doesNotExist(); + assertThat(new File(this.extract, "application/application.properties")).doesNotExist(); + assertThat(new File(this.extract, "dependencies/a.jar")).exists().satisfies(this::timeAttributes); + assertThat(new File(this.extract, "dependencies/b.jar")).exists().satisfies(this::timeAttributes); + assertThat(new File(this.extract, "dependencies/e.jar")).doesNotExist(); + } + + private void timeAttributes(File file) { + try { + BasicFileAttributes basicAttributes = Files + .getFileAttributeView(file.toPath(), BasicFileAttributeView.class) + .readAttributes(); + assertThat(basicAttributes.lastModifiedTime().to(TimeUnit.SECONDS)) + .isEqualTo(LAST_MODIFIED_TIME.to(TimeUnit.SECONDS)); + assertThat(basicAttributes.creationTime().to(TimeUnit.SECONDS)).satisfiesAnyOf( + (creationTime) -> assertThat(creationTime).isEqualTo(CREATION_TIME.to(TimeUnit.SECONDS)), + // On macOS (at least) the creation time is the last modified time + (creationTime) -> assertThat(creationTime).isEqualTo(LAST_MODIFIED_TIME.to(TimeUnit.SECONDS))); + assertThat(basicAttributes.lastAccessTime().to(TimeUnit.SECONDS)) + .isEqualTo(LAST_ACCESS_TIME.to(TimeUnit.SECONDS)); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + } + + private File createJarFile(String name) throws Exception { + return createJarFile(name, (out) -> { + }); + } + + private File createJarFile(String name, Consumer streamHandler) throws Exception { + File file = new File(this.temp, name); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { + createEntry(out, "BOOT-INF/classes/com/example/DemoApplication.class", DEMO_APPLICATION); + createEntry(out, "BOOT-INF/classes/application.properties", "prop=value"); + createEntry(out, "BOOT-INF/classpath.idx", getFile("test-classpath.idx")); + createEntry(out, "hello.txt", "To be ignored"); + createEmptyEntries(out, "BOOT-INF/lib/a.jar", "BOOT-INF/lib/b.jar", "BOOT-INF/lib/c.jar"); + out.putNextEntry(entry("META-INF/MANIFEST.MF")); + out.write(getFile("test-manifest.MF").getBytes()); + out.closeEntry(); + streamHandler.accept(out); + } + return file; + } + + private static void createEmptyEntries(ZipOutputStream out, String... paths) throws IOException { + for (String path : paths) { + out.putNextEntry(entry(path)); + out.closeEntry(); + } + } + + private static void createEntry(ZipOutputStream out, String path, String content) throws IOException { + out.putNextEntry(entry(path)); + out.write(content.getBytes()); + out.closeEntry(); + } + + private static ZipEntry entry(String path) { + ZipEntry entry = new ZipEntry(path); + entry.setCreationTime(CREATION_TIME); + entry.setLastModifiedTime(LAST_MODIFIED_TIME); + entry.setLastAccessTime(LAST_ACCESS_TIME); + return entry; + } + + private String getFile(String fileName) throws Exception { + ClassPathResource resource = new ClassPathResource(fileName, getClass()); + InputStreamReader reader = new InputStreamReader(resource.getInputStream()); + return FileCopyUtils.copyToString(reader); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/IndexedJarStructureTests.java b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/IndexedJarStructureTests.java new file mode 100644 index 000000000000..48c44e6d013e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/java/org/springframework/boot/jarmode/layertools/IndexedJarStructureTests.java @@ -0,0 +1,181 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.jarmode.layertools; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.jarmode.layertools.JarStructure.Entry; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link IndexedJarStructure}. + * + * @author Stephane Nicoll + */ +class IndexedJarStructureTests { + + @Test + void createWhenIndexFileIsEmptyThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> createInstance(" \n ")) + .withMessage("Empty classpath index file loaded"); + } + + @Test + void createWhenIndexFileIsMalformedThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> createInstance("test")) + .withMessage("Classpath index file is malformed"); + } + + @Test + void createWhenManifestDoesNotHaveLibLocationThrowsException() { + Manifest manifest = createDefaultManifest(); + manifest.getMainAttributes().remove(new Name("Spring-Boot-Lib")); + assertThatIllegalStateException().isThrownBy(() -> new IndexedJarStructure(manifest, getIndex())) + .withMessage("Manifest attribute 'Spring-Boot-Lib' is mandatory"); + } + + @Test + void createWhenManifestDoesNotHaveClassesLocationThrowsException() { + Manifest manifest = createDefaultManifest(); + manifest.getMainAttributes().remove(new Name("Spring-Boot-Classes")); + assertThatIllegalStateException().isThrownBy(() -> new IndexedJarStructure(manifest, getIndex())) + .withMessage("Manifest attribute 'Spring-Boot-Classes' is mandatory"); + } + + @Test + void toRunManifestCreateClasspathEntriesInOrder() throws Exception { + IndexedJarStructure jarStructure = createInstance(getIndex()); + Manifest manifest = jarStructure.createRunJarManifest((lib) -> "libs/" + lib); + assertThat(manifest.getMainAttributes().get(Name.CLASS_PATH)).isEqualTo("libs/a.jar libs/b.jar"); + } + + @Test + void resolveEntryOnMatchingLib() throws Exception { + IndexedJarStructure jarStructure = createInstance(getIndex()); + assertThat(jarStructure.resolve(mockEntry("BOOT-INF/lib/a.jar"))) + .isEqualTo(new Entry("BOOT-INF/lib/a.jar", "a.jar", true)); + } + + @Test + void resolveEntryOnNonMatchingLib() throws Exception { + IndexedJarStructure jarStructure = createInstance(getIndex()); + assertThat(jarStructure.resolve(mockEntry("BOOT-INF/lib/not-in-index.jar"))).isNull(); + } + + @Test + void resolveEntryOnClass() throws Exception { + IndexedJarStructure jarStructure = createInstance(getIndex()); + assertThat(jarStructure.resolve(mockEntry("BOOT-INF/classes/com/example/DemoApplication.class"))) + .isEqualTo(new Entry("BOOT-INF/classes/com/example/DemoApplication.class", + "com/example/DemoApplication.class", false)); + } + + @Test + void getFileTypeOnNonMatchingResource() throws Exception { + IndexedJarStructure jarStructure = createInstance(getIndex()); + assertThat(jarStructure.resolve(mockEntry("META-INF/test.txt"))).isNull(); + } + + @Test + void getShouldReturnIndexedLayersFromContext(@TempDir File temp) throws Exception { + Context context = mock(Context.class); + given(context.getArchiveFile()).willReturn(createJarFile(temp, "test.jar")); + IndexedJarStructure jarStructure = IndexedJarStructure.get(context); + Manifest manifest = jarStructure.createRunJarManifest((dependency) -> "dependencies/" + dependency); + Attributes attributes = manifest.getMainAttributes(); + assertThat(attributes.get(Name.MAIN_CLASS)).isEqualTo("com.example.DemoApplication"); + assertThat(attributes.get(Name.CLASS_PATH)).isEqualTo("dependencies/a.jar dependencies/b.jar"); + assertThat(jarStructure.resolve(mockEntry("BOOT-INF/classes/com/example/Test.class"))) + .satisfies((entry) -> assertThat(entry.location()).isEqualTo("com/example/Test.class")); + assertThat(jarStructure.resolve(mockEntry("BOOT-INF/lib/a.jar"))) + .satisfies((entry) -> assertThat(entry.location()).isEqualTo("a.jar")); + assertThat(jarStructure.resolve(mockEntry("BOOT-INF/lib/c.jar"))).isNull(); + } + + private IndexedJarStructure createInstance(String indexFile) { + return new IndexedJarStructure(createDefaultManifest(), indexFile); + } + + private Manifest createDefaultManifest() { + return createManifest("com.example.DemoApplication", "BOOT-INF/lib/", "BOOT-INF/classes/"); + } + + private Manifest createManifest(String className, String libLocation, String classesLocation) { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.putValue("Start-Class", className); + attributes.putValue("Spring-Boot-Lib", libLocation); + attributes.putValue("Spring-Boot-Classes", classesLocation); + return manifest; + } + + private String getIndex() throws Exception { + return getFile("test-classpath.idx"); + } + + private String getFile(String fileName) throws Exception { + ClassPathResource resource = new ClassPathResource(fileName, getClass()); + InputStreamReader reader = new InputStreamReader(resource.getInputStream()); + return FileCopyUtils.copyToString(reader); + } + + private ZipEntry mockEntry(String name) { + ZipEntry entry = mock(ZipEntry.class); + given(entry.getName()).willReturn(name); + return entry; + } + + private File createJarFile(File temp, String name) throws Exception { + File file = new File(temp, name); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { + createEntries(out, "BOOT-INF/lib/a.jar", "BOOT-INF/lib/b.jar", "BOOT-INF/lib/c.jar", + "BOOT-INF/classes/com/example/DemoApplication.class"); + out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + out.write(getFile("test-manifest.MF").getBytes()); + out.closeEntry(); + out.putNextEntry(new ZipEntry("BOOT-INF/classpath.idx")); + out.write(getIndex().getBytes()); + out.closeEntry(); + } + return file; + } + + private static void createEntries(ZipOutputStream out, String... paths) throws IOException { + for (String path : paths) { + out.putNextEntry(new ZipEntry(path)); + out.closeEntry(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/resources/org/springframework/boot/jarmode/layertools/test-classpath.idx b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/resources/org/springframework/boot/jarmode/layertools/test-classpath.idx new file mode 100644 index 000000000000..85c4f0f14852 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-jarmode-layertools/src/test/resources/org/springframework/boot/jarmode/layertools/test-classpath.idx @@ -0,0 +1,2 @@ +- "BOOT-INF/lib/a.jar" +- "BOOT-INF/lib/b.jar"