View Javadoc
1   /*
2    *    Copyright 2009-2024 the original author or authors.
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    *       https://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  package org.apache.ibatis.io;
17  
18  import java.io.BufferedReader;
19  import java.io.File;
20  import java.io.FileNotFoundException;
21  import java.io.IOException;
22  import java.io.InputStream;
23  import java.io.InputStreamReader;
24  import java.io.UnsupportedEncodingException;
25  import java.net.MalformedURLException;
26  import java.net.URL;
27  import java.net.URLEncoder;
28  import java.nio.charset.StandardCharsets;
29  import java.nio.file.FileSystemException;
30  import java.nio.file.InvalidPathException;
31  import java.util.ArrayList;
32  import java.util.Arrays;
33  import java.util.List;
34  import java.util.jar.JarEntry;
35  import java.util.jar.JarInputStream;
36  
37  import org.apache.ibatis.logging.Log;
38  import org.apache.ibatis.logging.LogFactory;
39  
40  /**
41   * A default implementation of {@link VFS} that works for most application servers.
42   *
43   * @author Ben Gunter
44   */
45  public class DefaultVFS extends VFS {
46    private static final Log log = LogFactory.getLog(DefaultVFS.class);
47  
48    /** The magic header that indicates a JAR (ZIP) file. */
49    private static final byte[] JAR_MAGIC = { 'P', 'K', 3, 4 };
50  
51    @Override
52    public boolean isValid() {
53      return true;
54    }
55  
56    @Override
57    public List<String> list(URL url, String path) throws IOException {
58      InputStream is = null;
59      try {
60        List<String> resources = new ArrayList<>();
61  
62        // First, try to find the URL of a JAR file containing the requested resource. If a JAR
63        // file is found, then we'll list child resources by reading the JAR.
64        URL jarUrl = findJarForResource(url);
65        if (jarUrl != null) {
66          is = jarUrl.openStream();
67          if (log.isDebugEnabled()) {
68            log.debug("Listing " + url);
69          }
70          resources = listResources(new JarInputStream(is), path);
71        } else {
72          List<String> children = new ArrayList<>();
73          try {
74            if (isJar(url)) {
75              // Some versions of JBoss VFS might give a JAR stream even if the resource
76              // referenced by the URL isn't actually a JAR
77              is = url.openStream();
78              try (JarInputStream jarInput = new JarInputStream(is)) {
79                if (log.isDebugEnabled()) {
80                  log.debug("Listing " + url);
81                }
82                File destinationDir = new File(path);
83                for (JarEntry entry; (entry = jarInput.getNextJarEntry()) != null;) {
84                  if (log.isDebugEnabled()) {
85                    log.debug("Jar entry: " + entry.getName());
86                  }
87                  File entryFile = new File(destinationDir, entry.getName()).getCanonicalFile();
88                  if (!entryFile.getPath().startsWith(destinationDir.getCanonicalPath())) {
89                    throw new IOException("Bad zip entry: " + entry.getName());
90                  }
91                  children.add(entry.getName());
92                }
93              }
94            } else {
95              /*
96               * Some servlet containers allow reading from directory resources like a text file, listing the child
97               * resources one per line. However, there is no way to differentiate between directory and file resources
98               * just by reading them. To work around that, as each line is read, try to look it up via the class loader
99               * as a child of the current resource. If any line fails then we assume the current resource is not a
100              * directory.
101              */
102             is = url.openStream();
103             List<String> lines = new ArrayList<>();
104             try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
105               for (String line; (line = reader.readLine()) != null;) {
106                 if (log.isDebugEnabled()) {
107                   log.debug("Reader entry: " + line);
108                 }
109                 lines.add(line);
110                 if (getResources(path + "/" + line).isEmpty()) {
111                   lines.clear();
112                   break;
113                 }
114               }
115             } catch (InvalidPathException | FileSystemException e) {
116               // #1974 #2598
117               lines.clear();
118             }
119             if (!lines.isEmpty()) {
120               if (log.isDebugEnabled()) {
121                 log.debug("Listing " + url);
122               }
123               children.addAll(lines);
124             }
125           }
126         } catch (FileNotFoundException e) {
127           /*
128            * For file URLs the openStream() call might fail, depending on the servlet container, because directories
129            * can't be opened for reading. If that happens, then list the directory directly instead.
130            */
131           if (!"file".equals(url.getProtocol())) {
132             // No idea where the exception came from so rethrow it
133             throw e;
134           }
135           File file = new File(url.getFile());
136           if (log.isDebugEnabled()) {
137             log.debug("Listing directory " + file.getAbsolutePath());
138           }
139           if (file.isDirectory()) {
140             if (log.isDebugEnabled()) {
141               log.debug("Listing " + url);
142             }
143             children = Arrays.asList(file.list());
144           }
145         }
146 
147         // The URL prefix to use when recursively listing child resources
148         String prefix = url.toExternalForm();
149         if (!prefix.endsWith("/")) {
150           prefix = prefix + "/";
151         }
152 
153         // Iterate over immediate children, adding files and recurring into directories
154         for (String child : children) {
155           String resourcePath = path + "/" + child;
156           resources.add(resourcePath);
157           URL childUrl = new URL(prefix + child);
158           resources.addAll(list(childUrl, resourcePath));
159         }
160       }
161 
162       return resources;
163     } finally {
164       if (is != null) {
165         try {
166           is.close();
167         } catch (Exception e) {
168           // Ignore
169         }
170       }
171     }
172   }
173 
174   /**
175    * List the names of the entries in the given {@link JarInputStream} that begin with the specified {@code path}.
176    * Entries will match with or without a leading slash.
177    *
178    * @param jar
179    *          The JAR input stream
180    * @param path
181    *          The leading path to match
182    *
183    * @return The names of all the matching entries
184    *
185    * @throws IOException
186    *           If I/O errors occur
187    */
188   protected List<String> listResources(JarInputStream jar, String path) throws IOException {
189     // Include the leading and trailing slash when matching names
190     if (!path.startsWith("/")) {
191       path = "/" + path;
192     }
193     if (!path.endsWith("/")) {
194       path = path + "/";
195     }
196 
197     // Iterate over the entries and collect those that begin with the requested path
198     List<String> resources = new ArrayList<>();
199     for (JarEntry entry; (entry = jar.getNextJarEntry()) != null;) {
200       if (!entry.isDirectory()) {
201         // Add leading slash if it's missing
202         StringBuilder name = new StringBuilder(entry.getName());
203         if (name.charAt(0) != '/') {
204           name.insert(0, '/');
205         }
206 
207         // Check file name
208         if (name.indexOf(path) == 0) {
209           if (log.isDebugEnabled()) {
210             log.debug("Found resource: " + name);
211           }
212           // Trim leading slash
213           resources.add(name.substring(1));
214         }
215       }
216     }
217     return resources;
218   }
219 
220   /**
221    * Attempts to deconstruct the given URL to find a JAR file containing the resource referenced by the URL. That is,
222    * assuming the URL references a JAR entry, this method will return a URL that references the JAR file containing the
223    * entry. If the JAR cannot be located, then this method returns null.
224    *
225    * @param url
226    *          The URL of the JAR entry.
227    *
228    * @return The URL of the JAR file, if one is found. Null if not.
229    *
230    * @throws MalformedURLException
231    *           the malformed URL exception
232    */
233   protected URL findJarForResource(URL url) throws MalformedURLException {
234     if (log.isDebugEnabled()) {
235       log.debug("Find JAR URL: " + url);
236     }
237 
238     // If the file part of the URL is itself a URL, then that URL probably points to the JAR
239     boolean continueLoop = true;
240     while (continueLoop) {
241       try {
242         url = new URL(url.getFile());
243         if (log.isDebugEnabled()) {
244           log.debug("Inner URL: " + url);
245         }
246       } catch (MalformedURLException e) {
247         // This will happen at some point and serves as a break in the loop
248         continueLoop = false;
249       }
250     }
251 
252     // Look for the .jar extension and chop off everything after that
253     StringBuilder jarUrl = new StringBuilder(url.toExternalForm());
254     int index = jarUrl.lastIndexOf(".jar");
255     if (index < 0) {
256       if (log.isDebugEnabled()) {
257         log.debug("Not a JAR: " + jarUrl);
258       }
259       return null;
260     }
261     jarUrl.setLength(index + 4);
262     if (log.isDebugEnabled()) {
263       log.debug("Extracted JAR URL: " + jarUrl);
264     }
265 
266     // Try to open and test it
267     try {
268       URL testUrl = new URL(jarUrl.toString());
269       if (isJar(testUrl)) {
270         return testUrl;
271       }
272       // WebLogic fix: check if the URL's file exists in the filesystem.
273       if (log.isDebugEnabled()) {
274         log.debug("Not a JAR: " + jarUrl);
275       }
276       jarUrl.replace(0, jarUrl.length(), testUrl.getFile());
277       File file = new File(jarUrl.toString());
278 
279       // File name might be URL-encoded
280       if (!file.exists()) {
281         try {
282           file = new File(URLEncoder.encode(jarUrl.toString(), StandardCharsets.UTF_8.name()));
283         } catch (UnsupportedEncodingException e) {
284           throw new RuntimeException("Unsupported encoding?  UTF-8?  That's impossible.");
285         }
286       }
287 
288       if (file.exists()) {
289         if (log.isDebugEnabled()) {
290           log.debug("Trying real file: " + file.getAbsolutePath());
291         }
292         testUrl = file.toURI().toURL();
293         if (isJar(testUrl)) {
294           return testUrl;
295         }
296       }
297     } catch (MalformedURLException e) {
298       log.warn("Invalid JAR URL: " + jarUrl);
299     }
300 
301     if (log.isDebugEnabled()) {
302       log.debug("Not a JAR: " + jarUrl);
303     }
304     return null;
305   }
306 
307   /**
308    * Converts a Java package name to a path that can be looked up with a call to
309    * {@link ClassLoader#getResources(String)}.
310    *
311    * @param packageName
312    *          The Java package name to convert to a path
313    *
314    * @return the package path
315    */
316   protected String getPackagePath(String packageName) {
317     return packageName == null ? null : packageName.replace('.', '/');
318   }
319 
320   /**
321    * Returns true if the resource located at the given URL is a JAR file.
322    *
323    * @param url
324    *          The URL of the resource to test.
325    *
326    * @return true, if is jar
327    */
328   protected boolean isJar(URL url) {
329     return isJar(url, new byte[JAR_MAGIC.length]);
330   }
331 
332   /**
333    * Returns true if the resource located at the given URL is a JAR file.
334    *
335    * @param url
336    *          The URL of the resource to test.
337    * @param buffer
338    *          A buffer into which the first few bytes of the resource are read. The buffer must be at least the size of
339    *          {@link #JAR_MAGIC}. (The same buffer may be reused for multiple calls as an optimization.)
340    *
341    * @return true, if is jar
342    */
343   protected boolean isJar(URL url, byte[] buffer) {
344     try (InputStream is = url.openStream()) {
345       is.read(buffer, 0, JAR_MAGIC.length);
346       if (Arrays.equals(buffer, JAR_MAGIC)) {
347         if (log.isDebugEnabled()) {
348           log.debug("Found JAR: " + url);
349         }
350         return true;
351       }
352     } catch (Exception e) {
353       // Failure to read the stream means this is not a JAR
354     }
355 
356     return false;
357   }
358 }