From 16e6bc89ed2f074c0fba8eab4d9115d0d6ac2dcd Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 20 Jan 2020 15:43:12 -0800 Subject: [PATCH] Create a new ImagePackager tools class Pull functionality from `Repackager` into a new `Packager` base class and develop a variant for Docker image creation. The new `ImagePackager` class provides a general purpose way to construct jar entries without being tied to an actual file. This will allow us to link it to a buildpack and provide application content directly. Closes gh-19834 --- .../spring-boot-loader-tools/build.gradle | 3 +- .../boot/loader/tools/AbstractJarWriter.java | 388 ++++++++++ .../boot/loader/tools/EntryWriter.java | 47 ++ .../boot/loader/tools/ImagePackager.java | 80 ++ .../boot/loader/tools/JarWriter.java | 451 +---------- .../boot/loader/tools/Packager.java | 468 +++++++++++ .../boot/loader/tools/Repackager.java | 425 +--------- .../tools/SizeCalculatingEntryWriter.java | 143 ++++ .../tools/ZipHeaderPeekInputStream.java | 98 +++ .../loader/tools/AbstractPackagerTests.java | 641 ++++++++++++++++ .../boot/loader/tools/ImagePackagerTests.java | 92 +++ .../boot/loader/tools/RepackagerTests.java | 725 ++---------------- .../SizeCalculatingEntryWriterTests.java | 81 ++ .../tools/ZipHeaderPeekInputStreamTests.java | 4 +- 14 files changed, 2132 insertions(+), 1514 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/EntryWriter.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ImagePackager.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriter.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStream.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ImagePackagerTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriterTests.java diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle index b01ff3ff03..43add46200 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/build.gradle @@ -16,11 +16,10 @@ configurations { dependencies { api platform(project(':spring-boot-project:spring-boot-parent')) api "org.apache.commons:commons-compress" + api "org.springframework:spring-core" compileOnly "ch.qos.logback:logback-classic" - implementation "org.springframework:spring-core" - loader project(":spring-boot-project:spring-boot-tools:spring-boot-loader") testImplementation "org.assertj:assertj-core" diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java new file mode 100644 index 0000000000..016ad03321 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/AbstractJarWriter.java @@ -0,0 +1,388 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +import org.apache.commons.compress.archivers.jar.JarArchiveEntry; +import org.apache.commons.compress.archivers.zip.UnixStat; + +/** + * Abstract base class for JAR writers. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @since 2.3.0 + */ +public abstract class AbstractJarWriter implements LoaderClassesWriter { + + private static final String NESTED_LOADER_JAR = "META-INF/loader/spring-boot-loader.jar"; + + private static final int BUFFER_SIZE = 32 * 1024; + + private static final int UNIX_FILE_MODE = UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM; + + private static final int UNIX_DIR_MODE = UnixStat.DIR_FLAG | UnixStat.DEFAULT_DIR_PERM; + + private final Set writtenEntries = new HashSet<>(); + + /** + * Write the specified manifest. + * @param manifest the manifest to write + * @throws IOException of the manifest cannot be written + */ + public void writeManifest(Manifest manifest) throws IOException { + JarArchiveEntry entry = new JarArchiveEntry("META-INF/MANIFEST.MF"); + writeEntry(entry, manifest::write); + } + + /** + * Write all entries from the specified jar file. + * @param jarFile the source jar file + * @throws IOException if the entries cannot be written + */ + public void writeEntries(JarFile jarFile) throws IOException { + this.writeEntries(jarFile, EntryTransformer.NONE, UnpackHandler.NEVER); + } + + final void writeEntries(JarFile jarFile, EntryTransformer entryTransformer, UnpackHandler unpackHandler) + throws IOException { + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarArchiveEntry entry = new JarArchiveEntry(entries.nextElement()); + setUpEntry(jarFile, entry); + try (ZipHeaderPeekInputStream inputStream = new ZipHeaderPeekInputStream(jarFile.getInputStream(entry))) { + EntryWriter entryWriter = new InputStreamEntryWriter(inputStream); + JarArchiveEntry transformedEntry = entryTransformer.transform(entry); + if (transformedEntry != null) { + writeEntry(transformedEntry, entryWriter, unpackHandler); + } + } + } + } + + private void setUpEntry(JarFile jarFile, JarArchiveEntry entry) throws IOException { + try (ZipHeaderPeekInputStream inputStream = new ZipHeaderPeekInputStream(jarFile.getInputStream(entry))) { + if (inputStream.hasZipHeader() && entry.getMethod() != ZipEntry.STORED) { + new CrcAndSize(inputStream).setupStoredEntry(entry); + } + else { + entry.setCompressedSize(-1); + } + } + } + + /** + * Writes an entry. The {@code inputStream} is closed once the entry has been written + * @param entryName the name of the entry + * @param inputStream the stream from which the entry's data can be read + * @throws IOException if the write fails + */ + @Override + public void writeEntry(String entryName, InputStream inputStream) throws IOException { + JarArchiveEntry entry = new JarArchiveEntry(entryName); + try { + writeEntry(entry, new InputStreamEntryWriter(inputStream)); + } + finally { + inputStream.close(); + } + } + + /** + * Write a nested library. + * @param destination the destination of the library + * @param library the library + * @throws IOException if the write fails + */ + public void writeNestedLibrary(String destination, Library library) throws IOException { + File file = library.getFile(); + JarArchiveEntry entry = new JarArchiveEntry(destination + library.getName()); + entry.setTime(getNestedLibraryTime(file)); + new CrcAndSize(file).setupStoredEntry(entry); + try (FileInputStream input = new FileInputStream(file)) { + writeEntry(entry, new InputStreamEntryWriter(input), new LibraryUnpackHandler(library)); + } + } + + /** + * Write a simple index file containing the specified UTF-8 lines. + * @param location the location of the index file + * @param lines the lines to write + * @throws IOException if the write fails + * @since 2.3.0 + */ + public void writeIndexFile(String location, List lines) throws IOException { + if (location != null) { + JarArchiveEntry entry = new JarArchiveEntry(location); + writeEntry(entry, (outputStream) -> { + BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); + for (String line : lines) { + writer.write(line); + writer.write("\n"); + } + writer.flush(); + }); + } + } + + private long getNestedLibraryTime(File file) { + try { + try (JarFile jarFile = new JarFile(file)) { + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + return entry.getTime(); + } + } + } + } + catch (Exception ex) { + // Ignore and just use the source file timestamp + } + return file.lastModified(); + } + + /** + * Write the required spring-boot-loader classes to the JAR. + * @throws IOException if the classes cannot be written + */ + @Override + public void writeLoaderClasses() throws IOException { + writeLoaderClasses(NESTED_LOADER_JAR); + } + + /** + * Write the required spring-boot-loader classes to the JAR. + * @param loaderJarResourceName the name of the resource containing the loader classes + * to be written + * @throws IOException if the classes cannot be written + */ + @Override + public void writeLoaderClasses(String loaderJarResourceName) throws IOException { + URL loaderJar = getClass().getClassLoader().getResource(loaderJarResourceName); + try (JarInputStream inputStream = new JarInputStream(new BufferedInputStream(loaderJar.openStream()))) { + JarEntry entry; + while ((entry = inputStream.getNextJarEntry()) != null) { + if (entry.getName().endsWith(".class")) { + writeEntry(new JarArchiveEntry(entry), new InputStreamEntryWriter(inputStream)); + } + } + } + } + + private void writeEntry(JarArchiveEntry entry, EntryWriter entryWriter) throws IOException { + writeEntry(entry, entryWriter, UnpackHandler.NEVER); + } + + /** + * Perform the actual write of a {@link JarEntry}. All other write methods delegate to + * this one. + * @param entry the entry to write + * @param entryWriter the entry writer or {@code null} if there is no content + * @param unpackHandler handles possible unpacking for the entry + * @throws IOException in case of I/O errors + */ + private void writeEntry(JarArchiveEntry entry, EntryWriter entryWriter, UnpackHandler unpackHandler) + throws IOException { + String name = entry.getName(); + writeParentFolderEntries(name); + if (this.writtenEntries.add(name)) { + entry.setUnixMode(name.endsWith("/") ? UNIX_DIR_MODE : UNIX_FILE_MODE); + entry.getGeneralPurposeBit().useUTF8ForNames(true); + if (!entry.isDirectory() && entry.getSize() == -1) { + entryWriter = SizeCalculatingEntryWriter.get(entryWriter); + entry.setSize(entryWriter.size()); + } + entryWriter = addUnpackCommentIfNecessary(entry, entryWriter, unpackHandler); + writeToArchive(entry, entryWriter); + } + } + + protected abstract void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException; + + private void writeParentFolderEntries(String name) throws IOException { + String parent = name.endsWith("/") ? name.substring(0, name.length() - 1) : name; + while (parent.lastIndexOf('/') != -1) { + parent = parent.substring(0, parent.lastIndexOf('/')); + if (!parent.isEmpty()) { + writeEntry(new JarArchiveEntry(parent + "/"), null, UnpackHandler.NEVER); + } + } + } + + private EntryWriter addUnpackCommentIfNecessary(JarArchiveEntry entry, EntryWriter entryWriter, + UnpackHandler unpackHandler) throws IOException { + if (entryWriter == null || !unpackHandler.requiresUnpack(entry.getName())) { + return entryWriter; + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entryWriter.write(output); + entry.setComment("UNPACK:" + unpackHandler.sha1Hash(entry.getName())); + return new InputStreamEntryWriter(new ByteArrayInputStream(output.toByteArray())); + } + + /** + * {@link EntryWriter} that writes content from an {@link InputStream}. + */ + private static class InputStreamEntryWriter implements EntryWriter { + + private final InputStream inputStream; + + InputStreamEntryWriter(InputStream inputStream) { + this.inputStream = inputStream; + } + + @Override + public void write(OutputStream outputStream) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = this.inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } + + } + + /** + * Data holder for CRC and Size. + */ + private static class CrcAndSize { + + private final CRC32 crc = new CRC32(); + + private long size; + + CrcAndSize(File file) throws IOException { + try (FileInputStream inputStream = new FileInputStream(file)) { + load(inputStream); + } + } + + CrcAndSize(InputStream inputStream) throws IOException { + load(inputStream); + } + + private void load(InputStream inputStream) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + this.crc.update(buffer, 0, bytesRead); + this.size += bytesRead; + } + } + + void setupStoredEntry(JarArchiveEntry entry) { + entry.setSize(this.size); + entry.setCompressedSize(this.size); + entry.setCrc(this.crc.getValue()); + entry.setMethod(ZipEntry.STORED); + } + + } + + /** + * An {@code EntryTransformer} enables the transformation of {@link JarEntry jar + * entries} during the writing process. + */ + @FunctionalInterface + interface EntryTransformer { + + /** + * No-op entity transformer. + */ + EntryTransformer NONE = (jarEntry) -> jarEntry; + + JarArchiveEntry transform(JarArchiveEntry jarEntry); + + } + + /** + * An {@code UnpackHandler} determines whether or not unpacking is required and + * provides a SHA1 hash if required. + */ + interface UnpackHandler { + + UnpackHandler NEVER = new UnpackHandler() { + + @Override + public boolean requiresUnpack(String name) { + return false; + } + + @Override + public String sha1Hash(String name) throws IOException { + throw new UnsupportedOperationException(); + } + + }; + + boolean requiresUnpack(String name); + + String sha1Hash(String name) throws IOException; + + } + + /** + * {@link UnpackHandler} backed by a {@link Library}. + */ + private static final class LibraryUnpackHandler implements UnpackHandler { + + private final Library library; + + private LibraryUnpackHandler(Library library) { + this.library = library; + } + + @Override + public boolean requiresUnpack(String name) { + return this.library.isUnpackRequired(); + } + + @Override + public String sha1Hash(String name) throws IOException { + return FileUtils.sha1Hash(this.library.getFile()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/EntryWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/EntryWriter.java new file mode 100644 index 0000000000..5829ee8718 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/EntryWriter.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Interface used to write jar entry data. + * + * @author Phillip Webb + * @since 2.3.0 + */ +@FunctionalInterface +public interface EntryWriter { + + /** + * Write entry data to the specified output stream. + * @param outputStream the destination for the data + * @throws IOException in case of I/O errors + */ + void write(OutputStream outputStream) throws IOException; + + /** + * Return the size of the content that will be written, or {@code -1} if the size is + * not known. + * @return the size of the content + */ + default int size() { + return -1; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ImagePackager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ImagePackager.java new file mode 100644 index 0000000000..dad74aee02 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ImagePackager.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.File; +import java.io.IOException; +import java.util.function.BiConsumer; +import java.util.jar.JarFile; +import java.util.zip.ZipEntry; + +import org.springframework.util.Assert; + +/** + * Utility class that can be used to export a fully packaged archive to an OCI image. + * + * @author Phillip Webb + * @since 2.3.0 + */ +public class ImagePackager extends Packager { + + /** + * Create a new {@link ImagePackager} instance. + * @param source the source file to package + */ + public ImagePackager(File source) { + super(source, null); + } + + /** + * Create an packaged image. + * @param libraries the contained libraries + * @param exporter the exporter used to write the image + * @throws IOException on IO error + */ + public void packageImage(Libraries libraries, BiConsumer exporter) throws IOException { + packageImage(libraries, new DelegatingJarWriter(exporter)); + } + + private void packageImage(Libraries libraries, AbstractJarWriter writer) throws IOException { + File source = isAlreadyPackaged() ? getBackupFile() : getSource(); + Assert.state(source.exists() && source.isFile(), "Unable to read jar file " + source); + Assert.state(!isAlreadyPackaged(source), "Repackaged jar file " + source + " cannot be exported"); + try (JarFile sourceJar = new JarFile(source)) { + write(sourceJar, libraries, writer); + } + } + + /** + * {@link AbstractJarWriter} that delegates to a {@link BiConsumer}. + */ + private static class DelegatingJarWriter extends AbstractJarWriter { + + private BiConsumer exporter; + + DelegatingJarWriter(BiConsumer exporter) { + this.exporter = exporter; + } + + @Override + protected void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException { + this.exporter.accept(entry, entryWriter); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java index b54daf7a22..4a76519431 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java @@ -16,39 +16,20 @@ package org.springframework.boot.loader.tools; -import java.io.BufferedInputStream; -import java.io.BufferedWriter; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; -import java.io.FilterInputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.net.URL; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; -import java.util.Arrays; -import java.util.Enumeration; import java.util.HashSet; -import java.util.List; import java.util.Set; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; -import java.util.jar.JarInputStream; -import java.util.jar.Manifest; -import java.util.zip.CRC32; import java.util.zip.ZipEntry; +import java.util.zip.ZipException; import org.apache.commons.compress.archivers.jar.JarArchiveEntry; import org.apache.commons.compress.archivers.jar.JarArchiveOutputStream; -import org.apache.commons.compress.archivers.zip.UnixStat; /** * Writes JAR content, ensuring valid directory entries are always created and duplicate @@ -59,19 +40,9 @@ import org.apache.commons.compress.archivers.zip.UnixStat; * @author Madhura Bhave * @since 1.0.0 */ -public class JarWriter implements LoaderClassesWriter, AutoCloseable { +public class JarWriter extends AbstractJarWriter implements AutoCloseable { - private static final String NESTED_LOADER_JAR = "META-INF/loader/spring-boot-loader.jar"; - - private static final int BUFFER_SIZE = 32 * 1024; - - private static final int UNIX_FILE_MODE = UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM; - - private static final int UNIX_DIR_MODE = UnixStat.DIR_FLAG | UnixStat.DEFAULT_DIR_PERM; - - private final JarArchiveOutputStream jarOutput; - - private final Set writtenEntries = new HashSet<>(); + private final JarArchiveOutputStream jarOutputStream; /** * Create a new {@link JarWriter} instance. @@ -96,8 +67,8 @@ public class JarWriter implements LoaderClassesWriter, AutoCloseable { fileOutputStream.write(launchScript.toByteArray()); setExecutableFilePermission(file); } - this.jarOutput = new JarArchiveOutputStream(fileOutputStream); - this.jarOutput.setEncoding("UTF-8"); + this.jarOutputStream = new JarArchiveOutputStream(fileOutputStream); + this.jarOutputStream.setEncoding("UTF-8"); } private void setExecutableFilePermission(File file) { @@ -112,155 +83,20 @@ public class JarWriter implements LoaderClassesWriter, AutoCloseable { } } - /** - * Write the specified manifest. - * @param manifest the manifest to write - * @throws IOException of the manifest cannot be written - */ - public void writeManifest(Manifest manifest) throws IOException { - JarArchiveEntry entry = new JarArchiveEntry("META-INF/MANIFEST.MF"); - writeEntry(entry, manifest::write); - } - - /** - * Write all entries from the specified jar file. - * @param jarFile the source jar file - * @throws IOException if the entries cannot be written - */ - public void writeEntries(JarFile jarFile) throws IOException { - this.writeEntries(jarFile, EntryTransformer.NONE, UnpackHandler.NEVER); - } - - void writeEntries(JarFile jarFile, UnpackHandler unpackHandler) throws IOException { - this.writeEntries(jarFile, EntryTransformer.NONE, unpackHandler); - } - - void writeEntries(JarFile jarFile, EntryTransformer entryTransformer, UnpackHandler unpackHandler) - throws IOException { - Enumeration entries = jarFile.entries(); - while (entries.hasMoreElements()) { - JarArchiveEntry entry = new JarArchiveEntry(entries.nextElement()); - setUpEntry(jarFile, entry); - try (ZipHeaderPeekInputStream inputStream = new ZipHeaderPeekInputStream(jarFile.getInputStream(entry))) { - EntryWriter entryWriter = new InputStreamEntryWriter(inputStream); - JarArchiveEntry transformedEntry = entryTransformer.transform(entry); - if (transformedEntry != null) { - writeEntry(transformedEntry, entryWriter, unpackHandler); - } - } - } - } - - private void setUpEntry(JarFile jarFile, JarArchiveEntry entry) throws IOException { - try (ZipHeaderPeekInputStream inputStream = new ZipHeaderPeekInputStream(jarFile.getInputStream(entry))) { - if (inputStream.hasZipHeader() && entry.getMethod() != ZipEntry.STORED) { - new CrcAndSize(inputStream).setupStoredEntry(entry); - } - else { - entry.setCompressedSize(-1); - } - } - } - - /** - * Writes an entry. The {@code inputStream} is closed once the entry has been written - * @param entryName the name of the entry - * @param inputStream the stream from which the entry's data can be read - * @throws IOException if the write fails - */ @Override - public void writeEntry(String entryName, InputStream inputStream) throws IOException { - JarArchiveEntry entry = new JarArchiveEntry(entryName); - try { - writeEntry(entry, new InputStreamEntryWriter(inputStream)); - } - finally { - inputStream.close(); + protected void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException { + this.jarOutputStream.putArchiveEntry(asJarArchiveEntry(entry)); + if (entryWriter != null) { + entryWriter.write(this.jarOutputStream); } + this.jarOutputStream.closeArchiveEntry(); } - /** - * Write a nested library. - * @param destination the destination of the library - * @param library the library - * @throws IOException if the write fails - */ - public void writeNestedLibrary(String destination, Library library) throws IOException { - File file = library.getFile(); - JarArchiveEntry entry = new JarArchiveEntry(destination + library.getName()); - entry.setTime(getNestedLibraryTime(file)); - new CrcAndSize(file).setupStoredEntry(entry); - try (FileInputStream input = new FileInputStream(file)) { - writeEntry(entry, new InputStreamEntryWriter(input), new LibraryUnpackHandler(library)); - } - } - - /** - * Write a simple index file containing the specified UTF-8 lines. - * @param location the location of the index file - * @param lines the lines to write - * @throws IOException if the write fails - * @since 2.3.0 - */ - public void writeIndexFile(String location, List lines) throws IOException { - if (location != null) { - JarArchiveEntry entry = new JarArchiveEntry(location); - writeEntry(entry, (outputStream) -> { - BufferedWriter writer = new BufferedWriter( - new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); - for (String line : lines) { - writer.write(line); - writer.write("\n"); - } - writer.flush(); - }); - } - } - - private long getNestedLibraryTime(File file) { - try { - try (JarFile jarFile = new JarFile(file)) { - Enumeration entries = jarFile.entries(); - while (entries.hasMoreElements()) { - JarEntry entry = entries.nextElement(); - if (!entry.isDirectory()) { - return entry.getTime(); - } - } - } - } - catch (Exception ex) { - // Ignore and just use the source file timestamp - } - return file.lastModified(); - } - - /** - * Write the required spring-boot-loader classes to the JAR. - * @throws IOException if the classes cannot be written - */ - @Override - public void writeLoaderClasses() throws IOException { - writeLoaderClasses(NESTED_LOADER_JAR); - } - - /** - * Write the required spring-boot-loader classes to the JAR. - * @param loaderJarResourceName the name of the resource containing the loader classes - * to be written - * @throws IOException if the classes cannot be written - */ - @Override - public void writeLoaderClasses(String loaderJarResourceName) throws IOException { - URL loaderJar = getClass().getClassLoader().getResource(loaderJarResourceName); - try (JarInputStream inputStream = new JarInputStream(new BufferedInputStream(loaderJar.openStream()))) { - JarEntry entry; - while ((entry = inputStream.getNextJarEntry()) != null) { - if (entry.getName().endsWith(".class")) { - writeEntry(new JarArchiveEntry(entry), new InputStreamEntryWriter(inputStream)); - } - } + private JarArchiveEntry asJarArchiveEntry(ZipEntry entry) throws ZipException { + if (entry instanceof JarArchiveEntry) { + return (JarArchiveEntry) entry; } + return new JarArchiveEntry(entry); } /** @@ -269,264 +105,7 @@ public class JarWriter implements LoaderClassesWriter, AutoCloseable { */ @Override public void close() throws IOException { - this.jarOutput.close(); - } - - private void writeEntry(JarArchiveEntry entry, EntryWriter entryWriter) throws IOException { - writeEntry(entry, entryWriter, UnpackHandler.NEVER); - } - - /** - * Perform the actual write of a {@link JarEntry}. All other write methods delegate to - * this one. - * @param entry the entry to write - * @param entryWriter the entry writer or {@code null} if there is no content - * @param unpackHandler handles possible unpacking for the entry - * @throws IOException in case of I/O errors - */ - private void writeEntry(JarArchiveEntry entry, EntryWriter entryWriter, UnpackHandler unpackHandler) - throws IOException { - String name = entry.getName(); - writeParentFolderEntries(name); - if (this.writtenEntries.add(name)) { - entry.setUnixMode(name.endsWith("/") ? UNIX_DIR_MODE : UNIX_FILE_MODE); - entryWriter = addUnpackCommentIfNecessary(entry, entryWriter, unpackHandler); - this.jarOutput.putArchiveEntry(entry); - if (entryWriter != null) { - entryWriter.write(this.jarOutput); - } - this.jarOutput.closeArchiveEntry(); - } - } - - private void writeParentFolderEntries(String name) throws IOException { - String parent = name.endsWith("/") ? name.substring(0, name.length() - 1) : name; - while (parent.lastIndexOf('/') != -1) { - parent = parent.substring(0, parent.lastIndexOf('/')); - if (!parent.isEmpty()) { - writeEntry(new JarArchiveEntry(parent + "/"), null, UnpackHandler.NEVER); - } - } - } - - private EntryWriter addUnpackCommentIfNecessary(JarArchiveEntry entry, EntryWriter entryWriter, - UnpackHandler unpackHandler) throws IOException { - if (entryWriter == null || !unpackHandler.requiresUnpack(entry.getName())) { - return entryWriter; - } - ByteArrayOutputStream output = new ByteArrayOutputStream(); - entryWriter.write(output); - entry.setComment("UNPACK:" + unpackHandler.sha1Hash(entry.getName())); - return new InputStreamEntryWriter(new ByteArrayInputStream(output.toByteArray())); - } - - /** - * Interface used to write jar entry date. - */ - private interface EntryWriter { - - /** - * Write entry data to the specified output stream. - * @param outputStream the destination for the data - * @throws IOException in case of I/O errors - */ - void write(OutputStream outputStream) throws IOException; - - } - - /** - * {@link EntryWriter} that writes content from an {@link InputStream}. - */ - private static class InputStreamEntryWriter implements EntryWriter { - - private final InputStream inputStream; - - InputStreamEntryWriter(InputStream inputStream) { - this.inputStream = inputStream; - } - - @Override - public void write(OutputStream outputStream) throws IOException { - byte[] buffer = new byte[BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = this.inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - outputStream.flush(); - } - - } - - /** - * {@link InputStream} that can peek ahead at zip header bytes. - */ - static class ZipHeaderPeekInputStream extends FilterInputStream { - - private static final byte[] ZIP_HEADER = new byte[] { 0x50, 0x4b, 0x03, 0x04 }; - - private final byte[] header; - - private final int headerLength; - - private int position; - - private ByteArrayInputStream headerStream; - - protected ZipHeaderPeekInputStream(InputStream in) throws IOException { - super(in); - this.header = new byte[4]; - this.headerLength = in.read(this.header); - this.headerStream = new ByteArrayInputStream(this.header, 0, this.headerLength); - } - - @Override - public int read() throws IOException { - int read = (this.headerStream != null) ? this.headerStream.read() : -1; - if (read != -1) { - this.position++; - if (this.position >= this.headerLength) { - this.headerStream = null; - } - return read; - } - return super.read(); - } - - @Override - public int read(byte[] b) throws IOException { - return read(b, 0, b.length); - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - int read = (this.headerStream != null) ? this.headerStream.read(b, off, len) : -1; - if (read <= 0) { - return readRemainder(b, off, len); - } - this.position += read; - if (read < len) { - int remainderRead = readRemainder(b, off + read, len - read); - if (remainderRead > 0) { - read += remainderRead; - } - } - if (this.position >= this.headerLength) { - this.headerStream = null; - } - return read; - } - - boolean hasZipHeader() { - return Arrays.equals(this.header, ZIP_HEADER); - } - - private int readRemainder(byte[] b, int off, int len) throws IOException { - int read = super.read(b, off, len); - if (read > 0) { - this.position += read; - } - return read; - } - - } - - /** - * Data holder for CRC and Size. - */ - private static class CrcAndSize { - - private final CRC32 crc = new CRC32(); - - private long size; - - CrcAndSize(File file) throws IOException { - try (FileInputStream inputStream = new FileInputStream(file)) { - load(inputStream); - } - } - - CrcAndSize(InputStream inputStream) throws IOException { - load(inputStream); - } - - private void load(InputStream inputStream) throws IOException { - byte[] buffer = new byte[BUFFER_SIZE]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - this.crc.update(buffer, 0, bytesRead); - this.size += bytesRead; - } - } - - void setupStoredEntry(JarArchiveEntry entry) { - entry.setSize(this.size); - entry.setCompressedSize(this.size); - entry.setCrc(this.crc.getValue()); - entry.setMethod(ZipEntry.STORED); - } - - } - - /** - * An {@code EntryTransformer} enables the transformation of {@link JarEntry jar - * entries} during the writing process. - */ - @FunctionalInterface - interface EntryTransformer { - - /** - * No-op entity transformer. - */ - EntryTransformer NONE = (jarEntry) -> jarEntry; - - JarArchiveEntry transform(JarArchiveEntry jarEntry); - - } - - /** - * An {@code UnpackHandler} determines whether or not unpacking is required and - * provides a SHA1 hash if required. - */ - interface UnpackHandler { - - UnpackHandler NEVER = new UnpackHandler() { - - @Override - public boolean requiresUnpack(String name) { - return false; - } - - @Override - public String sha1Hash(String name) throws IOException { - throw new UnsupportedOperationException(); - } - - }; - - boolean requiresUnpack(String name); - - String sha1Hash(String name) throws IOException; - - } - - private static final class LibraryUnpackHandler implements UnpackHandler { - - private final Library library; - - private LibraryUnpackHandler(Library library) { - this.library = library; - } - - @Override - public boolean requiresUnpack(String name) { - return this.library.isUnpackRequired(); - } - - @Override - public String sha1Hash(String name) throws IOException { - return FileUtils.sha1Hash(this.library.getFile()); - } - + this.jarOutputStream.close(); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java new file mode 100644 index 0000000000..3adba2d7df --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java @@ -0,0 +1,468 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.TimeUnit; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import org.apache.commons.compress.archivers.jar.JarArchiveEntry; + +import org.springframework.boot.loader.tools.AbstractJarWriter.EntryTransformer; +import org.springframework.boot.loader.tools.AbstractJarWriter.UnpackHandler; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Abstract base class for packagers. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Stephane Nicoll + * @author Madhura Bhave + * @since 2.3.0 + */ +public abstract class Packager { + + private static final String MAIN_CLASS_ATTRIBUTE = "Main-Class"; + + private static final String START_CLASS_ATTRIBUTE = "Start-Class"; + + private static final String BOOT_VERSION_ATTRIBUTE = "Spring-Boot-Version"; + + private static final String BOOT_CLASSES_ATTRIBUTE = "Spring-Boot-Classes"; + + private static final String BOOT_LIB_ATTRIBUTE = "Spring-Boot-Lib"; + + private static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index"; + + private static final String BOOT_LAYERS_INDEX_ATTRIBUTE = "Spring-Boot-Layers-Index"; + + private static final byte[] ZIP_FILE_HEADER = new byte[] { 'P', 'K', 3, 4 }; + + private static final long FIND_WARNING_TIMEOUT = TimeUnit.SECONDS.toMillis(10); + + private static final String SPRING_BOOT_APPLICATION_CLASS_NAME = "org.springframework.boot.autoconfigure.SpringBootApplication"; + + private List mainClassTimeoutListeners = new ArrayList<>(); + + private String mainClass; + + private final File source; + + private Layout layout; + + private LayoutFactory layoutFactory; + + private Layers layers = Layers.IMPLICIT; + + /** + * Create a new {@link Packager} instance. + * @param source the source JAR file to package + * @param layoutFactory the layout factory to use or {@code null} + */ + protected Packager(File source, LayoutFactory layoutFactory) { + Assert.notNull(source, "Source file must not be null"); + Assert.isTrue(source.exists() && source.isFile(), + "Source must refer to an existing file, got " + source.getAbsolutePath()); + this.source = source.getAbsoluteFile(); + this.layoutFactory = layoutFactory; + } + + /** + * Add a listener that will be triggered to display a warning if searching for the + * main class takes too long. + * @param listener the listener to add + */ + public void addMainClassTimeoutWarningListener(MainClassTimeoutWarningListener listener) { + this.mainClassTimeoutListeners.add(listener); + } + + /** + * Sets the main class that should be run. If not specified the value from the + * MANIFEST will be used, or if no manifest entry is found the archive will be + * searched for a suitable class. + * @param mainClass the main class name + */ + public void setMainClass(String mainClass) { + this.mainClass = mainClass; + } + + /** + * Sets the layout to use for the jar. Defaults to {@link Layouts#forFile(File)}. + * @param layout the layout + */ + public void setLayout(Layout layout) { + Assert.notNull(layout, "Layout must not be null"); + this.layout = layout; + } + + /** + * Sets the layout factory for the jar. The factory can be used when no specific + * layout is specified. + * @param layoutFactory the layout factory to set + */ + public void setLayoutFactory(LayoutFactory layoutFactory) { + this.layoutFactory = layoutFactory; + } + + /** + * Sets the layers that should be used in the jar. + * @param layers the jar layers + * @see LayeredLayout + */ + public void setLayers(Layers layers) { + Assert.notNull(layers, "Layers must not be null"); + this.layers = layers; + } + + protected final boolean isAlreadyPackaged() throws IOException { + return isAlreadyPackaged(this.source); + } + + protected final boolean isAlreadyPackaged(File file) throws IOException { + try (JarFile jarFile = new JarFile(file)) { + Manifest manifest = jarFile.getManifest(); + return (manifest != null && manifest.getMainAttributes().getValue(BOOT_VERSION_ATTRIBUTE) != null); + } + } + + protected final void write(JarFile sourceJar, Libraries libraries, AbstractJarWriter writer) throws IOException { + Assert.notNull(libraries, "Libraries must not be null"); + WritableLibraries writeableLibraries = new WritableLibraries(libraries); + writer.writeManifest(buildManifest(sourceJar)); + writeLoaderClasses(writer); + writer.writeEntries(sourceJar, getEntityTransformer(), writeableLibraries); + writeableLibraries.write(writer); + } + + private void writeLoaderClasses(AbstractJarWriter writer) throws IOException { + Layout layout = getLayout(); + if (layout instanceof CustomLoaderLayout) { + ((CustomLoaderLayout) getLayout()).writeLoadedClasses(writer); + } + else if (layout.isExecutable()) { + writer.writeLoaderClasses(); + } + } + + private EntryTransformer getEntityTransformer() { + if (getLayout() instanceof RepackagingLayout) { + return new RepackagingEntryTransformer((RepackagingLayout) getLayout(), this.layers); + } + return EntryTransformer.NONE; + } + + private boolean isZip(File file) { + try { + try (FileInputStream inputStream = new FileInputStream(file)) { + return isZip(inputStream); + } + } + catch (IOException ex) { + return false; + } + } + + private boolean isZip(InputStream inputStream) throws IOException { + for (byte magicByte : ZIP_FILE_HEADER) { + if (inputStream.read() != magicByte) { + return false; + } + } + return true; + } + + private Manifest buildManifest(JarFile source) throws IOException { + Manifest manifest = createInitialManifest(source); + addMainAndStartAttributes(source, manifest); + addBootAttributes(manifest.getMainAttributes()); + return manifest; + } + + private Manifest createInitialManifest(JarFile source) throws IOException { + if (source.getManifest() != null) { + return new Manifest(source.getManifest()); + } + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + return manifest; + } + + private void addMainAndStartAttributes(JarFile source, Manifest manifest) throws IOException { + String mainClass = getMainClass(source, manifest); + String launcherClass = getLayout().getLauncherClassName(); + if (launcherClass != null) { + Assert.state(mainClass != null, "Unable to find main class"); + manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, launcherClass); + manifest.getMainAttributes().putValue(START_CLASS_ATTRIBUTE, mainClass); + } + else if (mainClass != null) { + manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, mainClass); + } + } + + private String getMainClass(JarFile source, Manifest manifest) throws IOException { + if (this.mainClass != null) { + return this.mainClass; + } + String attributeValue = manifest.getMainAttributes().getValue(MAIN_CLASS_ATTRIBUTE); + if (attributeValue != null) { + return attributeValue; + } + return findMainMethodWithTimeoutWarning(source); + } + + private String findMainMethodWithTimeoutWarning(JarFile source) throws IOException { + long startTime = System.currentTimeMillis(); + String mainMethod = findMainMethod(source); + long duration = System.currentTimeMillis() - startTime; + if (duration > FIND_WARNING_TIMEOUT) { + for (MainClassTimeoutWarningListener listener : this.mainClassTimeoutListeners) { + listener.handleTimeoutWarning(duration, mainMethod); + } + } + return mainMethod; + } + + protected String findMainMethod(JarFile source) throws IOException { + return MainClassFinder.findSingleMainClass(source, getLayout().getClassesLocation(), + SPRING_BOOT_APPLICATION_CLASS_NAME); + } + + /** + * Return the {@link File} to use to backup the original source. + * @return the file to use to backup the original source + */ + public final File getBackupFile() { + File source = getSource(); + return new File(source.getParentFile(), source.getName() + ".original"); + } + + protected final File getSource() { + return this.source; + } + + protected final Layout getLayout() { + if (this.layout == null) { + Layout createdLayout = getLayoutFactory().getLayout(this.source); + Assert.state(createdLayout != null, "Unable to detect layout"); + this.layout = createdLayout; + } + return this.layout; + } + + private LayoutFactory getLayoutFactory() { + if (this.layoutFactory != null) { + return this.layoutFactory; + } + List factories = SpringFactoriesLoader.loadFactories(LayoutFactory.class, null); + if (factories.isEmpty()) { + return new DefaultLayoutFactory(); + } + Assert.state(factories.size() == 1, "No unique LayoutFactory found"); + return factories.get(0); + } + + private void addBootAttributes(Attributes attributes) { + attributes.putValue(BOOT_VERSION_ATTRIBUTE, getClass().getPackage().getImplementationVersion()); + Layout layout = getLayout(); + if (layout instanceof LayeredLayout) { + addBootBootAttributesForLayeredLayout(attributes, (LayeredLayout) layout); + } + else if (layout instanceof RepackagingLayout) { + addBootBootAttributesForRepackagingLayout(attributes, (RepackagingLayout) layout); + } + else { + addBootBootAttributesForPlainLayout(attributes, layout); + } + } + + private void addBootBootAttributesForLayeredLayout(Attributes attributes, LayeredLayout layout) { + String layersIndexFileLocation = layout.getLayersIndexFileLocation(); + putIfHasLength(attributes, BOOT_LAYERS_INDEX_ATTRIBUTE, layersIndexFileLocation); + putIfHasLength(attributes, BOOT_CLASSPATH_INDEX_ATTRIBUTE, layout.getClasspathIndexFileLocation()); + } + + private void addBootBootAttributesForRepackagingLayout(Attributes attributes, RepackagingLayout layout) { + attributes.putValue(BOOT_CLASSES_ATTRIBUTE, layout.getRepackagedClassesLocation()); + putIfHasLength(attributes, BOOT_LIB_ATTRIBUTE, getLayout().getLibraryLocation("", LibraryScope.COMPILE)); + putIfHasLength(attributes, BOOT_CLASSPATH_INDEX_ATTRIBUTE, layout.getClasspathIndexFileLocation()); + } + + private void addBootBootAttributesForPlainLayout(Attributes attributes, Layout layout) { + attributes.putValue(BOOT_CLASSES_ATTRIBUTE, getLayout().getClassesLocation()); + putIfHasLength(attributes, BOOT_LIB_ATTRIBUTE, getLayout().getLibraryLocation("", LibraryScope.COMPILE)); + } + + private void putIfHasLength(Attributes attributes, String name, String value) { + if (StringUtils.hasLength(value)) { + attributes.putValue(name, value); + } + } + + /** + * Callback interface used to present a warning when finding the main class takes too + * long. + */ + @FunctionalInterface + public interface MainClassTimeoutWarningListener { + + /** + * Handle a timeout warning. + * @param duration the amount of time it took to find the main method + * @param mainMethod the main method that was actually found + */ + void handleTimeoutWarning(long duration, String mainMethod); + + } + + /** + * An {@code EntryTransformer} that renames entries by applying a prefix. + */ + private static final class RepackagingEntryTransformer implements EntryTransformer { + + private final RepackagingLayout layout; + + private final Layers layers; + + private RepackagingEntryTransformer(RepackagingLayout layout, Layers layers) { + this.layout = layout; + this.layers = layers; + } + + @Override + public JarArchiveEntry transform(JarArchiveEntry entry) { + if (entry.getName().equals("META-INF/INDEX.LIST")) { + return null; + } + if (!isTransformable(entry)) { + return entry; + } + String transformedName = transformName(entry.getName()); + JarArchiveEntry transformedEntry = new JarArchiveEntry(transformedName); + transformedEntry.setTime(entry.getTime()); + transformedEntry.setSize(entry.getSize()); + transformedEntry.setMethod(entry.getMethod()); + if (entry.getComment() != null) { + transformedEntry.setComment(entry.getComment()); + } + transformedEntry.setCompressedSize(entry.getCompressedSize()); + transformedEntry.setCrc(entry.getCrc()); + if (entry.getCreationTime() != null) { + transformedEntry.setCreationTime(entry.getCreationTime()); + } + if (entry.getExtra() != null) { + transformedEntry.setExtra(entry.getExtra()); + } + if (entry.getLastAccessTime() != null) { + transformedEntry.setLastAccessTime(entry.getLastAccessTime()); + } + if (entry.getLastModifiedTime() != null) { + transformedEntry.setLastModifiedTime(entry.getLastModifiedTime()); + } + return transformedEntry; + } + + private String transformName(String name) { + if (this.layout instanceof LayeredLayout) { + Layer layer = this.layers.getLayer(name); + Assert.state(layer != null, "Invalid 'null' layer from " + this.layers.getClass().getName()); + return ((LayeredLayout) this.layout).getRepackagedClassesLocation(layer) + name; + } + return this.layout.getRepackagedClassesLocation() + name; + } + + private boolean isTransformable(JarArchiveEntry entry) { + String name = entry.getName(); + if (name.startsWith("META-INF/")) { + return name.equals("META-INF/aop.xml") || name.endsWith(".kotlin_module"); + } + return !name.startsWith("BOOT-INF/") && !name.equals("module-info.class"); + } + + } + + /** + * An {@link UnpackHandler} that determines that an entry needs to be unpacked if a + * library that requires unpacking has a matching entry name. + */ + private final class WritableLibraries implements UnpackHandler { + + private final Map libraryEntryNames = new LinkedHashMap<>(); + + WritableLibraries(Libraries libraries) throws IOException { + libraries.doWithLibraries((library) -> { + if (isZip(library.getFile())) { + String location = getLocation(library); + if (location != null) { + Library existing = this.libraryEntryNames.putIfAbsent(location + library.getName(), library); + Assert.state(existing == null, "Duplicate library " + library.getName()); + } + } + }); + } + + private String getLocation(Library library) { + Layout layout = getLayout(); + if (layout instanceof LayeredLayout) { + Layers layers = Packager.this.layers; + Layer layer = layers.getLayer(library); + Assert.state(layer != null, "Invalid 'null' library layer from " + layers.getClass().getName()); + return ((LayeredLayout) layout).getLibraryLocation(library.getName(), library.getScope(), layer); + } + return layout.getLibraryLocation(library.getName(), library.getScope()); + } + + @Override + public boolean requiresUnpack(String name) { + Library library = this.libraryEntryNames.get(name); + return library != null && library.isUnpackRequired(); + } + + @Override + public String sha1Hash(String name) throws IOException { + Library library = this.libraryEntryNames.get(name); + Assert.notNull(library, "No library found for entry name '" + name + "'"); + return FileUtils.sha1Hash(library.getFile()); + } + + private void write(AbstractJarWriter writer) throws IOException { + for (Entry entry : this.libraryEntryNames.entrySet()) { + writer.writeNestedLibrary(entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1), + entry.getValue()); + } + if (getLayout() instanceof RepackagingLayout) { + String location = ((RepackagingLayout) getLayout()).getClasspathIndexFileLocation(); + writer.writeIndexFile(location, new ArrayList<>(this.libraryEntryNames.keySet())); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java index bc6bb4a21c..6b0f5ddae9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Repackager.java @@ -17,26 +17,10 @@ package org.springframework.boot.loader.tools; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.concurrent.TimeUnit; -import java.util.jar.Attributes; import java.util.jar.JarFile; -import java.util.jar.Manifest; -import org.apache.commons.compress.archivers.jar.JarArchiveEntry; - -import org.springframework.boot.loader.tools.JarWriter.EntryTransformer; -import org.springframework.boot.loader.tools.JarWriter.UnpackHandler; -import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; /** * Utility class that can be used to repackage an archive so that it can be executed using @@ -48,71 +32,16 @@ import org.springframework.util.StringUtils; * @author Madhura Bhave * @since 1.0.0 */ -public class Repackager { - - private static final String MAIN_CLASS_ATTRIBUTE = "Main-Class"; - - private static final String START_CLASS_ATTRIBUTE = "Start-Class"; - - private static final String BOOT_VERSION_ATTRIBUTE = "Spring-Boot-Version"; - - private static final String BOOT_CLASSES_ATTRIBUTE = "Spring-Boot-Classes"; - - private static final String BOOT_LIB_ATTRIBUTE = "Spring-Boot-Lib"; - - private static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index"; - - private static final String BOOT_LAYERS_INDEX_ATTRIBUTE = "Spring-Boot-Layers-Index"; - - private static final byte[] ZIP_FILE_HEADER = new byte[] { 'P', 'K', 3, 4 }; - - private static final long FIND_WARNING_TIMEOUT = TimeUnit.SECONDS.toMillis(10); - - private static final String SPRING_BOOT_APPLICATION_CLASS_NAME = "org.springframework.boot.autoconfigure.SpringBootApplication"; - - private List mainClassTimeoutListeners = new ArrayList<>(); - - private String mainClass; +public class Repackager extends Packager { private boolean backupSource = true; - private final File source; - - private Layout layout; - - private LayoutFactory layoutFactory; - - private Layers layers = Layers.IMPLICIT; - public Repackager(File source) { this(source, null); } public Repackager(File source, LayoutFactory layoutFactory) { - Assert.notNull(source, "Source file must be provided"); - Assert.isTrue(source.exists() && source.isFile(), - "Source must refer to an existing file, got " + source.getAbsolutePath()); - this.source = source.getAbsoluteFile(); - this.layoutFactory = layoutFactory; - } - - /** - * Add a listener that will be triggered to display a warning if searching for the - * main class takes too long. - * @param listener the listener to add - */ - public void addMainClassTimeoutWarningListener(MainClassTimeoutWarningListener listener) { - this.mainClassTimeoutListeners.add(listener); - } - - /** - * Sets the main class that should be run. If not specified the value from the - * MANIFEST will be used, or if no manifest entry is found the archive will be - * searched for a suitable class. - * @param mainClass the main class name - */ - public void setMainClass(String mainClass) { - this.mainClass = mainClass; + super(source, layoutFactory); } /** @@ -123,41 +52,13 @@ public class Repackager { this.backupSource = backupSource; } - /** - * Sets the layout to use for the jar. Defaults to {@link Layouts#forFile(File)}. - * @param layout the layout - */ - public void setLayout(Layout layout) { - Assert.notNull(layout, "Layout must not be null"); - this.layout = layout; - } - - /** - * Sets the layers that should be used in the jar. - * @param layers the jar layers - * @see LayeredLayout - */ - public void setLayers(Layers layers) { - Assert.notNull(layers, "Layers must not be null"); - this.layers = layers; - } - - /** - * Sets the layout factory for the jar. The factory can be used when no specific - * layout is specified. - * @param layoutFactory the layout factory to set - */ - public void setLayoutFactory(LayoutFactory layoutFactory) { - this.layoutFactory = layoutFactory; - } - /** * Repackage the source file so that it can be run using '{@literal java -jar}'. * @param libraries the libraries required to run the archive * @throws IOException if the file cannot be repackaged */ public void repackage(Libraries libraries) throws IOException { - repackage(this.source, libraries); + repackage(getSource(), libraries); } /** @@ -182,197 +83,34 @@ public class Repackager { */ public void repackage(File destination, Libraries libraries, LaunchScript launchScript) throws IOException { Assert.isTrue(destination != null && !destination.isDirectory(), "Invalid destination"); - Assert.notNull(libraries, "Libraries must not be null"); - if (this.layout == null) { - this.layout = getLayoutFactory().getLayout(this.source); - } destination = destination.getAbsoluteFile(); - File workingSource = this.source; - if (alreadyRepackaged() && this.source.equals(destination)) { + File source = getSource(); + if (isAlreadyPackaged() && source.equals(destination)) { return; } - if (this.source.equals(destination)) { + File workingSource = source; + if (source.equals(destination)) { workingSource = getBackupFile(); workingSource.delete(); - renameFile(this.source, workingSource); + renameFile(source, workingSource); } destination.delete(); try { - try (JarFile jarFileSource = new JarFile(workingSource)) { - repackage(jarFileSource, destination, libraries, launchScript); + try (JarFile sourceJar = new JarFile(workingSource)) { + repackage(sourceJar, destination, libraries, launchScript); } } finally { - if (!this.backupSource && !this.source.equals(workingSource)) { + if (!this.backupSource && !source.equals(workingSource)) { deleteFile(workingSource); } } } - private LayoutFactory getLayoutFactory() { - if (this.layoutFactory != null) { - return this.layoutFactory; - } - List factories = SpringFactoriesLoader.loadFactories(LayoutFactory.class, null); - if (factories.isEmpty()) { - return new DefaultLayoutFactory(); - } - Assert.state(factories.size() == 1, "No unique LayoutFactory found"); - return factories.get(0); - } - - /** - * Return the {@link File} to use to backup the original source. - * @return the file to use to backup the original source - */ - public final File getBackupFile() { - return new File(this.source.getParentFile(), this.source.getName() + ".original"); - } - - private boolean alreadyRepackaged() throws IOException { - try (JarFile jarFile = new JarFile(this.source)) { - Manifest manifest = jarFile.getManifest(); - return (manifest != null && manifest.getMainAttributes().getValue(BOOT_VERSION_ATTRIBUTE) != null); - } - } - private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript) throws IOException { - WritableLibraries writeableLibraries = new WritableLibraries(libraries); try (JarWriter writer = new JarWriter(destination, launchScript)) { - writer.writeManifest(buildManifest(sourceJar)); - writeLoaderClasses(writer); - writer.writeEntries(sourceJar, getEntityTransformer(), writeableLibraries); - writeableLibraries.write(writer); - } - } - - private void writeLoaderClasses(JarWriter writer) throws IOException { - if (this.layout instanceof CustomLoaderLayout) { - ((CustomLoaderLayout) this.layout).writeLoadedClasses(writer); - } - else if (this.layout.isExecutable()) { - writer.writeLoaderClasses(); - } - } - - private EntryTransformer getEntityTransformer() { - if (this.layout instanceof RepackagingLayout) { - return new RepackagingEntryTransformer((RepackagingLayout) this.layout, this.layers); - } - return EntryTransformer.NONE; - } - - private boolean isZip(File file) { - try { - try (FileInputStream fileInputStream = new FileInputStream(file)) { - return isZip(fileInputStream); - } - } - catch (IOException ex) { - return false; - } - } - - private boolean isZip(InputStream inputStream) throws IOException { - for (byte magicByte : ZIP_FILE_HEADER) { - if (inputStream.read() != magicByte) { - return false; - } - } - return true; - } - - private Manifest buildManifest(JarFile source) throws IOException { - Manifest manifest = createInitialManifest(source); - addMainAndStartAttributes(source, manifest); - addBootAttributes(manifest.getMainAttributes()); - return manifest; - } - - private Manifest createInitialManifest(JarFile source) throws IOException { - if (source.getManifest() != null) { - return new Manifest(source.getManifest()); - } - Manifest manifest = new Manifest(); - manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); - return manifest; - } - - private void addMainAndStartAttributes(JarFile source, Manifest manifest) throws IOException { - String mainClass = getMainClass(source, manifest); - String launcherClass = this.layout.getLauncherClassName(); - if (launcherClass != null) { - Assert.state(mainClass != null, "Unable to find main class"); - manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, launcherClass); - manifest.getMainAttributes().putValue(START_CLASS_ATTRIBUTE, mainClass); - } - else if (mainClass != null) { - manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, mainClass); - } - } - - private String getMainClass(JarFile source, Manifest manifest) throws IOException { - if (this.mainClass != null) { - return this.mainClass; - } - String attributeValue = manifest.getMainAttributes().getValue(MAIN_CLASS_ATTRIBUTE); - if (attributeValue != null) { - return attributeValue; - } - return findMainMethodWithTimeoutWarning(source); - } - - private String findMainMethodWithTimeoutWarning(JarFile source) throws IOException { - long startTime = System.currentTimeMillis(); - String mainMethod = findMainMethod(source); - long duration = System.currentTimeMillis() - startTime; - if (duration > FIND_WARNING_TIMEOUT) { - for (MainClassTimeoutWarningListener listener : this.mainClassTimeoutListeners) { - listener.handleTimeoutWarning(duration, mainMethod); - } - } - return mainMethod; - } - - protected String findMainMethod(JarFile source) throws IOException { - return MainClassFinder.findSingleMainClass(source, this.layout.getClassesLocation(), - SPRING_BOOT_APPLICATION_CLASS_NAME); - } - - private void addBootAttributes(Attributes attributes) { - attributes.putValue(BOOT_VERSION_ATTRIBUTE, getClass().getPackage().getImplementationVersion()); - if (this.layout instanceof LayeredLayout) { - addBootBootAttributesForLayeredLayout(attributes, (LayeredLayout) this.layout); - } - else if (this.layout instanceof RepackagingLayout) { - addBootBootAttributesForRepackagingLayout(attributes, (RepackagingLayout) this.layout); - } - else { - addBootBootAttributesForPlainLayout(attributes, this.layout); - } - } - - private void addBootBootAttributesForLayeredLayout(Attributes attributes, LayeredLayout layout) { - String layersIndexFileLocation = layout.getLayersIndexFileLocation(); - putIfHasLength(attributes, BOOT_LAYERS_INDEX_ATTRIBUTE, layersIndexFileLocation); - putIfHasLength(attributes, BOOT_CLASSPATH_INDEX_ATTRIBUTE, layout.getClasspathIndexFileLocation()); - } - - private void addBootBootAttributesForRepackagingLayout(Attributes attributes, RepackagingLayout layout) { - attributes.putValue(BOOT_CLASSES_ATTRIBUTE, layout.getRepackagedClassesLocation()); - putIfHasLength(attributes, BOOT_LIB_ATTRIBUTE, this.layout.getLibraryLocation("", LibraryScope.COMPILE)); - putIfHasLength(attributes, BOOT_CLASSPATH_INDEX_ATTRIBUTE, layout.getClasspathIndexFileLocation()); - } - - private void addBootBootAttributesForPlainLayout(Attributes attributes, Layout layout) { - attributes.putValue(BOOT_CLASSES_ATTRIBUTE, this.layout.getClassesLocation()); - putIfHasLength(attributes, BOOT_LIB_ATTRIBUTE, this.layout.getLibraryLocation("", LibraryScope.COMPILE)); - } - - private void putIfHasLength(Attributes attributes, String name, String value) { - if (StringUtils.hasLength(value)) { - attributes.putValue(name, value); + write(sourceJar, libraries, writer); } } @@ -388,143 +126,4 @@ public class Repackager { } } - /** - * Callback interface used to present a warning when finding the main class takes too - * long. - */ - @FunctionalInterface - public interface MainClassTimeoutWarningListener { - - /** - * Handle a timeout warning. - * @param duration the amount of time it took to find the main method - * @param mainMethod the main method that was actually found - */ - void handleTimeoutWarning(long duration, String mainMethod); - - } - - /** - * An {@code EntryTransformer} that renames entries by applying a prefix. - */ - private static final class RepackagingEntryTransformer implements EntryTransformer { - - private final RepackagingLayout layout; - - private final Layers layers; - - private RepackagingEntryTransformer(RepackagingLayout layout, Layers layers) { - this.layout = layout; - this.layers = layers; - } - - @Override - public JarArchiveEntry transform(JarArchiveEntry entry) { - if (entry.getName().equals("META-INF/INDEX.LIST")) { - return null; - } - if (!isTransformable(entry)) { - return entry; - } - String transformedName = transformName(entry.getName()); - JarArchiveEntry transformedEntry = new JarArchiveEntry(transformedName); - transformedEntry.setTime(entry.getTime()); - transformedEntry.setSize(entry.getSize()); - transformedEntry.setMethod(entry.getMethod()); - if (entry.getComment() != null) { - transformedEntry.setComment(entry.getComment()); - } - transformedEntry.setCompressedSize(entry.getCompressedSize()); - transformedEntry.setCrc(entry.getCrc()); - if (entry.getCreationTime() != null) { - transformedEntry.setCreationTime(entry.getCreationTime()); - } - if (entry.getExtra() != null) { - transformedEntry.setExtra(entry.getExtra()); - } - if (entry.getLastAccessTime() != null) { - transformedEntry.setLastAccessTime(entry.getLastAccessTime()); - } - if (entry.getLastModifiedTime() != null) { - transformedEntry.setLastModifiedTime(entry.getLastModifiedTime()); - } - return transformedEntry; - } - - private String transformName(String name) { - if (this.layout instanceof LayeredLayout) { - Layer layer = this.layers.getLayer(name); - Assert.state(layer != null, "Invalid 'null' layer from " + this.layers.getClass().getName()); - return ((LayeredLayout) this.layout).getRepackagedClassesLocation(layer) + name; - } - return this.layout.getRepackagedClassesLocation() + name; - } - - private boolean isTransformable(JarArchiveEntry entry) { - String name = entry.getName(); - if (name.startsWith("META-INF/")) { - return name.equals("META-INF/aop.xml") || name.endsWith(".kotlin_module"); - } - return !name.startsWith("BOOT-INF/") && !name.equals("module-info.class"); - } - - } - - /** - * An {@link UnpackHandler} that determines that an entry needs to be unpacked if a - * library that requires unpacking has a matching entry name. - */ - private final class WritableLibraries implements UnpackHandler { - - private final Map libraryEntryNames = new LinkedHashMap<>(); - - private WritableLibraries(Libraries libraries) throws IOException { - libraries.doWithLibraries((library) -> { - if (isZip(library.getFile())) { - String location = getLocation(library); - if (location != null) { - Library existing = this.libraryEntryNames.putIfAbsent(location + library.getName(), library); - Assert.state(existing == null, "Duplicate library " + library.getName()); - } - } - }); - } - - private String getLocation(Library library) { - Layout layout = Repackager.this.layout; - if (layout instanceof LayeredLayout) { - Layers layers = Repackager.this.layers; - Layer layer = layers.getLayer(library); - Assert.state(layer != null, "Invalid 'null' library layer from " + layers.getClass().getName()); - return ((LayeredLayout) layout).getLibraryLocation(library.getName(), library.getScope(), layer); - } - return layout.getLibraryLocation(library.getName(), library.getScope()); - } - - @Override - public boolean requiresUnpack(String name) { - Library library = this.libraryEntryNames.get(name); - return library != null && library.isUnpackRequired(); - } - - @Override - public String sha1Hash(String name) throws IOException { - Library library = this.libraryEntryNames.get(name); - Assert.notNull(library, "No library found for entry name '" + name + "'"); - return FileUtils.sha1Hash(library.getFile()); - } - - private void write(JarWriter writer) throws IOException { - for (Entry entry : this.libraryEntryNames.entrySet()) { - writer.writeNestedLibrary(entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1), - entry.getValue()); - } - if (Repackager.this.layout instanceof RepackagingLayout) { - String location = ((RepackagingLayout) (Repackager.this.layout)).getClasspathIndexFileLocation(); - writer.writeIndexFile(location, new ArrayList<>(this.libraryEntryNames.keySet())); - } - } - - } - } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriter.java new file mode 100644 index 0000000000..b46e06516e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriter.java @@ -0,0 +1,143 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.springframework.util.StreamUtils; + +/** + * {@link EntryWriter} that always provides size information. + * + * @author Phillip Webb + */ +final class SizeCalculatingEntryWriter implements EntryWriter { + + static final int THRESHOLD = 1024 * 20; + + private final Object content; + + private final int size; + + private SizeCalculatingEntryWriter(EntryWriter entryWriter) throws IOException { + SizeCalculatingOutputStream outputStream = new SizeCalculatingOutputStream(); + try { + entryWriter.write(outputStream); + } + finally { + outputStream.close(); + } + this.content = outputStream.getContent(); + this.size = outputStream.getSize(); + } + + @Override + public void write(OutputStream outputStream) throws IOException { + InputStream inputStream = getContentInputStream(); + copy(inputStream, outputStream); + } + + private InputStream getContentInputStream() throws FileNotFoundException { + if (this.content instanceof File) { + return new FileInputStream((File) this.content); + } + return new ByteArrayInputStream((byte[]) this.content); + } + + private void copy(InputStream inputStream, OutputStream outputStream) throws IOException { + try { + StreamUtils.copy(inputStream, outputStream); + } + finally { + inputStream.close(); + } + } + + @Override + public int size() { + return this.size; + } + + static EntryWriter get(EntryWriter entryWriter) throws IOException { + if (entryWriter == null || entryWriter.size() != -1) { + return entryWriter; + } + return new SizeCalculatingEntryWriter(entryWriter); + } + + /** + * {@link OutputStream} to calculate the size and allow content to be written again. + */ + private static class SizeCalculatingOutputStream extends OutputStream { + + private int size = 0; + + private File tempFile; + + private OutputStream outputStream; + + SizeCalculatingOutputStream() throws IOException { + this.tempFile = File.createTempFile("springboot-", "-entrycontent"); + this.outputStream = new ByteArrayOutputStream(); + } + + @Override + public void write(int b) throws IOException { + write(new byte[] { (byte) b }, 0, 1); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + int updatedSize = this.size + len; + if (updatedSize > THRESHOLD && this.outputStream instanceof ByteArrayOutputStream) { + this.outputStream = convertToFileOutputStream((ByteArrayOutputStream) this.outputStream); + } + this.outputStream.write(b, off, len); + this.size = updatedSize; + } + + private OutputStream convertToFileOutputStream(ByteArrayOutputStream byteArrayOutputStream) throws IOException { + FileOutputStream fileOutputStream = new FileOutputStream(this.tempFile); + StreamUtils.copy(byteArrayOutputStream.toByteArray(), fileOutputStream); + return fileOutputStream; + } + + @Override + public void close() throws IOException { + this.outputStream.close(); + } + + Object getContent() { + return (this.outputStream instanceof ByteArrayOutputStream) + ? ((ByteArrayOutputStream) this.outputStream).toByteArray() : this.tempFile; + } + + int getSize() { + return this.size; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStream.java new file mode 100644 index 0000000000..8e2325d579 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStream.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +/** + * {@link InputStream} that can peek ahead at zip header bytes. + * + * @author Phillip Webb + */ +class ZipHeaderPeekInputStream extends FilterInputStream { + + private static final byte[] ZIP_HEADER = new byte[] { 0x50, 0x4b, 0x03, 0x04 }; + + private final byte[] header; + + private final int headerLength; + + private int position; + + private ByteArrayInputStream headerStream; + + protected ZipHeaderPeekInputStream(InputStream in) throws IOException { + super(in); + this.header = new byte[4]; + this.headerLength = in.read(this.header); + this.headerStream = new ByteArrayInputStream(this.header, 0, this.headerLength); + } + + @Override + public int read() throws IOException { + int read = (this.headerStream != null) ? this.headerStream.read() : -1; + if (read != -1) { + this.position++; + if (this.position >= this.headerLength) { + this.headerStream = null; + } + return read; + } + return super.read(); + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int read = (this.headerStream != null) ? this.headerStream.read(b, off, len) : -1; + if (read <= 0) { + return readRemainder(b, off, len); + } + this.position += read; + if (read < len) { + int remainderRead = readRemainder(b, off + read, len - read); + if (remainderRead > 0) { + read += remainderRead; + } + } + if (this.position >= this.headerLength) { + this.headerStream = null; + } + return read; + } + + boolean hasZipHeader() { + return Arrays.equals(this.header, ZIP_HEADER); + } + + private int readRemainder(byte[] b, int off, int len) throws IOException { + int read = super.read(b, off, len); + if (read > 0) { + this.position += read; + } + return read; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java new file mode 100644 index 0000000000..f0bda55d6b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java @@ -0,0 +1,641 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.Manifest; +import java.util.stream.Collectors; +import java.util.zip.Deflater; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.zeroturnaround.zip.ZipUtil; + +import org.springframework.boot.loader.tools.sample.ClassWithMainMethod; +import org.springframework.boot.loader.tools.sample.ClassWithoutMainMethod; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Abstract class for {@link Packager} based tests. + * + * @param

The packager type + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + */ +abstract class AbstractPackagerTests

{ + + protected static final Libraries NO_LIBRARIES = (callback) -> { + }; + + private static final long JAN_1_1980; + static { + Calendar calendar = Calendar.getInstance(); + calendar.set(1980, 0, 1, 0, 0, 0); + calendar.set(Calendar.MILLISECOND, 0); + JAN_1_1980 = calendar.getTime().getTime(); + } + + private static final long JAN_1_1985; + static { + Calendar calendar = Calendar.getInstance(); + calendar.set(1985, 0, 1, 0, 0, 0); + calendar.set(Calendar.MILLISECOND, 0); + JAN_1_1985 = calendar.getTime().getTime(); + } + + @TempDir + File tempDir; + + protected TestJarFile testJarFile; + + @BeforeEach + void setup() throws IOException { + this.testJarFile = new TestJarFile(this.tempDir); + } + + @Test + void specificMainClass() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); + P packager = createPackager(); + packager.setMainClass("a.b.C"); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) + .isEqualTo("org.springframework.boot.loader.JarLauncher"); + assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); + assertThat(hasPackagedLauncherClasses()).isTrue(); + } + + @Test + void mainClassFromManifest() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + manifest.getMainAttributes().putValue("Main-Class", "a.b.C"); + this.testJarFile.addManifest(manifest); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) + .isEqualTo("org.springframework.boot.loader.JarLauncher"); + assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); + assertThat(hasPackagedLauncherClasses()).isTrue(); + } + + @Test + void mainClassFound() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) + .isEqualTo("org.springframework.boot.loader.JarLauncher"); + assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); + assertThat(hasPackagedLauncherClasses()).isTrue(); + } + + @Test + void multipleMainClassFound() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + this.testJarFile.addClass("a/b/D.class", ClassWithMainMethod.class); + P packager = createPackager(); + assertThatIllegalStateException().isThrownBy(() -> execute(packager, NO_LIBRARIES)).withMessageContaining( + "Unable to find a single main class from the following candidates [a.b.C, a.b.D]"); + } + + @Test + void noMainClass() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); + P packager = createPackager(this.testJarFile.getFile()); + assertThatIllegalStateException().isThrownBy(() -> execute(packager, NO_LIBRARIES)) + .withMessageContaining("Unable to find main class"); + } + + @Test + void noMainClassAndLayoutIsNone() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + packager.setLayout(new Layouts.None()); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes().getValue("Main-Class")).isEqualTo("a.b.C"); + assertThat(hasPackagedLauncherClasses()).isFalse(); + } + + @Test + void noMainClassAndLayoutIsNoneWithNoMain() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); + P packager = createPackager(); + packager.setLayout(new Layouts.None()); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes().getValue("Main-Class")).isNull(); + assertThat(hasPackagedLauncherClasses()).isFalse(); + } + + @Test + void nullLibraries() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + assertThatIllegalArgumentException().isThrownBy(() -> execute(packager, null)) + .withMessageContaining("Libraries must not be null"); + } + + @Test + void libraries() throws Exception { + TestJarFile libJar = new TestJarFile(this.tempDir); + libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); + File libJarFile = libJar.getFile(); + File libJarFileToUnpack = libJar.getFile(); + File libNonJarFile = new File(this.tempDir, "non-lib.jar"); + FileCopyUtils.copy(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }, libNonJarFile); + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + this.testJarFile.addFile("BOOT-INF/lib/" + libJarFileToUnpack.getName(), libJarFileToUnpack); + libJarFile.setLastModified(JAN_1_1980); + P packager = createPackager(); + execute(packager, (callback) -> { + callback.library(new Library(libJarFile, LibraryScope.COMPILE)); + callback.library(new Library(libJarFileToUnpack, LibraryScope.COMPILE, true)); + callback.library(new Library(libNonJarFile, LibraryScope.COMPILE)); + }); + assertThat(hasPackagedEntry("BOOT-INF/lib/" + libJarFile.getName())).isTrue(); + assertThat(hasPackagedEntry("BOOT-INF/lib/" + libJarFileToUnpack.getName())).isTrue(); + assertThat(hasPackagedEntry("BOOT-INF/lib/" + libNonJarFile.getName())).isFalse(); + ZipEntry entry = getPackagedEntry("BOOT-INF/lib/" + libJarFile.getName()); + assertThat(entry.getTime()).isEqualTo(JAN_1_1985); + entry = getPackagedEntry("BOOT-INF/lib/" + libJarFileToUnpack.getName()); + assertThat(entry.getComment()).startsWith("UNPACK:"); + assertThat(entry.getComment()).hasSize(47); + } + + @Test + void index() throws Exception { + TestJarFile libJar1 = new TestJarFile(this.tempDir); + libJar1.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); + File libJarFile1 = libJar1.getFile(); + TestJarFile libJar2 = new TestJarFile(this.tempDir); + libJar2.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); + File libJarFile2 = libJar2.getFile(); + TestJarFile libJar3 = new TestJarFile(this.tempDir); + libJar3.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); + File libJarFile3 = libJar3.getFile(); + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + File file = this.testJarFile.getFile(); + P packager = createPackager(file); + execute(packager, (callback) -> { + callback.library(new Library(libJarFile1, LibraryScope.COMPILE)); + callback.library(new Library(libJarFile2, LibraryScope.COMPILE)); + callback.library(new Library(libJarFile3, LibraryScope.COMPILE)); + }); + assertThat(hasPackagedEntry("BOOT-INF/classpath.idx")).isTrue(); + String index = getPackagedEntryContent("BOOT-INF/classpath.idx"); + String[] libraries = index.split("\\r?\\n"); + assertThat(Arrays.asList(libraries)).contains("BOOT-INF/lib/" + libJarFile1.getName(), + "BOOT-INF/lib/" + libJarFile2.getName(), "BOOT-INF/lib/" + libJarFile3.getName()); + } + + @Test + void layeredLayout() throws Exception { + TestJarFile libJar1 = new TestJarFile(this.tempDir); + libJar1.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); + File libJarFile1 = libJar1.getFile(); + TestJarFile libJar2 = new TestJarFile(this.tempDir); + libJar2.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); + File libJarFile2 = libJar2.getFile(); + TestJarFile libJar3 = new TestJarFile(this.tempDir); + libJar3.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); + File libJarFile3 = libJar3.getFile(); + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + TestLayers layers = new TestLayers(); + layers.addLibrary(libJarFile1, "0001"); + layers.addLibrary(libJarFile2, "0002"); + layers.addLibrary(libJarFile3, "0003"); + packager.setLayers(layers); + packager.setLayout(new Layouts.LayeredJar()); + execute(packager, (callback) -> { + callback.library(new Library(libJarFile1, LibraryScope.COMPILE)); + callback.library(new Library(libJarFile2, LibraryScope.COMPILE)); + callback.library(new Library(libJarFile3, LibraryScope.COMPILE)); + }); + assertThat(hasPackagedEntry("BOOT-INF/classpath.idx")).isTrue(); + String index = getPackagedEntryContent("BOOT-INF/classpath.idx"); + String[] libraries = index.split("\\n"); + List expected = new ArrayList<>(); + expected.add("BOOT-INF/layers/0001/lib/" + libJarFile1.getName()); + expected.add("BOOT-INF/layers/0002/lib/" + libJarFile2.getName()); + expected.add("BOOT-INF/layers/0003/lib/" + libJarFile3.getName()); + assertThat(Arrays.asList(libraries)).containsExactly(expected.toArray(new String[0])); + } + + @Test + void duplicateLibraries() throws Exception { + TestJarFile libJar = new TestJarFile(this.tempDir); + libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); + File libJarFile = libJar.getFile(); + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + assertThatIllegalStateException().isThrownBy(() -> execute(packager, (callback) -> { + callback.library(new Library(libJarFile, LibraryScope.COMPILE, false)); + callback.library(new Library(libJarFile, LibraryScope.COMPILE, false)); + })).withMessageContaining("Duplicate library"); + } + + @Test + void customLayout() throws Exception { + TestJarFile libJar = new TestJarFile(this.tempDir); + libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); + File libJarFile = libJar.getFile(); + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + Layout layout = mock(Layout.class); + LibraryScope scope = mock(LibraryScope.class); + given(layout.getLauncherClassName()).willReturn("testLauncher"); + given(layout.getLibraryLocation(anyString(), eq(scope))).willReturn("test/"); + given(layout.getLibraryLocation(anyString(), eq(LibraryScope.COMPILE))).willReturn("test-lib/"); + packager.setLayout(layout); + execute(packager, (callback) -> callback.library(new Library(libJarFile, scope))); + assertThat(hasPackagedEntry("test/" + libJarFile.getName())).isTrue(); + assertThat(getPackagedManifest().getMainAttributes().getValue("Spring-Boot-Lib")).isEqualTo("test-lib/"); + assertThat(getPackagedManifest().getMainAttributes().getValue("Main-Class")).isEqualTo("testLauncher"); + } + + @Test + void customLayoutNoBootLib() throws Exception { + TestJarFile libJar = new TestJarFile(this.tempDir); + libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); + File libJarFile = libJar.getFile(); + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + Layout layout = mock(Layout.class); + LibraryScope scope = mock(LibraryScope.class); + given(layout.getLauncherClassName()).willReturn("testLauncher"); + packager.setLayout(layout); + execute(packager, (callback) -> callback.library(new Library(libJarFile, scope))); + assertThat(getPackagedManifest().getMainAttributes().getValue("Spring-Boot-Lib")).isNull(); + assertThat(getPackagedManifest().getMainAttributes().getValue("Main-Class")).isEqualTo("testLauncher"); + } + + @Test + void springBootVersion() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes()).containsKey(new Attributes.Name("Spring-Boot-Version")); + } + + @Test + void executableJarLayoutAttributes() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Lib"), + "BOOT-INF/lib/"); + assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Classes"), + "BOOT-INF/classes/"); + } + + @Test + void executableWarLayoutAttributes() throws Exception { + this.testJarFile.addClass("WEB-INF/classes/a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(this.testJarFile.getFile("war")); + execute(packager, NO_LIBRARIES); + Manifest actualManifest = getPackagedManifest(); + assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Lib"), + "WEB-INF/lib/"); + assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Classes"), + "WEB-INF/classes/"); + } + + @Test + void nullCustomLayout() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); + Packager packager = createPackager(); + assertThatIllegalArgumentException().isThrownBy(() -> packager.setLayout(null)) + .withMessageContaining("Layout must not be null"); + } + + @Test + void dontRecompressZips() throws Exception { + TestJarFile nested = new TestJarFile(this.tempDir); + nested.addClass("a/b/C.class", ClassWithoutMainMethod.class); + File nestedFile = nested.getFile(); + this.testJarFile.addFile("test/nested.jar", nestedFile); + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + P packager = createPackager(); + execute(packager, (callback) -> callback.library(new Library(nestedFile, LibraryScope.COMPILE))); + assertThat(getPackagedEntry("BOOT-INF/lib/" + nestedFile.getName()).getMethod()).isEqualTo(ZipEntry.STORED); + assertThat(getPackagedEntry("BOOT-INF/classes/test/nested.jar").getMethod()).isEqualTo(ZipEntry.STORED); + } + + @Test + void unpackLibrariesTakePrecedenceOverExistingSourceEntries() throws Exception { + TestJarFile nested = new TestJarFile(this.tempDir); + nested.addClass("a/b/C.class", ClassWithoutMainMethod.class); + File nestedFile = nested.getFile(); + String name = "BOOT-INF/lib/" + nestedFile.getName(); + this.testJarFile.addFile(name, nested.getFile()); + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + P packager = createPackager(); + execute(packager, (callback) -> callback.library(new Library(nestedFile, LibraryScope.COMPILE, true))); + assertThat(getPackagedEntry(name).getComment()).startsWith("UNPACK:"); + } + + @Test + void existingSourceEntriesTakePrecedenceOverStandardLibraries() throws Exception { + TestJarFile nested = new TestJarFile(this.tempDir); + nested.addClass("a/b/C.class", ClassWithoutMainMethod.class); + File nestedFile = nested.getFile(); + this.testJarFile.addFile("BOOT-INF/lib/" + nestedFile.getName(), nested.getFile()); + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + P packager = createPackager(); + long sourceLength = nestedFile.length(); + execute(packager, (callback) -> { + nestedFile.delete(); + File toZip = new File(this.tempDir, "to-zip"); + toZip.createNewFile(); + ZipUtil.packEntry(toZip, nestedFile); + callback.library(new Library(nestedFile, LibraryScope.COMPILE)); + }); + assertThat(getPackagedEntry("BOOT-INF/lib/" + nestedFile.getName()).getSize()).isEqualTo(sourceLength); + } + + @Test + void metaInfIndexListIsRemovedFromRepackagedJar() throws Exception { + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + File indexList = new File(this.tempDir, "INDEX.LIST"); + indexList.createNewFile(); + this.testJarFile.addFile("META-INF/INDEX.LIST", indexList); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + assertThat(getPackagedEntry("META-INF/INDEX.LIST")).isNull(); + } + + @Test + void customLayoutFactoryWithoutLayout() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + packager.setLayoutFactory(new TestLayoutFactory()); + execute(packager, NO_LIBRARIES); + assertThat(getPackagedEntry("test")).isNotNull(); + } + + @Test + void customLayoutFactoryWithLayout() throws Exception { + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + packager.setLayoutFactory(new TestLayoutFactory()); + packager.setLayout(new Layouts.Jar()); + execute(packager, NO_LIBRARIES); + assertThat(getPackagedEntry("test")).isNull(); + } + + @Test + void metaInfAopXmlIsMovedBeneathBootInfClassesWhenRepackaged() throws Exception { + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + File aopXml = new File(this.tempDir, "aop.xml"); + aopXml.createNewFile(); + this.testJarFile.addFile("META-INF/aop.xml", aopXml); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + assertThat(getPackagedEntry("META-INF/aop.xml")).isNull(); + assertThat(getPackagedEntry("BOOT-INF/classes/META-INF/aop.xml")).isNotNull(); + } + + @Test + void allEntriesUseUnixPlatformAndUtf8NameEncoding() throws IOException { + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + for (ZipArchiveEntry entry : getAllPackagedEntries()) { + assertThat(entry.getPlatform()).isEqualTo(ZipArchiveEntry.PLATFORM_UNIX); + assertThat(entry.getGeneralPurposeBit().usesUTF8ForNames()).isTrue(); + } + } + + @Test + void loaderIsWrittenFirstThenApplicationClassesThenLibraries() throws IOException { + this.testJarFile.addClass("com/example/Application.class", ClassWithMainMethod.class); + File libraryOne = createLibrary(); + File libraryTwo = createLibrary(); + File libraryThree = createLibrary(); + P packager = createPackager(); + execute(packager, (callback) -> { + callback.library(new Library(libraryOne, LibraryScope.COMPILE, false)); + callback.library(new Library(libraryTwo, LibraryScope.COMPILE, true)); + callback.library(new Library(libraryThree, LibraryScope.COMPILE, false)); + }); + assertThat(getPackagedEntryNames()).containsSubsequence("org/springframework/boot/loader/", + "BOOT-INF/classes/com/example/Application.class", "BOOT-INF/lib/" + libraryOne.getName(), + "BOOT-INF/lib/" + libraryTwo.getName(), "BOOT-INF/lib/" + libraryThree.getName()); + } + + @Test + void existingEntryThatMatchesUnpackLibraryIsMarkedForUnpack() throws IOException { + File library = createLibrary(); + this.testJarFile.addClass("WEB-INF/classes/com/example/Application.class", ClassWithMainMethod.class); + this.testJarFile.addFile("WEB-INF/lib/" + library.getName(), library); + P packager = createPackager(this.testJarFile.getFile("war")); + packager.setLayout(new Layouts.War()); + execute(packager, (callback) -> callback.library(new Library(library, LibraryScope.COMPILE, true))); + assertThat(getPackagedEntryNames()).containsSubsequence("org/springframework/boot/loader/", + "WEB-INF/classes/com/example/Application.class", "WEB-INF/lib/" + library.getName()); + ZipEntry unpackLibrary = getPackagedEntry("WEB-INF/lib/" + library.getName()); + assertThat(unpackLibrary.getComment()).startsWith("UNPACK:"); + } + + @Test + void layoutCanOmitLibraries() throws IOException { + TestJarFile libJar = new TestJarFile(this.tempDir); + libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); + File libJarFile = libJar.getFile(); + this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); + P packager = createPackager(); + Layout layout = mock(Layout.class); + LibraryScope scope = mock(LibraryScope.class); + packager.setLayout(layout); + execute(packager, (callback) -> callback.library(new Library(libJarFile, scope))); + assertThat(getPackagedEntryNames()).containsExactly("META-INF/", "META-INF/MANIFEST.MF", "a/", "a/b/", + "a/b/C.class"); + } + + @Test + void jarThatUsesCustomCompressionConfigurationCanBeRepackaged() throws IOException { + File source = new File(this.tempDir, "source.jar"); + ZipOutputStream output = new ZipOutputStream(new FileOutputStream(source)) { + { + this.def = new Deflater(Deflater.NO_COMPRESSION, true); + } + }; + byte[] data = new byte[1024 * 1024]; + new Random().nextBytes(data); + ZipEntry entry = new ZipEntry("entry.dat"); + output.putNextEntry(entry); + output.write(data); + output.closeEntry(); + output.close(); + P packager = createPackager(source); + packager.setMainClass("com.example.Main"); + execute(packager, NO_LIBRARIES); + } + + @Test + void moduleInfoClassRemainsInRootOfJarWhenRepackaged() throws Exception { + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + this.testJarFile.addClass("module-info.class", ClassWithoutMainMethod.class); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + assertThat(getPackagedEntry("module-info.class")).isNotNull(); + assertThat(getPackagedEntry("BOOT-INF/classes/module-info.class")).isNull(); + } + + @Test + void kotlinModuleMetadataMovesBeneathBootInfClassesWhenRepackaged() throws Exception { + this.testJarFile.addClass("A.class", ClassWithMainMethod.class); + File kotlinModule = new File(this.tempDir, "test.kotlin_module"); + kotlinModule.createNewFile(); + this.testJarFile.addFile("META-INF/test.kotlin_module", kotlinModule); + P packager = createPackager(); + execute(packager, NO_LIBRARIES); + assertThat(getPackagedEntry("META-INF/test.kotlin_module")).isNull(); + assertThat(getPackagedEntry("BOOT-INF/classes/META-INF/test.kotlin_module")).isNotNull(); + } + + private File createLibrary() throws IOException { + TestJarFile library = new TestJarFile(this.tempDir); + library.addClass("com/example/library/Library.class", ClassWithoutMainMethod.class); + return library.getFile(); + } + + protected final P createPackager() throws IOException { + return createPackager(this.testJarFile.getFile()); + } + + protected abstract P createPackager(File source); + + protected abstract void execute(P packager, Libraries libraries) throws IOException; + + protected Collection getPackagedEntryNames() throws IOException { + return getAllPackagedEntries().stream().map(ZipArchiveEntry::getName).collect(Collectors.toList()); + } + + protected boolean hasPackagedLauncherClasses() throws IOException { + return hasPackagedEntry("org/springframework/boot/") + && hasPackagedEntry("org/springframework/boot/loader/JarLauncher.class"); + } + + private boolean hasPackagedEntry(String name) throws IOException { + return getPackagedEntry(name) != null; + } + + protected ZipEntry getPackagedEntry(String name) throws IOException { + return getAllPackagedEntries().stream().filter((entry) -> name.equals(entry.getName())).findFirst() + .orElse(null); + + } + + protected abstract Collection getAllPackagedEntries() throws IOException; + + protected abstract Manifest getPackagedManifest() throws IOException; + + protected abstract String getPackagedEntryContent(String name) throws IOException; + + static class TestLayoutFactory implements LayoutFactory { + + @Override + public Layout getLayout(File source) { + return new TestLayout(); + } + + } + + static class TestLayout extends Layouts.Jar implements CustomLoaderLayout { + + @Override + public void writeLoadedClasses(LoaderClassesWriter writer) throws IOException { + writer.writeEntry("test", new ByteArrayInputStream("test".getBytes())); + } + + } + + static class TestLayers implements Layers { + + private static final Layer DEFAULT_LAYER = new Layer("default"); + + private Set layers = new LinkedHashSet(); + + private Map libraries = new HashMap<>(); + + TestLayers() { + this.layers.add(DEFAULT_LAYER); + } + + void addLibrary(File jarFile, String layerName) { + Layer layer = new Layer(layerName); + this.layers.add(layer); + this.libraries.put(jarFile.getName(), layer); + } + + @Override + public Iterator iterator() { + return this.layers.iterator(); + } + + @Override + public Layer getLayer(String name) { + return DEFAULT_LAYER; + } + + @Override + public Layer getLayer(Library library) { + String name = new File(library.getName()).getName(); + return this.libraries.getOrDefault(name, DEFAULT_LAYER); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ImagePackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ImagePackagerTests.java new file mode 100644 index 0000000000..e97e6f37d4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ImagePackagerTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; + +/** + * Tests for {@link ImagePackager} + * + * @author Phillip Webb + */ +class ImagePackagerTests extends AbstractPackagerTests { + + private Map entries; + + @Override + protected ImagePackager createPackager(File source) { + return new ImagePackager(source); + } + + @Override + protected void execute(ImagePackager packager, Libraries libraries) throws IOException { + this.entries = new LinkedHashMap<>(); + packager.packageImage(libraries, this::save); + } + + private void save(ZipEntry entry, EntryWriter writer) { + try { + this.entries.put((ZipArchiveEntry) entry, getContent(writer)); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private byte[] getContent(EntryWriter writer) throws IOException { + if (writer == null) { + return null; + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + writer.write(outputStream); + return outputStream.toByteArray(); + } + + @Override + protected Collection getAllPackagedEntries() throws IOException { + return this.entries.keySet(); + } + + @Override + protected Manifest getPackagedManifest() throws IOException { + byte[] bytes = getEntryBytes("META-INF/MANIFEST.MF"); + return (bytes != null) ? new Manifest(new ByteArrayInputStream(bytes)) : null; + } + + @Override + protected String getPackagedEntryContent(String name) throws IOException { + byte[] bytes = getEntryBytes(name); + return (bytes != null) ? new String(bytes, StandardCharsets.UTF_8) : null; + } + + private byte[] getEntryBytes(String name) throws IOException { + ZipEntry entry = getPackagedEntry(name); + return (entry != null) ? this.entries.get(entry) : null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java index 7e3500405d..c92e043148 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/RepackagerTests.java @@ -16,52 +16,29 @@ package org.springframework.boot.loader.tools; -import java.io.ByteArrayInputStream; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.attribute.PosixFilePermission; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; +import java.util.Collection; import java.util.Enumeration; -import java.util.HashMap; -import java.util.Iterator; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Set; -import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.Manifest; -import java.util.zip.Deflater; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.zeroturnaround.zip.ZipUtil; import org.springframework.boot.loader.tools.sample.ClassWithMainMethod; -import org.springframework.boot.loader.tools.sample.ClassWithoutMainMethod; import org.springframework.util.FileCopyUtils; -import org.springframework.util.StreamUtils; +import org.springframework.util.StringUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; /** * Tests for {@link Repackager}. @@ -70,33 +47,9 @@ import static org.mockito.Mockito.mock; * @author Andy Wilkinson * @author Madhura Bhave */ -class RepackagerTests { +class RepackagerTests extends AbstractPackagerTests { - private static final Libraries NO_LIBRARIES = (callback) -> { - }; - - private static final long JAN_1_1980; - - private static final long JAN_1_1985; - - static { - Calendar calendar = Calendar.getInstance(); - calendar.set(1980, 0, 1, 0, 0, 0); - calendar.set(Calendar.MILLISECOND, 0); - JAN_1_1980 = calendar.getTime().getTime(); - calendar.set(Calendar.YEAR, 1985); - JAN_1_1985 = calendar.getTime().getTime(); - } - - @TempDir - File tempDir; - - private TestJarFile testJarFile; - - @BeforeEach - void setup() throws IOException { - this.testJarFile = new TestJarFile(this.tempDir); - } + private File destination; @Test void nullSource() { @@ -113,143 +66,55 @@ class RepackagerTests { assertThatIllegalArgumentException().isThrownBy(() -> new Repackager(this.tempDir)); } - @Test - void specificMainClass() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.setMainClass("a.b.C"); - repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.JarLauncher"); - assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); - assertThat(hasLauncherClasses(file)).isTrue(); - } - - @Test - void mainClassFromManifest() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); - Manifest manifest = new Manifest(); - manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); - manifest.getMainAttributes().putValue("Main-Class", "a.b.C"); - this.testJarFile.addManifest(manifest); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.JarLauncher"); - assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); - assertThat(hasLauncherClasses(file)).isTrue(); - } - - @Test - void mainClassFound() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) - .isEqualTo("org.springframework.boot.loader.JarLauncher"); - assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); - assertThat(hasLauncherClasses(file)).isTrue(); - } - @Test void jarIsOnlyRepackagedOnce() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); + Repackager repackager = createRepackager(this.testJarFile.getFile(), false); repackager.repackage(NO_LIBRARIES); repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); + Manifest actualManifest = getPackagedManifest(); assertThat(actualManifest.getMainAttributes().getValue("Main-Class")) .isEqualTo("org.springframework.boot.loader.JarLauncher"); assertThat(actualManifest.getMainAttributes().getValue("Start-Class")).isEqualTo("a.b.C"); - assertThat(hasLauncherClasses(file)).isTrue(); + assertThat(hasPackagedLauncherClasses()).isTrue(); } @Test - void multipleMainClassFound() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - this.testJarFile.addClass("a/b/D.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - assertThatIllegalStateException().isThrownBy(() -> repackager.repackage(NO_LIBRARIES)).withMessageContaining( - "Unable to find a single main class from the following candidates [a.b.C, a.b.D]"); - } - - @Test - void noMainClass() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); - assertThatIllegalStateException() - .isThrownBy(() -> new Repackager(this.testJarFile.getFile()).repackage(NO_LIBRARIES)) - .withMessageContaining("Unable to find main class"); - } - - @Test - void noMainClassAndLayoutIsNone() throws Exception { + void sameSourceAndDestinationWithoutBackup() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.setLayout(new Layouts.None()); - repackager.repackage(file, NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes().getValue("Main-Class")).isEqualTo("a.b.C"); - assertThat(hasLauncherClasses(file)).isFalse(); - } - - @Test - void noMainClassAndLayoutIsNoneWithNoMain() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.setLayout(new Layouts.None()); - repackager.repackage(file, NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes().getValue("Main-Class")).isNull(); - assertThat(hasLauncherClasses(file)).isFalse(); + Repackager repackager = createRepackager(file, false); + repackager.setBackupSource(false); + repackager.repackage(NO_LIBRARIES); + assertThat(new File(file.getParent(), file.getName() + ".original")).doesNotExist(); + assertThat(hasPackagedLauncherClasses()).isTrue(); } @Test void sameSourceAndDestinationWithBackup() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); + Repackager repackager = createRepackager(file, false); repackager.repackage(NO_LIBRARIES); assertThat(new File(file.getParent(), file.getName() + ".original")).exists(); - assertThat(hasLauncherClasses(file)).isTrue(); - } - - @Test - void sameSourceAndDestinationWithoutBackup() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.setBackupSource(false); - repackager.repackage(NO_LIBRARIES); - assertThat(new File(file.getParent(), file.getName() + ".original")).doesNotExist(); - assertThat(hasLauncherClasses(file)).isTrue(); + assertThat(hasPackagedLauncherClasses()).isTrue(); } @Test void differentDestination() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "different.jar"); - Repackager repackager = new Repackager(source); - repackager.repackage(dest, NO_LIBRARIES); + Repackager repackager = createRepackager(source, true); + execute(repackager, NO_LIBRARIES); assertThat(new File(source.getParent(), source.getName() + ".original")).doesNotExist(); assertThat(hasLauncherClasses(source)).isFalse(); - assertThat(hasLauncherClasses(dest)).isTrue(); + assertThat(hasPackagedLauncherClasses()).isTrue(); } @Test void nullDestination() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - Repackager repackager = new Repackager(this.testJarFile.getFile()); + Repackager repackager = createRepackager(this.testJarFile.getFile(), true); assertThatIllegalArgumentException().isThrownBy(() -> repackager.repackage(null, NO_LIBRARIES)) .withMessageContaining("Invalid destination"); } @@ -257,7 +122,7 @@ class RepackagerTests { @Test void destinationIsDirectory() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - Repackager repackager = new Repackager(this.testJarFile.getFile()); + Repackager repackager = createRepackager(this.testJarFile.getFile(), true); assertThatIllegalArgumentException().isThrownBy(() -> repackager.repackage(this.tempDir, NO_LIBRARIES)) .withMessageContaining("Invalid destination"); } @@ -265,498 +130,92 @@ class RepackagerTests { @Test void overwriteDestination() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - Repackager repackager = new Repackager(this.testJarFile.getFile()); - File dest = new File(this.tempDir, "dest.jar"); - dest.createNewFile(); - repackager.repackage(dest, NO_LIBRARIES); - assertThat(hasLauncherClasses(dest)).isTrue(); - } - - @Test - void nullLibraries() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - assertThatIllegalArgumentException().isThrownBy(() -> repackager.repackage(file, null)) - .withMessageContaining("Libraries must not be null"); - } - - @Test - void libraries() throws Exception { - TestJarFile libJar = new TestJarFile(this.tempDir); - libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); - File libJarFile = libJar.getFile(); - File libJarFileToUnpack = libJar.getFile(); - File libNonJarFile = new File(this.tempDir, "non-lib.jar"); - FileCopyUtils.copy(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }, libNonJarFile); - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - this.testJarFile.addFile("BOOT-INF/lib/" + libJarFileToUnpack.getName(), libJarFileToUnpack); - File file = this.testJarFile.getFile(); - libJarFile.setLastModified(JAN_1_1980); - Repackager repackager = new Repackager(file); - repackager.repackage((callback) -> { - callback.library(new Library(libJarFile, LibraryScope.COMPILE)); - callback.library(new Library(libJarFileToUnpack, LibraryScope.COMPILE, true)); - callback.library(new Library(libNonJarFile, LibraryScope.COMPILE)); - }); - assertThat(hasEntry(file, "BOOT-INF/lib/" + libJarFile.getName())).isTrue(); - assertThat(hasEntry(file, "BOOT-INF/lib/" + libJarFileToUnpack.getName())).isTrue(); - assertThat(hasEntry(file, "BOOT-INF/lib/" + libNonJarFile.getName())).isFalse(); - JarEntry entry = getEntry(file, "BOOT-INF/lib/" + libJarFile.getName()); - assertThat(entry.getTime()).isEqualTo(JAN_1_1985); - entry = getEntry(file, "BOOT-INF/lib/" + libJarFileToUnpack.getName()); - assertThat(entry.getComment()).startsWith("UNPACK:"); - assertThat(entry.getComment()).hasSize(47); - } - - @Test - void index() throws Exception { - TestJarFile libJar1 = new TestJarFile(this.tempDir); - libJar1.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); - File libJarFile1 = libJar1.getFile(); - TestJarFile libJar2 = new TestJarFile(this.tempDir); - libJar2.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); - File libJarFile2 = libJar2.getFile(); - TestJarFile libJar3 = new TestJarFile(this.tempDir); - libJar3.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); - File libJarFile3 = libJar3.getFile(); - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.repackage((callback) -> { - callback.library(new Library(libJarFile1, LibraryScope.COMPILE)); - callback.library(new Library(libJarFile2, LibraryScope.COMPILE)); - callback.library(new Library(libJarFile3, LibraryScope.COMPILE)); - }); - assertThat(hasEntry(file, "BOOT-INF/classpath.idx")).isTrue(); - ZipUtil.unpack(file, new File(file.getParent())); - try (FileInputStream inputStream = new FileInputStream( - new File(file.getParent() + "/BOOT-INF/classpath.idx"))) { - String index = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); - String[] libraries = index.split("\\r?\\n"); - assertThat(Arrays.asList(libraries)).contains("BOOT-INF/lib/" + libJarFile1.getName(), - "BOOT-INF/lib/" + libJarFile2.getName(), "BOOT-INF/lib/" + libJarFile3.getName()); - } - } - - @Test - void layeredLayout() throws Exception { - TestJarFile libJar1 = new TestJarFile(this.tempDir); - libJar1.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); - File libJarFile1 = libJar1.getFile(); - TestJarFile libJar2 = new TestJarFile(this.tempDir); - libJar2.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); - File libJarFile2 = libJar2.getFile(); - TestJarFile libJar3 = new TestJarFile(this.tempDir); - libJar3.addClass("a/b/C.class", ClassWithoutMainMethod.class, JAN_1_1985); - File libJarFile3 = libJar3.getFile(); - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - TestLayers layers = new TestLayers(); - layers.addLibrary(libJarFile1, "0001"); - layers.addLibrary(libJarFile2, "0002"); - layers.addLibrary(libJarFile3, "0003"); - repackager.setLayers(layers); - repackager.setLayout(new Layouts.LayeredJar()); - repackager.repackage((callback) -> { - callback.library(new Library(libJarFile1, LibraryScope.COMPILE)); - callback.library(new Library(libJarFile2, LibraryScope.COMPILE)); - callback.library(new Library(libJarFile3, LibraryScope.COMPILE)); - }); - assertThat(hasEntry(file, "BOOT-INF/classpath.idx")).isTrue(); - ZipUtil.unpack(file, new File(file.getParent())); - try (FileInputStream inputStream = new FileInputStream( - new File(file.getParent() + "/BOOT-INF/classpath.idx"))) { - String index = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); - String[] libraries = index.split("\\r?\\n"); - List expected = new ArrayList<>(); - expected.add("BOOT-INF/layers/0001/lib/" + libJarFile1.getName()); - expected.add("BOOT-INF/layers/0002/lib/" + libJarFile2.getName()); - expected.add("BOOT-INF/layers/0003/lib/" + libJarFile3.getName()); - assertThat(Arrays.asList(libraries)).containsExactly(expected.toArray(new String[0])); - } - } - - @Test - void duplicateLibraries() throws Exception { - TestJarFile libJar = new TestJarFile(this.tempDir); - libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File libJarFile = libJar.getFile(); - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - assertThatIllegalStateException().isThrownBy(() -> repackager.repackage((callback) -> { - callback.library(new Library(libJarFile, LibraryScope.COMPILE, false)); - callback.library(new Library(libJarFile, LibraryScope.COMPILE, false)); - })).withMessageContaining("Duplicate library"); - } - - @Test - void customLayout() throws Exception { - TestJarFile libJar = new TestJarFile(this.tempDir); - libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File libJarFile = libJar.getFile(); - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - Layout layout = mock(Layout.class); - LibraryScope scope = mock(LibraryScope.class); - given(layout.getLauncherClassName()).willReturn("testLauncher"); - given(layout.getLibraryLocation(anyString(), eq(scope))).willReturn("test/"); - given(layout.getLibraryLocation(anyString(), eq(LibraryScope.COMPILE))).willReturn("test-lib/"); - repackager.setLayout(layout); - repackager.repackage((callback) -> callback.library(new Library(libJarFile, scope))); - assertThat(hasEntry(file, "test/" + libJarFile.getName())).isTrue(); - assertThat(getManifest(file).getMainAttributes().getValue("Spring-Boot-Lib")).isEqualTo("test-lib/"); - assertThat(getManifest(file).getMainAttributes().getValue("Main-Class")).isEqualTo("testLauncher"); - } - - @Test - void customLayoutNoBootLib() throws Exception { - TestJarFile libJar = new TestJarFile(this.tempDir); - libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File libJarFile = libJar.getFile(); - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - Layout layout = mock(Layout.class); - LibraryScope scope = mock(LibraryScope.class); - given(layout.getLauncherClassName()).willReturn("testLauncher"); - repackager.setLayout(layout); - repackager.repackage((callback) -> callback.library(new Library(libJarFile, scope))); - assertThat(getManifest(file).getMainAttributes().getValue("Spring-Boot-Lib")).isNull(); - assertThat(getManifest(file).getMainAttributes().getValue("Main-Class")).isEqualTo("testLauncher"); - } - - @Test - void springBootVersion() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes()).containsKey(new Attributes.Name("Spring-Boot-Version")); - } - - @Test - void executableJarLayoutAttributes() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Lib"), - "BOOT-INF/lib/"); - assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Classes"), - "BOOT-INF/classes/"); - } - - @Test - void executableWarLayoutAttributes() throws Exception { - this.testJarFile.addClass("WEB-INF/classes/a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile("war"); - Repackager repackager = new Repackager(file); - repackager.repackage(NO_LIBRARIES); - Manifest actualManifest = getManifest(file); - assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Lib"), - "WEB-INF/lib/"); - assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Classes"), - "WEB-INF/classes/"); - } - - @Test - void nullCustomLayout() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithoutMainMethod.class); - Repackager repackager = new Repackager(this.testJarFile.getFile()); - assertThatIllegalArgumentException().isThrownBy(() -> repackager.setLayout(null)) - .withMessageContaining("Layout must not be null"); - } - - @Test - void dontRecompressZips() throws Exception { - TestJarFile nested = new TestJarFile(this.tempDir); - nested.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File nestedFile = nested.getFile(); - this.testJarFile.addFile("test/nested.jar", nestedFile); - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.repackage((callback) -> callback.library(new Library(nestedFile, LibraryScope.COMPILE))); - - try (JarFile jarFile = new JarFile(file)) { - assertThat(jarFile.getEntry("BOOT-INF/lib/" + nestedFile.getName()).getMethod()).isEqualTo(ZipEntry.STORED); - assertThat(jarFile.getEntry("BOOT-INF/classes/test/nested.jar").getMethod()).isEqualTo(ZipEntry.STORED); - } + Repackager repackager = createRepackager(this.testJarFile.getFile(), true); + this.destination.createNewFile(); + repackager.repackage(this.destination, NO_LIBRARIES); + assertThat(hasLauncherClasses(this.destination)).isTrue(); } @Test void addLauncherScript() throws Exception { this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "dest.jar"); - Repackager repackager = new Repackager(source); + Repackager repackager = createRepackager(source, true); LaunchScript script = new MockLauncherScript("ABC"); - repackager.repackage(dest, NO_LIBRARIES, script); - byte[] bytes = FileCopyUtils.copyToByteArray(dest); + repackager.repackage(this.destination, NO_LIBRARIES, script); + byte[] bytes = FileCopyUtils.copyToByteArray(this.destination); assertThat(new String(bytes)).startsWith("ABC"); assertThat(hasLauncherClasses(source)).isFalse(); - assertThat(hasLauncherClasses(dest)).isTrue(); + assertThat(hasLauncherClasses(this.destination)).isTrue(); try { - assertThat(Files.getPosixFilePermissions(dest.toPath())).contains(PosixFilePermission.OWNER_EXECUTE); + assertThat(Files.getPosixFilePermissions(this.destination.toPath())) + .contains(PosixFilePermission.OWNER_EXECUTE); } catch (UnsupportedOperationException ex) { // Probably running the test on Windows } } - @Test - void unpackLibrariesTakePrecedenceOverExistingSourceEntries() throws Exception { - TestJarFile nested = new TestJarFile(this.tempDir); - nested.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File nestedFile = nested.getFile(); - String name = "BOOT-INF/lib/" + nestedFile.getName(); - this.testJarFile.addFile(name, nested.getFile()); - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - repackager.repackage((callback) -> callback.library(new Library(nestedFile, LibraryScope.COMPILE, true))); - try (JarFile jarFile = new JarFile(file)) { - assertThat(jarFile.getEntry(name).getComment()).startsWith("UNPACK:"); - } + private boolean hasLauncherClasses(File file) throws IOException { + return hasEntry(file, "org/springframework/boot/") + && hasEntry(file, "org/springframework/boot/loader/JarLauncher.class"); } - @Test - void existingSourceEntriesTakePrecedenceOverStandardLibraries() throws Exception { - TestJarFile nested = new TestJarFile(this.tempDir); - nested.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File nestedFile = nested.getFile(); - this.testJarFile.addFile("BOOT-INF/lib/" + nestedFile.getName(), nested.getFile()); - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - long sourceLength = nestedFile.length(); - repackager.repackage((callback) -> { - nestedFile.delete(); - File toZip = new File(this.tempDir, "to-zip"); - toZip.createNewFile(); - ZipUtil.packEntry(toZip, nestedFile); - callback.library(new Library(nestedFile, LibraryScope.COMPILE)); - }); - try (JarFile jarFile = new JarFile(file)) { - assertThat(jarFile.getEntry("BOOT-INF/lib/" + nestedFile.getName()).getSize()).isEqualTo(sourceLength); - } + private boolean hasEntry(File file, String name) throws IOException { + return getEntry(file, name) != null; } - @Test - void metaInfIndexListIsRemovedFromRepackagedJar() throws Exception { - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - File indexList = new File(this.tempDir, "INDEX.LIST"); - indexList.createNewFile(); - this.testJarFile.addFile("META-INF/INDEX.LIST", indexList); - File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "dest.jar"); - Repackager repackager = new Repackager(source); - repackager.repackage(dest, NO_LIBRARIES); - try (JarFile jarFile = new JarFile(dest)) { - assertThat(jarFile.getEntry("META-INF/INDEX.LIST")).isNull(); + private JarEntry getEntry(File file, String name) throws IOException { + try (JarFile jarFile = new JarFile(file)) { + return jarFile.getJarEntry(name); } } - @Test - void customLayoutFactoryWithoutLayout() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File source = this.testJarFile.getFile(); - Repackager repackager = new Repackager(source, new TestLayoutFactory()); - repackager.repackage(NO_LIBRARIES); - JarFile jarFile = new JarFile(source); - assertThat(jarFile.getEntry("test")).isNotNull(); - jarFile.close(); + @Override + protected Repackager createPackager(File source) { + return createRepackager(source, true); } - @Test - void customLayoutFactoryWithLayout() throws Exception { - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File source = this.testJarFile.getFile(); - Repackager repackager = new Repackager(source, new TestLayoutFactory()); - repackager.setLayout(new Layouts.Jar()); - repackager.repackage(NO_LIBRARIES); - JarFile jarFile = new JarFile(source); - assertThat(jarFile.getEntry("test")).isNull(); - jarFile.close(); + private Repackager createRepackager(File source, boolean differentDest) { + String ext = StringUtils.getFilenameExtension(source.getName()); + this.destination = differentDest ? new File(this.tempDir, "dest." + ext) : source; + return new Repackager(source); } - @Test - void metaInfAopXmlIsMovedBeneathBootInfClassesWhenRepackaged() throws Exception { - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - File aopXml = new File(this.tempDir, "aop.xml"); - aopXml.createNewFile(); - this.testJarFile.addFile("META-INF/aop.xml", aopXml); - File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "dest.jar"); - Repackager repackager = new Repackager(source); - repackager.repackage(dest, NO_LIBRARIES); - try (JarFile jarFile = new JarFile(dest)) { - assertThat(jarFile.getEntry("META-INF/aop.xml")).isNull(); - assertThat(jarFile.getEntry("BOOT-INF/classes/META-INF/aop.xml")).isNotNull(); - } + @Override + protected void execute(Repackager packager, Libraries libraries) throws IOException { + packager.repackage(this.destination, libraries); } - @Test - void allEntriesUseUnixPlatformAndUtf8NameEncoding() throws IOException { - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "dest.jar"); - Repackager repackager = new Repackager(source); - repackager.repackage(dest, NO_LIBRARIES); - try (ZipFile zip = new ZipFile(dest)) { + @Override + protected Collection getAllPackagedEntries() throws IOException { + List result = new ArrayList<>(); + try (ZipFile zip = new ZipFile(this.destination)) { Enumeration entries = zip.getEntries(); while (entries.hasMoreElements()) { - ZipArchiveEntry entry = entries.nextElement(); - assertThat(entry.getPlatform()).isEqualTo(ZipArchiveEntry.PLATFORM_UNIX); - assertThat(entry.getGeneralPurposeBit().usesUTF8ForNames()).isTrue(); + result.add(entries.nextElement()); } } + return result; } - @Test - void loaderIsWrittenFirstThenApplicationClassesThenLibraries() throws IOException { - this.testJarFile.addClass("com/example/Application.class", ClassWithMainMethod.class); - File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "dest.jar"); - File libraryOne = createLibrary(); - File libraryTwo = createLibrary(); - File libraryThree = createLibrary(); - Repackager repackager = new Repackager(source); - repackager.repackage(dest, (callback) -> { - callback.library(new Library(libraryOne, LibraryScope.COMPILE, false)); - callback.library(new Library(libraryTwo, LibraryScope.COMPILE, true)); - callback.library(new Library(libraryThree, LibraryScope.COMPILE, false)); - }); - assertThat(getEntryNames(dest)).containsSubsequence("org/springframework/boot/loader/", - "BOOT-INF/classes/com/example/Application.class", "BOOT-INF/lib/" + libraryOne.getName(), - "BOOT-INF/lib/" + libraryTwo.getName(), "BOOT-INF/lib/" + libraryThree.getName()); - } - - @Test - void existingEntryThatMatchesUnpackLibraryIsMarkedForUnpack() throws IOException { - File library = createLibrary(); - this.testJarFile.addClass("WEB-INF/classes/com/example/Application.class", ClassWithMainMethod.class); - this.testJarFile.addFile("WEB-INF/lib/" + library.getName(), library); - File source = this.testJarFile.getFile("war"); - File dest = new File(this.tempDir, "dest.war"); - Repackager repackager = new Repackager(source); - repackager.setLayout(new Layouts.War()); - repackager.repackage(dest, (callback) -> callback.library(new Library(library, LibraryScope.COMPILE, true))); - assertThat(getEntryNames(dest)).containsSubsequence("org/springframework/boot/loader/", - "WEB-INF/classes/com/example/Application.class", "WEB-INF/lib/" + library.getName()); - JarEntry unpackLibrary = getEntry(dest, "WEB-INF/lib/" + library.getName()); - assertThat(unpackLibrary.getComment()).startsWith("UNPACK:"); - } - - @Test - void layoutCanOmitLibraries() throws IOException { - TestJarFile libJar = new TestJarFile(this.tempDir); - libJar.addClass("a/b/C.class", ClassWithoutMainMethod.class); - File libJarFile = libJar.getFile(); - this.testJarFile.addClass("a/b/C.class", ClassWithMainMethod.class); - File file = this.testJarFile.getFile(); - Repackager repackager = new Repackager(file); - Layout layout = mock(Layout.class); - LibraryScope scope = mock(LibraryScope.class); - repackager.setLayout(layout); - repackager.repackage((callback) -> callback.library(new Library(libJarFile, scope))); - assertThat(getEntryNames(file)).containsExactly("META-INF/", "META-INF/MANIFEST.MF", "a/", "a/b/", - "a/b/C.class"); - } - - @Test - void jarThatUsesCustomCompressionConfigurationCanBeRepackaged() throws IOException { - File source = new File(this.tempDir, "source.jar"); - ZipOutputStream output = new ZipOutputStream(new FileOutputStream(source)) { - { - this.def = new Deflater(Deflater.NO_COMPRESSION, true); - } - }; - byte[] data = new byte[1024 * 1024]; - new Random().nextBytes(data); - ZipEntry entry = new ZipEntry("entry.dat"); - output.putNextEntry(entry); - output.write(data); - output.closeEntry(); - output.close(); - File dest = new File(this.tempDir, "dest.jar"); - Repackager repackager = new Repackager(source); - repackager.setMainClass("com.example.Main"); - repackager.repackage(dest, NO_LIBRARIES); - } - - @Test - void moduleInfoClassRemainsInRootOfJarWhenRepackaged() throws Exception { - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - this.testJarFile.addClass("module-info.class", ClassWithoutMainMethod.class); - File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "dest.jar"); - Repackager repackager = new Repackager(source); - repackager.repackage(dest, NO_LIBRARIES); - try (JarFile jarFile = new JarFile(dest)) { - assertThat(jarFile.getEntry("module-info.class")).isNotNull(); - assertThat(jarFile.getEntry("BOOT-INF/classes/module-info.class")).isNull(); - } - } - - @Test - void kotlinModuleMetadataMovesBeneathBootInfClassesWhenRepackaged() throws Exception { - this.testJarFile.addClass("A.class", ClassWithMainMethod.class); - File kotlinModule = new File(this.tempDir, "test.kotlin_module"); - kotlinModule.createNewFile(); - this.testJarFile.addFile("META-INF/test.kotlin_module", kotlinModule); - File source = this.testJarFile.getFile(); - File dest = new File(this.tempDir, "dest.jar"); - Repackager repackager = new Repackager(source); - repackager.repackage(dest, NO_LIBRARIES); - try (JarFile jarFile = new JarFile(dest)) { - assertThat(jarFile.getEntry("META-INF/test.kotlin_module")).isNull(); - assertThat(jarFile.getEntry("BOOT-INF/classes/META-INF/test.kotlin_module")).isNotNull(); - } - } - - private File createLibrary() throws IOException { - TestJarFile library = new TestJarFile(this.tempDir); - library.addClass("com/example/library/Library.class", ClassWithoutMainMethod.class); - return library.getFile(); - } - - private boolean hasLauncherClasses(File file) throws IOException { - return hasEntry(file, "org/springframework/boot/") - && hasEntry(file, "org/springframework/boot/loader/JarLauncher.class"); - } - - private boolean hasEntry(File file, String name) throws IOException { - return getEntry(file, name) != null; - } - - private JarEntry getEntry(File file, String name) throws IOException { - try (JarFile jarFile = new JarFile(file)) { - return jarFile.getJarEntry(name); - } - } - - private Manifest getManifest(File file) throws IOException { - try (JarFile jarFile = new JarFile(file)) { + @Override + protected Manifest getPackagedManifest() throws IOException { + try (JarFile jarFile = new JarFile(this.destination)) { return jarFile.getManifest(); } } - private List getEntryNames(File file) throws IOException { - List entryNames = new ArrayList<>(); - try (JarFile jarFile = new JarFile(file)) { - Enumeration entries = jarFile.entries(); - while (entries.hasMoreElements()) { - entryNames.add(entries.nextElement().getName()); + @Override + protected String getPackagedEntryContent(String name) throws IOException { + try (ZipFile zip = new ZipFile(this.destination)) { + ZipArchiveEntry entry = zip.getEntry(name); + if (entry == null) { + return null; } + byte[] bytes = FileCopyUtils.copyToByteArray(zip.getInputStream(entry)); + return new String(bytes, StandardCharsets.UTF_8); } - return entryNames; } static class MockLauncherScript implements LaunchScript { @@ -774,58 +233,4 @@ class RepackagerTests { } - static class TestLayoutFactory implements LayoutFactory { - - @Override - public Layout getLayout(File source) { - return new TestLayout(); - } - - } - - static class TestLayout extends Layouts.Jar implements CustomLoaderLayout { - - @Override - public void writeLoadedClasses(LoaderClassesWriter writer) throws IOException { - writer.writeEntry("test", new ByteArrayInputStream("test".getBytes())); - } - - } - - static class TestLayers implements Layers { - - private static final Layer DEFAULT_LAYER = new Layer("default"); - - private Set layers = new LinkedHashSet(); - - private Map libraries = new HashMap<>(); - - TestLayers() { - this.layers.add(DEFAULT_LAYER); - } - - void addLibrary(File jarFile, String layerName) { - Layer layer = new Layer(layerName); - this.layers.add(layer); - this.libraries.put(jarFile.getName(), layer); - } - - @Override - public Iterator iterator() { - return this.layers.iterator(); - } - - @Override - public Layer getLayer(String name) { - return DEFAULT_LAYER; - } - - @Override - public Layer getLayer(Library library) { - String name = new File(library.getName()).getName(); - return this.libraries.getOrDefault(name, DEFAULT_LAYER); - } - - } - } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriterTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriterTests.java new file mode 100644 index 0000000000..5207a3dbd2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/SizeCalculatingEntryWriterTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2020 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.loader.tools; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Random; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SizeCalculatingEntryWriter}. + * + * @author Phillip Webb + */ +class SizeCalculatingEntryWriterTests { + + @Test + void getWhenWithinThreshold() throws Exception { + TestEntryWriter original = new TestEntryWriter(SizeCalculatingEntryWriter.THRESHOLD - 1); + EntryWriter writer = SizeCalculatingEntryWriter.get(original); + assertThat(writer.size()).isEqualTo(original.getBytes().length); + assertThat(writeBytes(writer)).isEqualTo(original.getBytes()); + assertThat(writer).extracting("content").isNotInstanceOf(File.class); + } + + @Test + void getWhenExceedingThreshold() throws Exception { + TestEntryWriter original = new TestEntryWriter(SizeCalculatingEntryWriter.THRESHOLD + 1); + EntryWriter writer = SizeCalculatingEntryWriter.get(original); + assertThat(writer.size()).isEqualTo(original.getBytes().length); + assertThat(writeBytes(writer)).isEqualTo(original.getBytes()); + assertThat(writer).extracting("content").isInstanceOf(File.class); + } + + private byte[] writeBytes(EntryWriter writer) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + writer.write(outputStream); + outputStream.close(); + return outputStream.toByteArray(); + } + + private static class TestEntryWriter implements EntryWriter { + + private byte[] bytes; + + TestEntryWriter(int size) { + this.bytes = new byte[size]; + new Random().nextBytes(this.bytes); + } + + byte[] getBytes() { + return this.bytes; + } + + @Override + public void write(OutputStream outputStream) throws IOException { + outputStream.write(this.bytes); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStreamTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStreamTests.java index 042fe76311..514c1dc796 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStreamTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/ZipHeaderPeekInputStreamTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2020 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. @@ -21,8 +21,6 @@ import java.io.IOException; import org.junit.jupiter.api.Test; -import org.springframework.boot.loader.tools.JarWriter.ZipHeaderPeekInputStream; - import static org.assertj.core.api.Assertions.assertThat; /**