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.jar;
018
019import java.io.File;
020import java.io.IOException;
021import java.lang.ref.SoftReference;
022import java.lang.reflect.Method;
023import java.net.MalformedURLException;
024import java.net.URL;
025import java.net.URLConnection;
026import java.net.URLDecoder;
027import java.net.URLStreamHandler;
028import java.util.Map;
029import java.util.concurrent.ConcurrentHashMap;
030import java.util.logging.Level;
031import java.util.logging.Logger;
032import java.util.regex.Pattern;
033
034/**
035 * {@link URLStreamHandler} for Spring Boot loader {@link JarFile}s.
036 *
037 * @author Phillip Webb
038 * @author Andy Wilkinson
039 * @see JarFile#registerUrlProtocolHandler()
040 */
041public class Handler extends URLStreamHandler {
042
043        // NOTE: in order to be found as a URL protocol handler, this class must be public,
044        // must be named Handler and must be in a package ending '.jar'
045
046        private static final String JAR_PROTOCOL = "jar:";
047
048        private static final String FILE_PROTOCOL = "file:";
049
050        private static final String SEPARATOR = "!/";
051
052        private static final String CURRENT_DIR = "/./";
053
054        private static final Pattern CURRENT_DIR_PATTERN = Pattern.compile(CURRENT_DIR);
055
056        private static final String PARENT_DIR = "/../";
057
058        private static final String[] FALLBACK_HANDLERS = {
059                        "sun.net.www.protocol.jar.Handler" };
060
061        private static final Method OPEN_CONNECTION_METHOD;
062
063        static {
064                Method method = null;
065                try {
066                        method = URLStreamHandler.class.getDeclaredMethod("openConnection",
067                                        URL.class);
068                }
069                catch (Exception ex) {
070                        // Swallow and ignore
071                }
072                OPEN_CONNECTION_METHOD = method;
073        }
074
075        private static SoftReference<Map<File, JarFile>> rootFileCache;
076
077        static {
078                rootFileCache = new SoftReference<>(null);
079        }
080
081        private final JarFile jarFile;
082
083        private URLStreamHandler fallbackHandler;
084
085        public Handler() {
086                this(null);
087        }
088
089        public Handler(JarFile jarFile) {
090                this.jarFile = jarFile;
091        }
092
093        @Override
094        protected URLConnection openConnection(URL url) throws IOException {
095                if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) {
096                        return JarURLConnection.get(url, this.jarFile);
097                }
098                try {
099                        return JarURLConnection.get(url, getRootJarFileFromUrl(url));
100                }
101                catch (Exception ex) {
102                        return openFallbackConnection(url, ex);
103                }
104        }
105
106        private boolean isUrlInJarFile(URL url, JarFile jarFile)
107                        throws MalformedURLException {
108                // Try the path first to save building a new url string each time
109                return url.getPath().startsWith(jarFile.getUrl().getPath())
110                                && url.toString().startsWith(jarFile.getUrlString());
111        }
112
113        private URLConnection openFallbackConnection(URL url, Exception reason)
114                        throws IOException {
115                try {
116                        return openConnection(getFallbackHandler(), url);
117                }
118                catch (Exception ex) {
119                        if (reason instanceof IOException) {
120                                log(false, "Unable to open fallback handler", ex);
121                                throw (IOException) reason;
122                        }
123                        log(true, "Unable to open fallback handler", ex);
124                        if (reason instanceof RuntimeException) {
125                                throw (RuntimeException) reason;
126                        }
127                        throw new IllegalStateException(reason);
128                }
129        }
130
131        private void log(boolean warning, String message, Exception cause) {
132                try {
133                        Level level = warning ? Level.WARNING : Level.FINEST;
134                        Logger.getLogger(getClass().getName()).log(level, message, cause);
135                }
136                catch (Exception ex) {
137                        if (warning) {
138                                System.err.println("WARNING: " + message);
139                        }
140                }
141        }
142
143        private URLStreamHandler getFallbackHandler() {
144                if (this.fallbackHandler != null) {
145                        return this.fallbackHandler;
146                }
147                for (String handlerClassName : FALLBACK_HANDLERS) {
148                        try {
149                                Class<?> handlerClass = Class.forName(handlerClassName);
150                                this.fallbackHandler = (URLStreamHandler) handlerClass.newInstance();
151                                return this.fallbackHandler;
152                        }
153                        catch (Exception ex) {
154                                // Ignore
155                        }
156                }
157                throw new IllegalStateException("Unable to find fallback handler");
158        }
159
160        private URLConnection openConnection(URLStreamHandler handler, URL url)
161                        throws Exception {
162                if (OPEN_CONNECTION_METHOD == null) {
163                        throw new IllegalStateException(
164                                        "Unable to invoke fallback open connection method");
165                }
166                OPEN_CONNECTION_METHOD.setAccessible(true);
167                return (URLConnection) OPEN_CONNECTION_METHOD.invoke(handler, url);
168        }
169
170        @Override
171        protected void parseURL(URL context, String spec, int start, int limit) {
172                if (spec.regionMatches(true, 0, JAR_PROTOCOL, 0, JAR_PROTOCOL.length())) {
173                        setFile(context, getFileFromSpec(spec.substring(start, limit)));
174                }
175                else {
176                        setFile(context, getFileFromContext(context, spec.substring(start, limit)));
177                }
178        }
179
180        private String getFileFromSpec(String spec) {
181                int separatorIndex = spec.lastIndexOf("!/");
182                if (separatorIndex == -1) {
183                        throw new IllegalArgumentException("No !/ in spec '" + spec + "'");
184                }
185                try {
186                        new URL(spec.substring(0, separatorIndex));
187                        return spec;
188                }
189                catch (MalformedURLException ex) {
190                        throw new IllegalArgumentException("Invalid spec URL '" + spec + "'", ex);
191                }
192        }
193
194        private String getFileFromContext(URL context, String spec) {
195                String file = context.getFile();
196                if (spec.startsWith("/")) {
197                        return trimToJarRoot(file) + SEPARATOR + spec.substring(1);
198                }
199                if (file.endsWith("/")) {
200                        return file + spec;
201                }
202                int lastSlashIndex = file.lastIndexOf('/');
203                if (lastSlashIndex == -1) {
204                        throw new IllegalArgumentException(
205                                        "No / found in context URL's file '" + file + "'");
206                }
207                return file.substring(0, lastSlashIndex + 1) + spec;
208        }
209
210        private String trimToJarRoot(String file) {
211                int lastSeparatorIndex = file.lastIndexOf(SEPARATOR);
212                if (lastSeparatorIndex == -1) {
213                        throw new IllegalArgumentException(
214                                        "No !/ found in context URL's file '" + file + "'");
215                }
216                return file.substring(0, lastSeparatorIndex);
217        }
218
219        private void setFile(URL context, String file) {
220                String path = normalize(file);
221                String query = null;
222                int queryIndex = path.lastIndexOf('?');
223                if (queryIndex != -1) {
224                        query = path.substring(queryIndex + 1);
225                        path = path.substring(0, queryIndex);
226                }
227                setURL(context, JAR_PROTOCOL, null, -1, null, null, path, query,
228                                context.getRef());
229        }
230
231        private String normalize(String file) {
232                if (!file.contains(CURRENT_DIR) && !file.contains(PARENT_DIR)) {
233                        return file;
234                }
235                int afterLastSeparatorIndex = file.lastIndexOf(SEPARATOR) + SEPARATOR.length();
236                String afterSeparator = file.substring(afterLastSeparatorIndex);
237                afterSeparator = replaceParentDir(afterSeparator);
238                afterSeparator = replaceCurrentDir(afterSeparator);
239                return file.substring(0, afterLastSeparatorIndex) + afterSeparator;
240        }
241
242        private String replaceParentDir(String file) {
243                int parentDirIndex;
244                while ((parentDirIndex = file.indexOf(PARENT_DIR)) >= 0) {
245                        int precedingSlashIndex = file.lastIndexOf('/', parentDirIndex - 1);
246                        if (precedingSlashIndex >= 0) {
247                                file = file.substring(0, precedingSlashIndex)
248                                                + file.substring(parentDirIndex + 3);
249                        }
250                        else {
251                                file = file.substring(parentDirIndex + 4);
252                        }
253                }
254                return file;
255        }
256
257        private String replaceCurrentDir(String file) {
258                return CURRENT_DIR_PATTERN.matcher(file).replaceAll("/");
259        }
260
261        @Override
262        protected int hashCode(URL u) {
263                return hashCode(u.getProtocol(), u.getFile());
264        }
265
266        private int hashCode(String protocol, String file) {
267                int result = (protocol != null) ? protocol.hashCode() : 0;
268                int separatorIndex = file.indexOf(SEPARATOR);
269                if (separatorIndex == -1) {
270                        return result + file.hashCode();
271                }
272                String source = file.substring(0, separatorIndex);
273                String entry = canonicalize(file.substring(separatorIndex + 2));
274                try {
275                        result += new URL(source).hashCode();
276                }
277                catch (MalformedURLException ex) {
278                        result += source.hashCode();
279                }
280                result += entry.hashCode();
281                return result;
282        }
283
284        @Override
285        protected boolean sameFile(URL u1, URL u2) {
286                if (!u1.getProtocol().equals("jar") || !u2.getProtocol().equals("jar")) {
287                        return false;
288                }
289                int separator1 = u1.getFile().indexOf(SEPARATOR);
290                int separator2 = u2.getFile().indexOf(SEPARATOR);
291                if (separator1 == -1 || separator2 == -1) {
292                        return super.sameFile(u1, u2);
293                }
294                String nested1 = u1.getFile().substring(separator1 + SEPARATOR.length());
295                String nested2 = u2.getFile().substring(separator2 + SEPARATOR.length());
296                if (!nested1.equals(nested2)) {
297                        String canonical1 = canonicalize(nested1);
298                        String canonical2 = canonicalize(nested2);
299                        if (!canonical1.equals(canonical2)) {
300                                return false;
301                        }
302                }
303                String root1 = u1.getFile().substring(0, separator1);
304                String root2 = u2.getFile().substring(0, separator2);
305                try {
306                        return super.sameFile(new URL(root1), new URL(root2));
307                }
308                catch (MalformedURLException ex) {
309                        // Continue
310                }
311                return super.sameFile(u1, u2);
312        }
313
314        private String canonicalize(String path) {
315                return path.replace(SEPARATOR, "/");
316        }
317
318        public JarFile getRootJarFileFromUrl(URL url) throws IOException {
319                String spec = url.getFile();
320                int separatorIndex = spec.indexOf(SEPARATOR);
321                if (separatorIndex == -1) {
322                        throw new MalformedURLException("Jar URL does not contain !/ separator");
323                }
324                String name = spec.substring(0, separatorIndex);
325                return getRootJarFile(name);
326        }
327
328        private JarFile getRootJarFile(String name) throws IOException {
329                try {
330                        if (!name.startsWith(FILE_PROTOCOL)) {
331                                throw new IllegalStateException("Not a file URL");
332                        }
333                        String path = name.substring(FILE_PROTOCOL.length());
334                        File file = new File(URLDecoder.decode(path, "UTF-8"));
335                        Map<File, JarFile> cache = rootFileCache.get();
336                        JarFile result = (cache != null) ? cache.get(file) : null;
337                        if (result == null) {
338                                result = new JarFile(file);
339                                addToRootFileCache(file, result);
340                        }
341                        return result;
342                }
343                catch (Exception ex) {
344                        throw new IOException("Unable to open root Jar file '" + name + "'", ex);
345                }
346        }
347
348        /**
349         * Add the given {@link JarFile} to the root file cache.
350         * @param sourceFile the source file to add
351         * @param jarFile the jar file.
352         */
353        static void addToRootFileCache(File sourceFile, JarFile jarFile) {
354                Map<File, JarFile> cache = rootFileCache.get();
355                if (cache == null) {
356                        cache = new ConcurrentHashMap<>();
357                        rootFileCache = new SoftReference<>(cache);
358                }
359                cache.put(sourceFile, jarFile);
360        }
361
362        /**
363         * Set if a generic static exception can be thrown when a URL cannot be connected.
364         * This optimization is used during class loading to save creating lots of exceptions
365         * which are then swallowed.
366         * @param useFastConnectionExceptions if fast connection exceptions can be used.
367         */
368        public static void setUseFastConnectionExceptions(
369                        boolean useFastConnectionExceptions) {
370                JarURLConnection.setUseFastExceptions(useFastConnectionExceptions);
371        }
372
373}