/*
* Copyright 2012-2017 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
*
* http://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.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import org.springframework.boot.loader.tools.JarWriter.EntryTransformer;
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
* '{@literal java -jar}'.
*
* @author Phillip Webb
* @author Andy Wilkinson
* @author Stephane Nicoll
*/
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_LIB_ATTRIBUTE = "Spring-Boot-Lib";
private static final String BOOT_CLASSES_ATTRIBUTE = "Spring-Boot-Classes";
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<MainClassTimeoutWarningListener> mainClassTimeoutListeners = new ArrayList<>();
private String mainClass;
private boolean backupSource = true;
private final File source;
private Layout layout;
private LayoutFactory layoutFactory;
public Repackager(File source) {
this(source, null);
}
public Repackager(File source, LayoutFactory layoutFactory) {
if (source == null || !source.exists() || !source.isFile()) {
throw new IllegalArgumentException("Source must refer to an existing file");
}
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 if source files should be backed up when they would be overwritten.
* @param backupSource if source files should be backed up
*/
public void setBackupSource(boolean backupSource) {
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) {
if (layout == null) {
throw new IllegalArgumentException("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;
}
/**
* 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 to the given destination so that it can be launched using '
* {@literal java -jar}'.
* @param destination the destination file (may be the same as the source)
* @param libraries the libraries required to run the archive
* @throws IOException if the file cannot be repackaged
*/
public void repackage(File destination, Libraries libraries) throws IOException {
repackage(destination, libraries, null);
}
/**
* Repackage to the given destination so that it can be launched using '
* {@literal java -jar}'.
* @param destination the destination file (may be the same as the source)
* @param libraries the libraries required to run the archive
* @param launchScript an optional launch script prepended to the front of the jar
* @throws IOException if the file cannot be repackaged
* @since 1.3.0
*/
public void repackage(File destination, Libraries libraries,
LaunchScript launchScript) throws IOException {
if (destination == null || destination.isDirectory()) {
throw new IllegalArgumentException("Invalid destination");
}
if (libraries == null) {
throw new IllegalArgumentException("Libraries must not be null");
}
if (this.layout == null) {
this.layout = getLayoutFactory().getLayout(this.source);
}
if (alreadyRepackaged()) {
return;
}
destination = destination.getAbsoluteFile();
File workingSource = this.source;
if (this.source.equals(destination)) {
workingSource = getBackupFile();
workingSource.delete();
renameFile(this.source, workingSource);
}
destination.delete();
try {
JarFile jarFileSource = new JarFile(workingSource);
try {
repackage(jarFileSource, destination, libraries, launchScript);
}
finally {
jarFileSource.close();
}
}
finally {
if (!this.backupSource && !this.source.equals(workingSource)) {
deleteFile(workingSource);
}
}
}
private LayoutFactory getLayoutFactory() {
if (this.layoutFactory != null) {
return this.layoutFactory;
}
List<LayoutFactory> 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 {
JarFile jarFile = new JarFile(this.source);
try {
Manifest manifest = jarFile.getManifest();
return (manifest != null && manifest.getMainAttributes()
.getValue(BOOT_VERSION_ATTRIBUTE) != null);
}
finally {
jarFile.close();
}
}
private void repackage(JarFile sourceJar, File destination, Libraries libraries,
LaunchScript launchScript) throws IOException {
JarWriter writer = new JarWriter(destination, launchScript);
try {
final List<Library> unpackLibraries = new ArrayList<>();
final List<Library> standardLibraries = new ArrayList<>();
libraries.doWithLibraries(new LibraryCallback() {
@Override
public void library(Library library) throws IOException {
File file = library.getFile();
if (isZip(file)) {
if (library.isUnpackRequired()) {
unpackLibraries.add(library);
}
else {
standardLibraries.add(library);
}
}
}
});
repackage(sourceJar, writer, unpackLibraries, standardLibraries);
}
finally {
try {
writer.close();
}
catch (Exception ex) {
// Ignore
}
}
}
private void repackage(JarFile sourceJar, JarWriter writer,
final List<Library> unpackLibraries, final List<Library> standardLibraries)
throws IOException {
writer.writeManifest(buildManifest(sourceJar));
Set<String> seen = new HashSet<>();
writeNestedLibraries(unpackLibraries, seen, writer);
if (this.layout instanceof RepackagingLayout) {
writer.writeEntries(sourceJar, new RenamingEntryTransformer(
((RepackagingLayout) this.layout).getRepackagedClassesLocation()));
}
else {
writer.writeEntries(sourceJar);
}
writeNestedLibraries(standardLibraries, seen, writer);
writeLoaderClasses(writer);
}
private void writeNestedLibraries(List<Library> libraries, Set<String> alreadySeen,
JarWriter writer) throws IOException {
for (Library library : libraries) {
String destination = Repackager.this.layout
.getLibraryDestination(library.getName(), library.getScope());
if (destination != null) {
if (!alreadySeen.add(destination + library.getName())) {
throw new IllegalStateException(
"Duplicate library " + library.getName());
}
writer.writeNestedLibrary(destination, library);
}
}
}
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 boolean isZip(File file) {
try {
FileInputStream fileInputStream = new FileInputStream(file);
try {
return isZip(fileInputStream);
}
finally {
fileInputStream.close();
}
}
catch (IOException ex) {
return false;
}
}
private boolean isZip(InputStream inputStream) throws IOException {
for (int i = 0; i < ZIP_FILE_HEADER.length; i++) {
if (inputStream.read() != ZIP_FILE_HEADER[i]) {
return false;
}
}
return true;
}
private Manifest buildManifest(JarFile source) throws IOException {
Manifest manifest = source.getManifest();
if (manifest == null) {
manifest = new Manifest();
manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
}
manifest = new Manifest(manifest);
String startClass = this.mainClass;
if (startClass == null) {
startClass = manifest.getMainAttributes().getValue(MAIN_CLASS_ATTRIBUTE);
}
if (startClass == null) {
startClass = findMainMethodWithTimeoutWarning(source);
}
String launcherClassName = this.layout.getLauncherClassName();
if (launcherClassName != null) {
manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE,
launcherClassName);
if (startClass == null) {
throw new IllegalStateException("Unable to find main class");
}
manifest.getMainAttributes().putValue(START_CLASS_ATTRIBUTE, startClass);
}
else if (startClass != null) {
manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, startClass);
}
String bootVersion = getClass().getPackage().getImplementationVersion();
manifest.getMainAttributes().putValue(BOOT_VERSION_ATTRIBUTE, bootVersion);
manifest.getMainAttributes().putValue(BOOT_CLASSES_ATTRIBUTE,
(this.layout instanceof RepackagingLayout)
? ((RepackagingLayout) this.layout).getRepackagedClassesLocation()
: this.layout.getClassesLocation());
String lib = this.layout.getLibraryDestination("", LibraryScope.COMPILE);
if (StringUtils.hasLength(lib)) {
manifest.getMainAttributes().putValue(BOOT_LIB_ATTRIBUTE, lib);
}
return manifest;
}
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 renameFile(File file, File dest) {
if (!file.renameTo(dest)) {
throw new IllegalStateException(
"Unable to rename '" + file + "' to '" + dest + "'");
}
}
private void deleteFile(File file) {
if (!file.delete()) {
throw new IllegalStateException("Unable to delete '" + file + "'");
}
}
/**
* 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 RenamingEntryTransformer implements EntryTransformer {
private final String namePrefix;
private RenamingEntryTransformer(String namePrefix) {
this.namePrefix = namePrefix;
}
@Override
public JarEntry transform(JarEntry entry) {
if (entry.getName().equals("META-INF/INDEX.LIST")) {
return null;
}
if ((entry.getName().startsWith("META-INF/")
&& !entry.getName().equals("META-INF/aop.xml"))
|| entry.getName().startsWith("BOOT-INF/")) {
return entry;
}
JarEntry renamedEntry = new JarEntry(this.namePrefix + entry.getName());
renamedEntry.setTime(entry.getTime());
renamedEntry.setSize(entry.getSize());
renamedEntry.setMethod(entry.getMethod());
if (entry.getComment() != null) {
renamedEntry.setComment(entry.getComment());
}
renamedEntry.setCompressedSize(entry.getCompressedSize());
renamedEntry.setCrc(entry.getCrc());
if (entry.getCreationTime() != null) {
renamedEntry.setCreationTime(entry.getCreationTime());
}
if (entry.getExtra() != null) {
renamedEntry.setExtra(entry.getExtra());
}
if (entry.getLastAccessTime() != null) {
renamedEntry.setLastAccessTime(entry.getLastAccessTime());
}
if (entry.getLastModifiedTime() != null) {
renamedEntry.setLastModifiedTime(entry.getLastModifiedTime());
}
return renamedEntry;
}
}
}