1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package libcore.io;
18 
19 import java.io.File;
20 import java.io.FileNotFoundException;
21 import java.io.FilterInputStream;
22 import java.io.IOException;
23 import java.io.InputStream;
24 import java.net.JarURLConnection;
25 import java.net.MalformedURLException;
26 import java.net.URL;
27 import java.net.URLConnection;
28 import java.net.URLEncoder;
29 import java.net.URLStreamHandler;
30 import java.util.jar.JarFile;
31 import java.util.zip.ZipEntry;
32 import sun.net.www.ParseUtil;
33 import sun.net.www.protocol.jar.Handler;
34 
35 /**
36  * A {@link URLStreamHandler} for a specific class path {@link JarFile}. This class avoids the need
37  * to open a jar file multiple times to read resources if the jar file can be held open. The
38  * {@link URLConnection} objects created are a subclass of {@link JarURLConnection}.
39  *
40  * <p>Use {@link #getEntryUrlOrNull(String)} to obtain a URL backed by this stream handler.
41  */
42 public class ClassPathURLStreamHandler extends Handler {
43   private final String fileUri;
44   private final JarFile jarFile;
45 
ClassPathURLStreamHandler(String jarFileName)46   public ClassPathURLStreamHandler(String jarFileName) throws IOException {
47     jarFile = new JarFile(jarFileName);
48 
49     // File.toURI() is compliant with RFC 1738 in always creating absolute path names. If we
50     // construct the URL by concatenating strings, we might end up with illegal URLs for relative
51     // names.
52     this.fileUri = new File(jarFileName).toURI().toString();
53   }
54 
55   /**
56    * Returns a URL backed by this stream handler for the named resource, or {@code null} if the
57    * entry cannot be found under the exact name presented.
58    */
getEntryUrlOrNull(String entryName)59   public URL getEntryUrlOrNull(String entryName) {
60     if (findEntryWithDirectoryFallback(jarFile, entryName) != null) {
61       try {
62         // Encode the path to ensure that any special characters like # survive their trip through
63         // the URL. Entry names must use / as the path separator.
64         String encodedName = ParseUtil.encodePath(entryName, false);
65         return new URL("jar", null, -1, fileUri + "!/" + encodedName, this);
66       } catch (MalformedURLException e) {
67         throw new RuntimeException("Invalid entry name", e);
68       }
69     }
70     return null;
71   }
72 
73   /**
74    * Returns true if an entry with the specified name exists and is stored (not compressed),
75    * and false otherwise.
76    */
isEntryStored(String entryName)77   public boolean isEntryStored(String entryName) {
78     ZipEntry entry = jarFile.getEntry(entryName);
79     return entry != null && entry.getMethod() == ZipEntry.STORED;
80   }
81 
82   @Override
openConnection(URL url)83   protected URLConnection openConnection(URL url) throws IOException {
84     return new ClassPathURLConnection(url);
85   }
86 
87   /** Used from tests to indicate this stream handler is finished with. */
close()88   public void close() throws IOException {
89     jarFile.close();
90   }
91 
92   /**
93    * Finds an entry with the specified name in the {@code jarFile}. If an exact match isn't found it
94    * will also try with "/" appended, if appropriate. This is to maintain compatibility with
95    * {@link sun.net.www.protocol.jar.Handler} and its treatment of directory entries.
96    */
findEntryWithDirectoryFallback(JarFile jarFile, String entryName)97   static ZipEntry findEntryWithDirectoryFallback(JarFile jarFile, String entryName) {
98     ZipEntry entry = jarFile.getEntry(entryName);
99     if (entry == null && !entryName.endsWith("/") ) {
100       entry = jarFile.getEntry(entryName + "/");
101     }
102     return entry;
103   }
104 
105   private class ClassPathURLConnection extends JarURLConnection {
106     // The JarFile instance is shared across URLConnections and must not be closed.
107     private JarFile connectionJarFile;
108 
109     private ZipEntry jarEntry;
110     private InputStream jarInput;
111     private boolean closed;
112 
113     /**
114      * Indicates the behavior of the {@link #jarFile}. If true, the reference is shared and should
115      * not be closed. If false, it must be closed.
116      */
117     private boolean useCachedJarFile;
118 
119 
ClassPathURLConnection(URL url)120     public ClassPathURLConnection(URL url) throws MalformedURLException {
121       super(url);
122     }
123 
124     @Override
connect()125     public void connect() throws IOException {
126       if (!connected) {
127         this.jarEntry = findEntryWithDirectoryFallback(ClassPathURLStreamHandler.this.jarFile,
128             getEntryName());
129         if (jarEntry == null) {
130           throw new FileNotFoundException(
131               "URL does not correspond to an entry in the zip file. URL=" + url
132               + ", zipfile=" + jarFile.getName());
133         }
134         useCachedJarFile = getUseCaches();
135         connected = true;
136       }
137     }
138 
139     @Override
getJarFile()140     public JarFile getJarFile() throws IOException {
141       connect();
142 
143       // We do cache in the surrounding class if useCachedJarFile is true to
144       // preserve garbage collection semantics to avoid leak warnings.
145       if (useCachedJarFile) {
146         connectionJarFile = jarFile;
147       } else {
148         connectionJarFile = new JarFile(jarFile.getName());
149       }
150       return connectionJarFile;
151     }
152 
153     @Override
getInputStream()154     public InputStream getInputStream() throws IOException {
155       if (closed) {
156         throw new IllegalStateException("JarURLConnection InputStream has been closed");
157       }
158       connect();
159       if (jarInput != null) {
160         return jarInput;
161       }
162       return jarInput = new FilterInputStream(jarFile.getInputStream(jarEntry)) {
163         @Override
164         public void close() throws IOException {
165           super.close();
166           // If the jar file is not cached closing the input stream will close the URLConnection and
167           // any JarFile returned from getJarFile().
168           if (connectionJarFile != null && !useCachedJarFile) {
169             connectionJarFile.close();
170             closed = true;
171           }
172         }
173       };
174     }
175 
176     /**
177      * Returns the content type of the entry based on the name of the entry. Returns
178      * non-null results ("content/unknown" for unknown types).
179      *
180      * @return the content type
181      */
182     @Override
getContentType()183     public String getContentType() {
184       String cType = guessContentTypeFromName(getEntryName());
185       if (cType == null) {
186         cType = "content/unknown";
187       }
188       return cType;
189     }
190 
191     @Override
getContentLength()192     public int getContentLength() {
193       try {
194         connect();
195         return (int) getJarEntry().getSize();
196       } catch (IOException e) {
197         // Ignored
198       }
199       return -1;
200     }
201   }
202 }
203