001/*
002 * Copyright 2012-2018 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;
018
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.UnsupportedEncodingException;
024import java.net.HttpURLConnection;
025import java.net.URL;
026import java.net.URLConnection;
027import java.net.URLDecoder;
028import java.util.ArrayList;
029import java.util.Collections;
030import java.util.LinkedHashSet;
031import java.util.List;
032import java.util.Locale;
033import java.util.Properties;
034import java.util.Set;
035import java.util.jar.Manifest;
036import java.util.regex.Matcher;
037import java.util.regex.Pattern;
038
039import org.springframework.boot.loader.archive.Archive;
040import org.springframework.boot.loader.archive.Archive.Entry;
041import org.springframework.boot.loader.archive.Archive.EntryFilter;
042import org.springframework.boot.loader.archive.ExplodedArchive;
043import org.springframework.boot.loader.archive.JarFileArchive;
044import org.springframework.boot.loader.util.SystemPropertyUtils;
045
046/**
047 * {@link Launcher} for archives with user-configured classpath and main class via a
048 * properties file. This model is often more flexible and more amenable to creating
049 * well-behaved OS-level services than a model based on executable jars.
050 * <p>
051 * Looks in various places for a properties file to extract loader settings, defaulting to
052 * {@code loader.properties} either on the current classpath or in the current working
053 * directory. The name of the properties file can be changed by setting a System property
054 * {@code loader.config.name} (e.g. {@code -Dloader.config.name=foo} will look for
055 * {@code foo.properties}. If that file doesn't exist then tries
056 * {@code loader.config.location} (with allowed prefixes {@code classpath:} and
057 * {@code file:} or any valid URL). Once that file is located turns it into Properties and
058 * extracts optional values (which can also be provided overridden as System properties in
059 * case the file doesn't exist):
060 * <ul>
061 * <li>{@code loader.path}: a comma-separated list of directories (containing file
062 * resources and/or nested archives in *.jar or *.zip or archives) or archives to append
063 * to the classpath. {@code BOOT-INF/classes,BOOT-INF/lib} in the application archive are
064 * always used</li>
065 * <li>{@code loader.main}: the main method to delegate execution to once the class loader
066 * is set up. No default, but will fall back to looking for a {@code Start-Class} in a
067 * {@code MANIFEST.MF}, if there is one in <code>${loader.home}/META-INF</code>.</li>
068 * </ul>
069 *
070 * @author Dave Syer
071 * @author Janne Valkealahti
072 * @author Andy Wilkinson
073 */
074public class PropertiesLauncher extends Launcher {
075
076        private static final String DEBUG = "loader.debug";
077
078        /**
079         * Properties key for main class. As a manifest entry can also be specified as
080         * {@code Start-Class}.
081         */
082        public static final String MAIN = "loader.main";
083
084        /**
085         * Properties key for classpath entries (directories possibly containing jars or
086         * jars). Multiple entries can be specified using a comma-separated list. {@code
087         * BOOT-INF/classes,BOOT-INF/lib} in the application archive are always used.
088         */
089        public static final String PATH = "loader.path";
090
091        /**
092         * Properties key for home directory. This is the location of external configuration
093         * if not on classpath, and also the base path for any relative paths in the
094         * {@link #PATH loader path}. Defaults to current working directory (
095         * <code>${user.dir}</code>).
096         */
097        public static final String HOME = "loader.home";
098
099        /**
100         * Properties key for default command line arguments. These arguments (if present) are
101         * prepended to the main method arguments before launching.
102         */
103        public static final String ARGS = "loader.args";
104
105        /**
106         * Properties key for name of external configuration file (excluding suffix). Defaults
107         * to "application". Ignored if {@link #CONFIG_LOCATION loader config location} is
108         * provided instead.
109         */
110        public static final String CONFIG_NAME = "loader.config.name";
111
112        /**
113         * Properties key for config file location (including optional classpath:, file: or
114         * URL prefix).
115         */
116        public static final String CONFIG_LOCATION = "loader.config.location";
117
118        /**
119         * Properties key for boolean flag (default false) which if set will cause the
120         * external configuration properties to be copied to System properties (assuming that
121         * is allowed by Java security).
122         */
123        public static final String SET_SYSTEM_PROPERTIES = "loader.system";
124
125        private static final Pattern WORD_SEPARATOR = Pattern.compile("\\W+");
126
127        private static final String NESTED_ARCHIVE_SEPARATOR = "!" + File.separator;
128
129        private final File home;
130
131        private List<String> paths = new ArrayList<>();
132
133        private final Properties properties = new Properties();
134
135        private Archive parent;
136
137        public PropertiesLauncher() {
138                try {
139                        this.home = getHomeDirectory();
140                        initializeProperties();
141                        initializePaths();
142                        this.parent = createArchive();
143                }
144                catch (Exception ex) {
145                        throw new IllegalStateException(ex);
146                }
147        }
148
149        protected File getHomeDirectory() {
150                try {
151                        return new File(getPropertyWithDefault(HOME, "${user.dir}"));
152                }
153                catch (Exception ex) {
154                        throw new IllegalStateException(ex);
155                }
156        }
157
158        private void initializeProperties() throws Exception, IOException {
159                List<String> configs = new ArrayList<>();
160                if (getProperty(CONFIG_LOCATION) != null) {
161                        configs.add(getProperty(CONFIG_LOCATION));
162                }
163                else {
164                        String[] names = getPropertyWithDefault(CONFIG_NAME, "loader").split(",");
165                        for (String name : names) {
166                                configs.add("file:" + getHomeDirectory() + "/" + name + ".properties");
167                                configs.add("classpath:" + name + ".properties");
168                                configs.add("classpath:BOOT-INF/classes/" + name + ".properties");
169                        }
170                }
171                for (String config : configs) {
172                        try (InputStream resource = getResource(config)) {
173                                if (resource != null) {
174                                        debug("Found: " + config);
175                                        loadResource(resource);
176                                        // Load the first one we find
177                                        return;
178                                }
179                                else {
180                                        debug("Not found: " + config);
181                                }
182                        }
183                }
184        }
185
186        private void loadResource(InputStream resource) throws IOException, Exception {
187                this.properties.load(resource);
188                for (Object key : Collections.list(this.properties.propertyNames())) {
189                        String text = this.properties.getProperty((String) key);
190                        String value = SystemPropertyUtils.resolvePlaceholders(this.properties, text);
191                        if (value != null) {
192                                this.properties.put(key, value);
193                        }
194                }
195                if ("true".equals(getProperty(SET_SYSTEM_PROPERTIES))) {
196                        debug("Adding resolved properties to System properties");
197                        for (Object key : Collections.list(this.properties.propertyNames())) {
198                                String value = this.properties.getProperty((String) key);
199                                System.setProperty((String) key, value);
200                        }
201                }
202        }
203
204        private InputStream getResource(String config) throws Exception {
205                if (config.startsWith("classpath:")) {
206                        return getClasspathResource(config.substring("classpath:".length()));
207                }
208                config = handleUrl(config);
209                if (isUrl(config)) {
210                        return getURLResource(config);
211                }
212                return getFileResource(config);
213        }
214
215        private String handleUrl(String path) throws UnsupportedEncodingException {
216                if (path.startsWith("jar:file:") || path.startsWith("file:")) {
217                        path = URLDecoder.decode(path, "UTF-8");
218                        if (path.startsWith("file:")) {
219                                path = path.substring("file:".length());
220                                if (path.startsWith("//")) {
221                                        path = path.substring(2);
222                                }
223                        }
224                }
225                return path;
226        }
227
228        private boolean isUrl(String config) {
229                return config.contains("://");
230        }
231
232        private InputStream getClasspathResource(String config) {
233                while (config.startsWith("/")) {
234                        config = config.substring(1);
235                }
236                config = "/" + config;
237                debug("Trying classpath: " + config);
238                return getClass().getResourceAsStream(config);
239        }
240
241        private InputStream getFileResource(String config) throws Exception {
242                File file = new File(config);
243                debug("Trying file: " + config);
244                if (file.canRead()) {
245                        return new FileInputStream(file);
246                }
247                return null;
248        }
249
250        private InputStream getURLResource(String config) throws Exception {
251                URL url = new URL(config);
252                if (exists(url)) {
253                        URLConnection con = url.openConnection();
254                        try {
255                                return con.getInputStream();
256                        }
257                        catch (IOException ex) {
258                                // Close the HTTP connection (if applicable).
259                                if (con instanceof HttpURLConnection) {
260                                        ((HttpURLConnection) con).disconnect();
261                                }
262                                throw ex;
263                        }
264                }
265                return null;
266        }
267
268        private boolean exists(URL url) throws IOException {
269                // Try a URL connection content-length header...
270                URLConnection connection = url.openConnection();
271                try {
272                        connection.setUseCaches(
273                                        connection.getClass().getSimpleName().startsWith("JNLP"));
274                        if (connection instanceof HttpURLConnection) {
275                                HttpURLConnection httpConnection = (HttpURLConnection) connection;
276                                httpConnection.setRequestMethod("HEAD");
277                                int responseCode = httpConnection.getResponseCode();
278                                if (responseCode == HttpURLConnection.HTTP_OK) {
279                                        return true;
280                                }
281                                else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
282                                        return false;
283                                }
284                        }
285                        return (connection.getContentLength() >= 0);
286                }
287                finally {
288                        if (connection instanceof HttpURLConnection) {
289                                ((HttpURLConnection) connection).disconnect();
290                        }
291                }
292        }
293
294        private void initializePaths() throws Exception {
295                String path = getProperty(PATH);
296                if (path != null) {
297                        this.paths = parsePathsProperty(path);
298                }
299                debug("Nested archive paths: " + this.paths);
300        }
301
302        private List<String> parsePathsProperty(String commaSeparatedPaths) {
303                List<String> paths = new ArrayList<>();
304                for (String path : commaSeparatedPaths.split(",")) {
305                        path = cleanupPath(path);
306                        // "" means the user wants root of archive but not current directory
307                        path = "".equals(path) ? "/" : path;
308                        paths.add(path);
309                }
310                if (paths.isEmpty()) {
311                        paths.add("lib");
312                }
313                return paths;
314        }
315
316        protected String[] getArgs(String... args) throws Exception {
317                String loaderArgs = getProperty(ARGS);
318                if (loaderArgs != null) {
319                        String[] defaultArgs = loaderArgs.split("\\s+");
320                        String[] additionalArgs = args;
321                        args = new String[defaultArgs.length + additionalArgs.length];
322                        System.arraycopy(defaultArgs, 0, args, 0, defaultArgs.length);
323                        System.arraycopy(additionalArgs, 0, args, defaultArgs.length,
324                                        additionalArgs.length);
325                }
326                return args;
327        }
328
329        @Override
330        protected String getMainClass() throws Exception {
331                String mainClass = getProperty(MAIN, "Start-Class");
332                if (mainClass == null) {
333                        throw new IllegalStateException(
334                                        "No '" + MAIN + "' or 'Start-Class' specified");
335                }
336                return mainClass;
337        }
338
339        @Override
340        protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
341                Set<URL> urls = new LinkedHashSet<>(archives.size());
342                for (Archive archive : archives) {
343                        urls.add(archive.getUrl());
344                }
345                ClassLoader loader = new LaunchedURLClassLoader(urls.toArray(new URL[0]),
346                                getClass().getClassLoader());
347                debug("Classpath: " + urls);
348                String customLoaderClassName = getProperty("loader.classLoader");
349                if (customLoaderClassName != null) {
350                        loader = wrapWithCustomClassLoader(loader, customLoaderClassName);
351                        debug("Using custom class loader: " + customLoaderClassName);
352                }
353                return loader;
354        }
355
356        @SuppressWarnings("unchecked")
357        private ClassLoader wrapWithCustomClassLoader(ClassLoader parent,
358                        String loaderClassName) throws Exception {
359                Class<ClassLoader> loaderClass = (Class<ClassLoader>) Class
360                                .forName(loaderClassName, true, parent);
361
362                try {
363                        return loaderClass.getConstructor(ClassLoader.class).newInstance(parent);
364                }
365                catch (NoSuchMethodException ex) {
366                        // Ignore and try with URLs
367                }
368                try {
369                        return loaderClass.getConstructor(URL[].class, ClassLoader.class)
370                                        .newInstance(new URL[0], parent);
371                }
372                catch (NoSuchMethodException ex) {
373                        // Ignore and try without any arguments
374                }
375                return loaderClass.newInstance();
376        }
377
378        private String getProperty(String propertyKey) throws Exception {
379                return getProperty(propertyKey, null, null);
380        }
381
382        private String getProperty(String propertyKey, String manifestKey) throws Exception {
383                return getProperty(propertyKey, manifestKey, null);
384        }
385
386        private String getPropertyWithDefault(String propertyKey, String defaultValue)
387                        throws Exception {
388                return getProperty(propertyKey, null, defaultValue);
389        }
390
391        private String getProperty(String propertyKey, String manifestKey,
392                        String defaultValue) throws Exception {
393                if (manifestKey == null) {
394                        manifestKey = propertyKey.replace('.', '-');
395                        manifestKey = toCamelCase(manifestKey);
396                }
397                String property = SystemPropertyUtils.getProperty(propertyKey);
398                if (property != null) {
399                        String value = SystemPropertyUtils.resolvePlaceholders(this.properties,
400                                        property);
401                        debug("Property '" + propertyKey + "' from environment: " + value);
402                        return value;
403                }
404                if (this.properties.containsKey(propertyKey)) {
405                        String value = SystemPropertyUtils.resolvePlaceholders(this.properties,
406                                        this.properties.getProperty(propertyKey));
407                        debug("Property '" + propertyKey + "' from properties: " + value);
408                        return value;
409                }
410                try {
411                        if (this.home != null) {
412                                // Prefer home dir for MANIFEST if there is one
413                                Manifest manifest = new ExplodedArchive(this.home, false).getManifest();
414                                if (manifest != null) {
415                                        String value = manifest.getMainAttributes().getValue(manifestKey);
416                                        if (value != null) {
417                                                debug("Property '" + manifestKey
418                                                                + "' from home directory manifest: " + value);
419                                                return SystemPropertyUtils.resolvePlaceholders(this.properties,
420                                                                value);
421                                        }
422                                }
423                        }
424                }
425                catch (IllegalStateException ex) {
426                        // Ignore
427                }
428                // Otherwise try the parent archive
429                Manifest manifest = createArchive().getManifest();
430                if (manifest != null) {
431                        String value = manifest.getMainAttributes().getValue(manifestKey);
432                        if (value != null) {
433                                debug("Property '" + manifestKey + "' from archive manifest: " + value);
434                                return SystemPropertyUtils.resolvePlaceholders(this.properties, value);
435                        }
436                }
437                return (defaultValue != null)
438                                ? SystemPropertyUtils.resolvePlaceholders(this.properties, defaultValue)
439                                : defaultValue;
440        }
441
442        @Override
443        protected List<Archive> getClassPathArchives() throws Exception {
444                List<Archive> lib = new ArrayList<>();
445                for (String path : this.paths) {
446                        for (Archive archive : getClassPathArchives(path)) {
447                                if (archive instanceof ExplodedArchive) {
448                                        List<Archive> nested = new ArrayList<>(
449                                                        archive.getNestedArchives(new ArchiveEntryFilter()));
450                                        nested.add(0, archive);
451                                        lib.addAll(nested);
452                                }
453                                else {
454                                        lib.add(archive);
455                                }
456                        }
457                }
458                addNestedEntries(lib);
459                return lib;
460        }
461
462        private List<Archive> getClassPathArchives(String path) throws Exception {
463                String root = cleanupPath(handleUrl(path));
464                List<Archive> lib = new ArrayList<>();
465                File file = new File(root);
466                if (!"/".equals(root)) {
467                        if (!isAbsolutePath(root)) {
468                                file = new File(this.home, root);
469                        }
470                        if (file.isDirectory()) {
471                                debug("Adding classpath entries from " + file);
472                                Archive archive = new ExplodedArchive(file, false);
473                                lib.add(archive);
474                        }
475                }
476                Archive archive = getArchive(file);
477                if (archive != null) {
478                        debug("Adding classpath entries from archive " + archive.getUrl() + root);
479                        lib.add(archive);
480                }
481                List<Archive> nestedArchives = getNestedArchives(root);
482                if (nestedArchives != null) {
483                        debug("Adding classpath entries from nested " + root);
484                        lib.addAll(nestedArchives);
485                }
486                return lib;
487        }
488
489        private boolean isAbsolutePath(String root) {
490                // Windows contains ":" others start with "/"
491                return root.contains(":") || root.startsWith("/");
492        }
493
494        private Archive getArchive(File file) throws IOException {
495                if (isNestedArchivePath(file)) {
496                        return null;
497                }
498                String name = file.getName().toLowerCase(Locale.ENGLISH);
499                if (name.endsWith(".jar") || name.endsWith(".zip")) {
500                        return new JarFileArchive(file);
501                }
502                return null;
503        }
504
505        private boolean isNestedArchivePath(File file) {
506                return file.getPath().contains(NESTED_ARCHIVE_SEPARATOR);
507        }
508
509        private List<Archive> getNestedArchives(String path) throws Exception {
510                Archive parent = this.parent;
511                String root = path;
512                if (!root.equals("/") && root.startsWith("/")
513                                || parent.getUrl().equals(this.home.toURI().toURL())) {
514                        // If home dir is same as parent archive, no need to add it twice.
515                        return null;
516                }
517                int index = root.indexOf('!');
518                if (index != -1) {
519                        File file = new File(this.home, root.substring(0, index));
520                        if (root.startsWith("jar:file:")) {
521                                file = new File(root.substring("jar:file:".length(), index));
522                        }
523                        parent = new JarFileArchive(file);
524                        root = root.substring(index + 1);
525                        while (root.startsWith("/")) {
526                                root = root.substring(1);
527                        }
528                }
529                if (root.endsWith(".jar")) {
530                        File file = new File(this.home, root);
531                        if (file.exists()) {
532                                parent = new JarFileArchive(file);
533                                root = "";
534                        }
535                }
536                if (root.equals("/") || root.equals("./") || root.equals(".")) {
537                        // The prefix for nested jars is actually empty if it's at the root
538                        root = "";
539                }
540                EntryFilter filter = new PrefixMatchingArchiveFilter(root);
541                List<Archive> archives = new ArrayList<>(parent.getNestedArchives(filter));
542                if (("".equals(root) || ".".equals(root)) && !path.endsWith(".jar")
543                                && parent != this.parent) {
544                        // You can't find the root with an entry filter so it has to be added
545                        // explicitly. But don't add the root of the parent archive.
546                        archives.add(parent);
547                }
548                return archives;
549        }
550
551        private void addNestedEntries(List<Archive> lib) {
552                // The parent archive might have "BOOT-INF/lib/" and "BOOT-INF/classes/"
553                // directories, meaning we are running from an executable JAR. We add nested
554                // entries from there with low priority (i.e. at end).
555                try {
556                        lib.addAll(this.parent.getNestedArchives((entry) -> {
557                                if (entry.isDirectory()) {
558                                        return entry.getName().equals(JarLauncher.BOOT_INF_CLASSES);
559                                }
560                                return entry.getName().startsWith(JarLauncher.BOOT_INF_LIB);
561                        }));
562                }
563                catch (IOException ex) {
564                        // Ignore
565                }
566        }
567
568        private String cleanupPath(String path) {
569                path = path.trim();
570                // No need for current dir path
571                if (path.startsWith("./")) {
572                        path = path.substring(2);
573                }
574                String lowerCasePath = path.toLowerCase(Locale.ENGLISH);
575                if (lowerCasePath.endsWith(".jar") || lowerCasePath.endsWith(".zip")) {
576                        return path;
577                }
578                if (path.endsWith("/*")) {
579                        path = path.substring(0, path.length() - 1);
580                }
581                else {
582                        // It's a directory
583                        if (!path.endsWith("/") && !path.equals(".")) {
584                                path = path + "/";
585                        }
586                }
587                return path;
588        }
589
590        public static void main(String[] args) throws Exception {
591                PropertiesLauncher launcher = new PropertiesLauncher();
592                args = launcher.getArgs(args);
593                launcher.launch(args);
594        }
595
596        public static String toCamelCase(CharSequence string) {
597                if (string == null) {
598                        return null;
599                }
600                StringBuilder builder = new StringBuilder();
601                Matcher matcher = WORD_SEPARATOR.matcher(string);
602                int pos = 0;
603                while (matcher.find()) {
604                        builder.append(capitalize(string.subSequence(pos, matcher.end()).toString()));
605                        pos = matcher.end();
606                }
607                builder.append(capitalize(string.subSequence(pos, string.length()).toString()));
608                return builder.toString();
609        }
610
611        private static String capitalize(String str) {
612                return Character.toUpperCase(str.charAt(0)) + str.substring(1);
613        }
614
615        private void debug(String message) {
616                if (Boolean.getBoolean(DEBUG)) {
617                        System.out.println(message);
618                }
619        }
620
621        /**
622         * Convenience class for finding nested archives that have a prefix in their file path
623         * (e.g. "lib/").
624         */
625        private static final class PrefixMatchingArchiveFilter implements EntryFilter {
626
627                private final String prefix;
628
629                private final ArchiveEntryFilter filter = new ArchiveEntryFilter();
630
631                private PrefixMatchingArchiveFilter(String prefix) {
632                        this.prefix = prefix;
633                }
634
635                @Override
636                public boolean matches(Entry entry) {
637                        if (entry.isDirectory()) {
638                                return entry.getName().equals(this.prefix);
639                        }
640                        return entry.getName().startsWith(this.prefix) && this.filter.matches(entry);
641                }
642
643        }
644
645        /**
646         * Convenience class for finding nested archives (archive entries that can be
647         * classpath entries).
648         */
649        private static final class ArchiveEntryFilter implements EntryFilter {
650
651                private static final String DOT_JAR = ".jar";
652
653                private static final String DOT_ZIP = ".zip";
654
655                @Override
656                public boolean matches(Entry entry) {
657                        return entry.getName().endsWith(DOT_JAR) || entry.getName().endsWith(DOT_ZIP);
658                }
659
660        }
661
662}