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
17package libcore.io;
18
19import java.io.File;
20import java.io.FileNotFoundException;
21import java.io.FilterInputStream;
22import java.io.IOException;
23import java.io.InputStream;
24import java.net.JarURLConnection;
25import java.net.MalformedURLException;
26import java.net.URL;
27import java.net.URLConnection;
28import java.net.URLEncoder;
29import java.net.URLStreamHandler;
30import java.util.jar.JarFile;
31import java.util.zip.ZipEntry;
32import sun.net.www.ParseUtil;
33import 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 */
42public class ClassPathURLStreamHandler extends Handler {
43  private final String fileUri;
44  private final JarFile jarFile;
45
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   */
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   */
77  public boolean isEntryStored(String entryName) {
78    ZipEntry entry = jarFile.getEntry(entryName);
79    return entry != null && entry.getMethod() == ZipEntry.STORED;
80  }
81
82  @Override
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. */
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   */
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 can be shared across URLConnections and should not be closed when it is:
107    //
108    // Sharing occurs if getUseCaches() is true when connect() is called (which can take place
109    // implicitly). useCachedJarFile records the state of sharing at connect() time.
110    // useCachedJarFile == true is the common case. If developers call getJarFile().close() when
111    // sharing is enabled then it will affect other users (current and future) of the shared
112    // JarFile.
113    //
114    // Developers could call ClassLoader.findResource().openConnection() to get a URLConnection and
115    // then call setUseCaches(false) before connect() to prevent sharing. The developer must then
116    // call getJarFile().close() or close() on the inputStream from getInputStream() will do it
117    // automatically. This is likely to be an extremely rare case.
118    //
119    // Most developers are not expecting to deal with the lifecycle of the underlying JarFile object
120    // at all. The presence of the getJarFile() method and setUseCaches() forces us to consider /
121    // handle it.
122    private JarFile connectionJarFile;
123
124    private ZipEntry jarEntry;
125    private InputStream jarInput;
126    private boolean closed;
127
128    /**
129     * Indicates the behavior of the {@link #jarFile}. If true, the reference is shared and should
130     * not be closed. If false, it must be closed.
131     */
132    private boolean useCachedJarFile;
133
134
135    public ClassPathURLConnection(URL url) throws MalformedURLException {
136      super(url);
137    }
138
139    @Override
140    public void connect() throws IOException {
141      if (!connected) {
142        this.jarEntry = findEntryWithDirectoryFallback(ClassPathURLStreamHandler.this.jarFile,
143            getEntryName());
144        if (jarEntry == null) {
145          throw new FileNotFoundException(
146              "URL does not correspond to an entry in the zip file. URL=" + url
147              + ", zipfile=" + jarFile.getName());
148        }
149        useCachedJarFile = getUseCaches();
150        connected = true;
151      }
152    }
153
154    @Override
155    public JarFile getJarFile() throws IOException {
156      connect();
157
158      // We do cache in the surrounding class if useCachedJarFile is true to
159      // preserve garbage collection semantics and to avoid leak warnings.
160      if (useCachedJarFile) {
161        connectionJarFile = jarFile;
162      } else {
163        connectionJarFile = new JarFile(jarFile.getName());
164      }
165      return connectionJarFile;
166    }
167
168    @Override
169    public InputStream getInputStream() throws IOException {
170      if (closed) {
171        throw new IllegalStateException("JarURLConnection InputStream has been closed");
172      }
173      connect();
174      if (jarInput != null) {
175        return jarInput;
176      }
177      return jarInput = new FilterInputStream(jarFile.getInputStream(jarEntry)) {
178        @Override
179        public void close() throws IOException {
180          super.close();
181          // If the jar file is not cached then closing the input stream will close the
182          // URLConnection and any JarFile returned from getJarFile(). If the jar file is cached
183          // we must not close it because it will affect other URLConnections.
184          if (connectionJarFile != null && !useCachedJarFile) {
185            connectionJarFile.close();
186            closed = true;
187          }
188        }
189      };
190    }
191
192    /**
193     * Returns the content type of the entry based on the name of the entry. Returns
194     * non-null results ("content/unknown" for unknown types).
195     *
196     * @return the content type
197     */
198    @Override
199    public String getContentType() {
200      String cType = guessContentTypeFromName(getEntryName());
201      if (cType == null) {
202        cType = "content/unknown";
203      }
204      return cType;
205    }
206
207    @Override
208    public int getContentLength() {
209      try {
210        connect();
211        return (int) getJarEntry().getSize();
212      } catch (IOException e) {
213        // Ignored
214      }
215      return -1;
216    }
217  }
218}
219