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