001/*
002 * Copyright 2012-2017 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.boot.loader.tools;
018
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.util.ArrayList;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Set;
027import java.util.concurrent.TimeUnit;
028import java.util.jar.JarEntry;
029import java.util.jar.JarFile;
030import java.util.jar.Manifest;
031
032import org.springframework.boot.loader.tools.JarWriter.EntryTransformer;
033import org.springframework.core.io.support.SpringFactoriesLoader;
034import org.springframework.lang.UsesJava8;
035import org.springframework.util.Assert;
036import org.springframework.util.StringUtils;
037
038/**
039 * Utility class that can be used to repackage an archive so that it can be executed using
040 * '{@literal java -jar}'.
041 *
042 * @author Phillip Webb
043 * @author Andy Wilkinson
044 * @author Stephane Nicoll
045 */
046public class Repackager {
047
048        private static final String MAIN_CLASS_ATTRIBUTE = "Main-Class";
049
050        private static final String START_CLASS_ATTRIBUTE = "Start-Class";
051
052        private static final String BOOT_VERSION_ATTRIBUTE = "Spring-Boot-Version";
053
054        private static final String BOOT_LIB_ATTRIBUTE = "Spring-Boot-Lib";
055
056        private static final String BOOT_CLASSES_ATTRIBUTE = "Spring-Boot-Classes";
057
058        private static final byte[] ZIP_FILE_HEADER = new byte[] { 'P', 'K', 3, 4 };
059
060        private static final long FIND_WARNING_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
061
062        private static final String SPRING_BOOT_APPLICATION_CLASS_NAME = "org.springframework.boot.autoconfigure.SpringBootApplication";
063
064        private List<MainClassTimeoutWarningListener> mainClassTimeoutListeners = new ArrayList<MainClassTimeoutWarningListener>();
065
066        private String mainClass;
067
068        private boolean backupSource = true;
069
070        private final File source;
071
072        private Layout layout;
073
074        private LayoutFactory layoutFactory;
075
076        public Repackager(File source) {
077                this(source, null);
078        }
079
080        public Repackager(File source, LayoutFactory layoutFactory) {
081                if (source == null) {
082                        throw new IllegalArgumentException("Source file must be provided");
083                }
084                if (!source.exists() || !source.isFile()) {
085                        throw new IllegalArgumentException("Source must refer to an existing file, "
086                                        + "got " + source.getAbsolutePath());
087                }
088                this.source = source.getAbsoluteFile();
089                this.layoutFactory = layoutFactory;
090        }
091
092        /**
093         * Add a listener that will be triggered to display a warning if searching for the
094         * main class takes too long.
095         * @param listener the listener to add
096         */
097        public void addMainClassTimeoutWarningListener(
098                        MainClassTimeoutWarningListener listener) {
099                this.mainClassTimeoutListeners.add(listener);
100        }
101
102        /**
103         * Sets the main class that should be run. If not specified the value from the
104         * MANIFEST will be used, or if no manifest entry is found the archive will be
105         * searched for a suitable class.
106         * @param mainClass the main class name
107         */
108        public void setMainClass(String mainClass) {
109                this.mainClass = mainClass;
110        }
111
112        /**
113         * Sets if source files should be backed up when they would be overwritten.
114         * @param backupSource if source files should be backed up
115         */
116        public void setBackupSource(boolean backupSource) {
117                this.backupSource = backupSource;
118        }
119
120        /**
121         * Sets the layout to use for the jar. Defaults to {@link Layouts#forFile(File)}.
122         * @param layout the layout
123         */
124        public void setLayout(Layout layout) {
125                if (layout == null) {
126                        throw new IllegalArgumentException("Layout must not be null");
127                }
128                this.layout = layout;
129        }
130
131        /**
132         * Sets the layout factory for the jar. The factory can be used when no specific
133         * layout is specified.
134         * @param layoutFactory the layout factory to set
135         */
136        public void setLayoutFactory(LayoutFactory layoutFactory) {
137                this.layoutFactory = layoutFactory;
138        }
139
140        /**
141         * Repackage the source file so that it can be run using '{@literal java -jar}'.
142         * @param libraries the libraries required to run the archive
143         * @throws IOException if the file cannot be repackaged
144         */
145        public void repackage(Libraries libraries) throws IOException {
146                repackage(this.source, libraries);
147        }
148
149        /**
150         * Repackage to the given destination so that it can be launched using '
151         * {@literal java -jar}'.
152         * @param destination the destination file (may be the same as the source)
153         * @param libraries the libraries required to run the archive
154         * @throws IOException if the file cannot be repackaged
155         */
156        public void repackage(File destination, Libraries libraries) throws IOException {
157                repackage(destination, libraries, null);
158        }
159
160        /**
161         * Repackage to the given destination so that it can be launched using '
162         * {@literal java -jar}'.
163         * @param destination the destination file (may be the same as the source)
164         * @param libraries the libraries required to run the archive
165         * @param launchScript an optional launch script prepended to the front of the jar
166         * @throws IOException if the file cannot be repackaged
167         * @since 1.3.0
168         */
169        public void repackage(File destination, Libraries libraries,
170                        LaunchScript launchScript) throws IOException {
171                if (destination == null || destination.isDirectory()) {
172                        throw new IllegalArgumentException("Invalid destination");
173                }
174                if (libraries == null) {
175                        throw new IllegalArgumentException("Libraries must not be null");
176                }
177                if (this.layout == null) {
178                        this.layout = getLayoutFactory().getLayout(this.source);
179                }
180                if (alreadyRepackaged()) {
181                        return;
182                }
183                destination = destination.getAbsoluteFile();
184                File workingSource = this.source;
185                if (this.source.equals(destination)) {
186                        workingSource = getBackupFile();
187                        workingSource.delete();
188                        renameFile(this.source, workingSource);
189                }
190                destination.delete();
191                try {
192                        JarFile jarFileSource = new JarFile(workingSource);
193                        try {
194                                repackage(jarFileSource, destination, libraries, launchScript);
195                        }
196                        finally {
197                                jarFileSource.close();
198                        }
199                }
200                finally {
201                        if (!this.backupSource && !this.source.equals(workingSource)) {
202                                deleteFile(workingSource);
203                        }
204                }
205        }
206
207        private LayoutFactory getLayoutFactory() {
208                if (this.layoutFactory != null) {
209                        return this.layoutFactory;
210                }
211                List<LayoutFactory> factories = SpringFactoriesLoader
212                                .loadFactories(LayoutFactory.class, null);
213                if (factories.isEmpty()) {
214                        return new DefaultLayoutFactory();
215                }
216                Assert.state(factories.size() == 1, "No unique LayoutFactory found");
217                return factories.get(0);
218        }
219
220        /**
221         * Return the {@link File} to use to backup the original source.
222         * @return the file to use to backup the original source
223         */
224        public final File getBackupFile() {
225                return new File(this.source.getParentFile(), this.source.getName() + ".original");
226        }
227
228        private boolean alreadyRepackaged() throws IOException {
229                JarFile jarFile = new JarFile(this.source);
230                try {
231                        Manifest manifest = jarFile.getManifest();
232                        return (manifest != null && manifest.getMainAttributes()
233                                        .getValue(BOOT_VERSION_ATTRIBUTE) != null);
234                }
235                finally {
236                        jarFile.close();
237                }
238        }
239
240        private void repackage(JarFile sourceJar, File destination, Libraries libraries,
241                        LaunchScript launchScript) throws IOException {
242                JarWriter writer = new JarWriter(destination, launchScript);
243                try {
244                        final List<Library> unpackLibraries = new ArrayList<Library>();
245                        final List<Library> standardLibraries = new ArrayList<Library>();
246                        libraries.doWithLibraries(new LibraryCallback() {
247
248                                @Override
249                                public void library(Library library) throws IOException {
250                                        File file = library.getFile();
251                                        if (isZip(file)) {
252                                                if (library.isUnpackRequired()) {
253                                                        unpackLibraries.add(library);
254                                                }
255                                                else {
256                                                        standardLibraries.add(library);
257                                                }
258                                        }
259                                }
260
261                        });
262                        repackage(sourceJar, writer, unpackLibraries, standardLibraries);
263                }
264                finally {
265                        try {
266                                writer.close();
267                        }
268                        catch (Exception ex) {
269                                // Ignore
270                        }
271                }
272        }
273
274        private void repackage(JarFile sourceJar, JarWriter writer,
275                        final List<Library> unpackLibraries, final List<Library> standardLibraries)
276                                        throws IOException {
277                writer.writeManifest(buildManifest(sourceJar));
278                Set<String> seen = new HashSet<String>();
279                writeNestedLibraries(unpackLibraries, seen, writer);
280                if (this.layout instanceof RepackagingLayout) {
281                        writer.writeEntries(sourceJar, new RenamingEntryTransformer(
282                                        ((RepackagingLayout) this.layout).getRepackagedClassesLocation()));
283                }
284                else {
285                        writer.writeEntries(sourceJar);
286                }
287                writeNestedLibraries(standardLibraries, seen, writer);
288                writeLoaderClasses(writer);
289        }
290
291        private void writeNestedLibraries(List<Library> libraries, Set<String> alreadySeen,
292                        JarWriter writer) throws IOException {
293                for (Library library : libraries) {
294                        String destination = Repackager.this.layout
295                                        .getLibraryDestination(library.getName(), library.getScope());
296                        if (destination != null) {
297                                if (!alreadySeen.add(destination + library.getName())) {
298                                        throw new IllegalStateException(
299                                                        "Duplicate library " + library.getName());
300                                }
301                                writer.writeNestedLibrary(destination, library);
302                        }
303                }
304        }
305
306        private void writeLoaderClasses(JarWriter writer) throws IOException {
307                if (this.layout instanceof CustomLoaderLayout) {
308                        ((CustomLoaderLayout) this.layout).writeLoadedClasses(writer);
309                }
310                else if (this.layout.isExecutable()) {
311                        writer.writeLoaderClasses();
312                }
313        }
314
315        private boolean isZip(File file) {
316                try {
317                        FileInputStream fileInputStream = new FileInputStream(file);
318                        try {
319                                return isZip(fileInputStream);
320                        }
321                        finally {
322                                fileInputStream.close();
323                        }
324                }
325                catch (IOException ex) {
326                        return false;
327                }
328        }
329
330        private boolean isZip(InputStream inputStream) throws IOException {
331                for (int i = 0; i < ZIP_FILE_HEADER.length; i++) {
332                        if (inputStream.read() != ZIP_FILE_HEADER[i]) {
333                                return false;
334                        }
335                }
336                return true;
337        }
338
339        private Manifest buildManifest(JarFile source) throws IOException {
340                Manifest manifest = source.getManifest();
341                if (manifest == null) {
342                        manifest = new Manifest();
343                        manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
344                }
345                manifest = new Manifest(manifest);
346                String startClass = this.mainClass;
347                if (startClass == null) {
348                        startClass = manifest.getMainAttributes().getValue(MAIN_CLASS_ATTRIBUTE);
349                }
350                if (startClass == null) {
351                        startClass = findMainMethodWithTimeoutWarning(source);
352                }
353                String launcherClassName = this.layout.getLauncherClassName();
354                if (launcherClassName != null) {
355                        manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE,
356                                        launcherClassName);
357                        if (startClass == null) {
358                                throw new IllegalStateException("Unable to find main class");
359                        }
360                        manifest.getMainAttributes().putValue(START_CLASS_ATTRIBUTE, startClass);
361                }
362                else if (startClass != null) {
363                        manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, startClass);
364                }
365                String bootVersion = getClass().getPackage().getImplementationVersion();
366                manifest.getMainAttributes().putValue(BOOT_VERSION_ATTRIBUTE, bootVersion);
367                manifest.getMainAttributes().putValue(BOOT_CLASSES_ATTRIBUTE,
368                                (this.layout instanceof RepackagingLayout)
369                                                ? ((RepackagingLayout) this.layout).getRepackagedClassesLocation()
370                                                : this.layout.getClassesLocation());
371                String lib = this.layout.getLibraryDestination("", LibraryScope.COMPILE);
372                if (StringUtils.hasLength(lib)) {
373                        manifest.getMainAttributes().putValue(BOOT_LIB_ATTRIBUTE, lib);
374                }
375                return manifest;
376        }
377
378        private String findMainMethodWithTimeoutWarning(JarFile source) throws IOException {
379                long startTime = System.currentTimeMillis();
380                String mainMethod = findMainMethod(source);
381                long duration = System.currentTimeMillis() - startTime;
382                if (duration > FIND_WARNING_TIMEOUT) {
383                        for (MainClassTimeoutWarningListener listener : this.mainClassTimeoutListeners) {
384                                listener.handleTimeoutWarning(duration, mainMethod);
385                        }
386                }
387                return mainMethod;
388        }
389
390        protected String findMainMethod(JarFile source) throws IOException {
391                return MainClassFinder.findSingleMainClass(source,
392                                this.layout.getClassesLocation(), SPRING_BOOT_APPLICATION_CLASS_NAME);
393        }
394
395        private void renameFile(File file, File dest) {
396                if (!file.renameTo(dest)) {
397                        throw new IllegalStateException(
398                                        "Unable to rename '" + file + "' to '" + dest + "'");
399                }
400        }
401
402        private void deleteFile(File file) {
403                if (!file.delete()) {
404                        throw new IllegalStateException("Unable to delete '" + file + "'");
405                }
406        }
407
408        /**
409         * Callback interface used to present a warning when finding the main class takes too
410         * long.
411         */
412        public interface MainClassTimeoutWarningListener {
413
414                /**
415                 * Handle a timeout warning.
416                 * @param duration the amount of time it took to find the main method
417                 * @param mainMethod the main method that was actually found
418                 */
419                void handleTimeoutWarning(long duration, String mainMethod);
420
421        }
422
423        /**
424         * An {@code EntryTransformer} that renames entries by applying a prefix.
425         */
426        private static final class RenamingEntryTransformer implements EntryTransformer {
427
428                private final String namePrefix;
429
430                private RenamingEntryTransformer(String namePrefix) {
431                        this.namePrefix = namePrefix;
432                }
433
434                @Override
435                public JarEntry transform(JarEntry entry) {
436                        if (entry.getName().equals("META-INF/INDEX.LIST")) {
437                                return null;
438                        }
439                        if ((entry.getName().startsWith("META-INF/")
440                                        && !entry.getName().equals("META-INF/aop.xml"))
441                                        || entry.getName().startsWith("BOOT-INF/")) {
442                                return entry;
443                        }
444                        JarEntry renamedEntry = new JarEntry(this.namePrefix + entry.getName());
445                        renamedEntry.setTime(entry.getTime());
446                        renamedEntry.setSize(entry.getSize());
447                        renamedEntry.setMethod(entry.getMethod());
448                        if (entry.getComment() != null) {
449                                renamedEntry.setComment(entry.getComment());
450                        }
451                        renamedEntry.setCompressedSize(entry.getCompressedSize());
452                        renamedEntry.setCrc(entry.getCrc());
453                        setCreationTimeIfPossible(entry, renamedEntry);
454                        if (entry.getExtra() != null) {
455                                renamedEntry.setExtra(entry.getExtra());
456                        }
457                        setLastAccessTimeIfPossible(entry, renamedEntry);
458                        setLastModifiedTimeIfPossible(entry, renamedEntry);
459                        return renamedEntry;
460                }
461
462                @UsesJava8
463                private void setCreationTimeIfPossible(JarEntry source, JarEntry target) {
464                        try {
465                                if (source.getCreationTime() != null) {
466                                        target.setCreationTime(source.getCreationTime());
467                                }
468                        }
469                        catch (NoSuchMethodError ex) {
470                                // Not running on Java 8. Continue.
471                        }
472                }
473
474                @UsesJava8
475                private void setLastAccessTimeIfPossible(JarEntry source, JarEntry target) {
476                        try {
477                                if (source.getLastAccessTime() != null) {
478                                        target.setLastAccessTime(source.getLastAccessTime());
479                                }
480                        }
481                        catch (NoSuchMethodError ex) {
482                                // Not running on Java 8. Continue.
483                        }
484                }
485
486                @UsesJava8
487                private void setLastModifiedTimeIfPossible(JarEntry source, JarEntry target) {
488                        try {
489                                if (source.getLastModifiedTime() != null) {
490                                        target.setLastModifiedTime(source.getLastModifiedTime());
491                                }
492                        }
493                        catch (NoSuchMethodError ex) {
494                                // Not running on Java 8. Continue.
495                        }
496                }
497
498        }
499
500}