001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements. See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache license, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License. You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the license for the specific language governing permissions and
015 * limitations under the license.
016 */
017package org.apache.logging.log4j.core.config.plugins.util;
018
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.IOException;
022import java.io.UnsupportedEncodingException;
023import java.net.URI;
024import java.net.URISyntaxException;
025import java.net.URL;
026import java.net.URLDecoder;
027import java.nio.charset.StandardCharsets;
028import java.util.Arrays;
029import java.util.Collection;
030import java.util.Enumeration;
031import java.util.HashSet;
032import java.util.List;
033import java.util.Set;
034import java.util.jar.JarEntry;
035import java.util.jar.JarInputStream;
036
037import org.apache.logging.log4j.Logger;
038import org.apache.logging.log4j.core.util.Loader;
039import org.apache.logging.log4j.status.StatusLogger;
040import org.osgi.framework.FrameworkUtil;
041import org.osgi.framework.wiring.BundleWiring;
042
043/**
044 * <p>
045 * ResolverUtil is used to locate classes that are available in the/a class path and meet arbitrary conditions. The two
046 * most common conditions are that a class implements/extends another class, or that is it annotated with a specific
047 * annotation. However, through the use of the {@link Test} class it is possible to search using arbitrary conditions.
048 * </p>
049 *
050 * <p>
051 * A ClassLoader is used to locate all locations (directories and jar files) in the class path that contain classes
052 * within certain packages, and then to load those classes and check them. By default the ClassLoader returned by
053 * {@code Thread.currentThread().getContextClassLoader()} is used, but this can be overridden by calling
054 * {@link #setClassLoader(ClassLoader)} prior to invoking any of the {@code find()} methods.
055 * </p>
056 *
057 * <p>
058 * General searches are initiated by calling the {@link #find(ResolverUtil.Test, String...)} method and supplying a
059 * package name and a Test instance. This will cause the named package <b>and all sub-packages</b> to be scanned for
060 * classes that meet the test. There are also utility methods for the common use cases of scanning multiple packages for
061 * extensions of particular classes, or classes annotated with a specific annotation.
062 * </p>
063 *
064 * <p>
065 * The standard usage pattern for the ResolverUtil class is as follows:
066 * </p>
067 *
068 * <pre>
069 * ResolverUtil resolver = new ResolverUtil();
070 * resolver.findInPackage(new CustomTest(), pkg1);
071 * resolver.find(new CustomTest(), pkg1);
072 * resolver.find(new CustomTest(), pkg1, pkg2);
073 * Set&lt;Class&lt;?&gt;&gt; beans = resolver.getClasses();
074 * </pre>
075 *
076 * <p>
077 * This class was copied and modified from Stripes - http://stripes.mc4j.org/confluence/display/stripes/Home
078 * </p>
079 */
080public class ResolverUtil {
081    /** An instance of Log to use for logging in this class. */
082    private static final Logger LOGGER = StatusLogger.getLogger();
083
084    private static final String VFSZIP = "vfszip";
085
086    private static final String VFS = "vfs";
087
088    private static final String BUNDLE_RESOURCE = "bundleresource";
089
090    /** The set of matches being accumulated. */
091    private final Set<Class<?>> classMatches = new HashSet<>();
092
093    /** The set of matches being accumulated. */
094    private final Set<URI> resourceMatches = new HashSet<>();
095
096    /**
097     * The ClassLoader to use when looking for classes. If null then the ClassLoader returned by
098     * Thread.currentThread().getContextClassLoader() will be used.
099     */
100    private ClassLoader classloader;
101
102    /**
103     * Provides access to the classes discovered so far. If no calls have been made to any of the {@code find()}
104     * methods, this set will be empty.
105     *
106     * @return the set of classes that have been discovered.
107     */
108    public Set<Class<?>> getClasses() {
109        return classMatches;
110    }
111
112    /**
113     * Returns the matching resources.
114     * 
115     * @return A Set of URIs that match the criteria.
116     */
117    public Set<URI> getResources() {
118        return resourceMatches;
119    }
120
121    /**
122     * Returns the ClassLoader that will be used for scanning for classes. If no explicit ClassLoader has been set by
123     * the calling, the context class loader will be used.
124     *
125     * @return the ClassLoader that will be used to scan for classes
126     */
127    public ClassLoader getClassLoader() {
128        return classloader != null ? classloader : (classloader = Loader.getClassLoader(ResolverUtil.class, null));
129    }
130
131    /**
132     * Sets an explicit ClassLoader that should be used when scanning for classes. If none is set then the context
133     * ClassLoader will be used.
134     *
135     * @param aClassloader
136     *        a ClassLoader to use when scanning for classes
137     */
138    public void setClassLoader(final ClassLoader aClassloader) {
139        this.classloader = aClassloader;
140    }
141
142    /**
143     * Attempts to discover classes that pass the test. Accumulated classes can be accessed by calling
144     * {@link #getClasses()}.
145     *
146     * @param test
147     *        the test to determine matching classes
148     * @param packageNames
149     *        one or more package names to scan (including subpackages) for classes
150     */
151    public void find(final Test test, final String... packageNames) {
152        if (packageNames == null) {
153            return;
154        }
155
156        for (final String pkg : packageNames) {
157            findInPackage(test, pkg);
158        }
159    }
160
161    /**
162     * Scans for classes starting at the package provided and descending into subpackages. Each class is offered up to
163     * the Test as it is discovered, and if the Test returns true the class is retained. Accumulated classes can be
164     * fetched by calling {@link #getClasses()}.
165     *
166     * @param test
167     *        an instance of {@link Test} that will be used to filter classes
168     * @param packageName
169     *        the name of the package from which to start scanning for classes, e.g. {@code net.sourceforge.stripes}
170     */
171    public void findInPackage(final Test test, String packageName) {
172        packageName = packageName.replace('.', '/');
173        final ClassLoader loader = getClassLoader();
174        Enumeration<URL> urls;
175
176        try {
177            urls = loader.getResources(packageName);
178        } catch (final IOException ioe) {
179            LOGGER.warn("Could not read package: {}", packageName, ioe);
180            return;
181        }
182
183        while (urls.hasMoreElements()) {
184            try {
185                final URL url = urls.nextElement();
186                final String urlPath = extractPath(url);
187
188                LOGGER.info("Scanning for classes in '{}' matching criteria {}", urlPath , test);
189                // Check for a jar in a war in JBoss
190                if (VFSZIP.equals(url.getProtocol())) {
191                    final String path = urlPath.substring(0, urlPath.length() - packageName.length() - 2);
192                    final URL newURL = new URL(url.getProtocol(), url.getHost(), path);
193                    @SuppressWarnings("resource")
194                    final JarInputStream stream = new JarInputStream(newURL.openStream());
195                    try {
196                        loadImplementationsInJar(test, packageName, path, stream);
197                    } finally {
198                        close(stream, newURL);
199                    }
200                } else if (VFS.equals(url.getProtocol())) {
201                    final String containerPath = urlPath.substring(1,
202                                                  urlPath.length() - packageName.length() - 2);
203                    final File containerFile = new File(containerPath);
204                    if (containerFile.isDirectory()) {
205                        loadImplementationsInDirectory(test, packageName, new File(containerFile, packageName));
206                    } else {
207                        loadImplementationsInJar(test, packageName, containerFile);
208                    }
209                } else if (BUNDLE_RESOURCE.equals(url.getProtocol())) {
210                    loadImplementationsInBundle(test, packageName);
211                } else {
212                    final File file = new File(urlPath);
213                    if (file.isDirectory()) {
214                        loadImplementationsInDirectory(test, packageName, file);
215                    } else {
216                        loadImplementationsInJar(test, packageName, file);
217                    }
218                }
219            } catch (final IOException | URISyntaxException ioe) {
220                LOGGER.warn("Could not read entries", ioe);
221            }
222        }
223    }
224
225    String extractPath(final URL url) throws UnsupportedEncodingException, URISyntaxException {
226        String urlPath = url.getPath(); // same as getFile but without the Query portion
227        // System.out.println(url.getProtocol() + "->" + urlPath);
228
229        // I would be surprised if URL.getPath() ever starts with "jar:" but no harm in checking
230        if (urlPath.startsWith("jar:")) {
231            urlPath = urlPath.substring(4);
232        }
233        // For jar: URLs, the path part starts with "file:"
234        if (urlPath.startsWith("file:")) {
235            urlPath = urlPath.substring(5);
236        }
237        // If it was in a JAR, grab the path to the jar
238        final int bangIndex = urlPath.indexOf('!');
239        if (bangIndex > 0) {
240            urlPath = urlPath.substring(0, bangIndex);
241        }
242
243        // LOG4J2-445
244        // Finally, decide whether to URL-decode the file name or not...
245        final String protocol = url.getProtocol();
246        final List<String> neverDecode = Arrays.asList(VFS, VFSZIP, BUNDLE_RESOURCE);
247        if (neverDecode.contains(protocol)) {
248            return urlPath;
249        }
250        final String cleanPath = new URI(urlPath).getPath();
251        if (new File(cleanPath).exists()) {
252            // if URL-encoded file exists, don't decode it
253            return cleanPath;
254        }
255        return URLDecoder.decode(urlPath, StandardCharsets.UTF_8.name());
256    }
257
258    private void loadImplementationsInBundle(final Test test, final String packageName) {
259        final BundleWiring wiring = FrameworkUtil.getBundle(ResolverUtil.class).adapt(BundleWiring.class);
260        final Collection<String> list = wiring.listResources(packageName, "*.class",
261                BundleWiring.LISTRESOURCES_RECURSE);
262        for (final String name : list) {
263            addIfMatching(test, name);
264        }
265    }
266
267    /**
268     * Finds matches in a physical directory on a file system. Examines all files within a directory - if the File object
269     * is not a directory, and ends with <i>.class</i> the file is loaded and tested to see if it is acceptable
270     * according to the Test. Operates recursively to find classes within a folder structure matching the package
271     * structure.
272     *
273     * @param test
274     *        a Test used to filter the classes that are discovered
275     * @param parent
276     *        the package name up to this directory in the package hierarchy. E.g. if /classes is in the classpath and
277     *        we wish to examine files in /classes/org/apache then the values of <i>parent</i> would be
278     *        <i>org/apache</i>
279     * @param location
280     *        a File object representing a directory
281     */
282    private void loadImplementationsInDirectory(final Test test, final String parent, final File location) {
283        final File[] files = location.listFiles();
284        if (files == null) {
285            return;
286        }
287
288        StringBuilder builder;
289        for (final File file : files) {
290            builder = new StringBuilder();
291            builder.append(parent).append('/').append(file.getName());
292            final String packageOrClass = parent == null ? file.getName() : builder.toString();
293
294            if (file.isDirectory()) {
295                loadImplementationsInDirectory(test, packageOrClass, file);
296            } else if (isTestApplicable(test, file.getName())) {
297                addIfMatching(test, packageOrClass);
298            }
299        }
300    }
301
302    private boolean isTestApplicable(final Test test, final String path) {
303        return test.doesMatchResource() || path.endsWith(".class") && test.doesMatchClass();
304    }
305
306    /**
307     * Finds matching classes within a jar files that contains a folder structure matching the package structure. If the
308     * File is not a JarFile or does not exist a warning will be logged, but no error will be raised.
309     *
310     * @param test
311     *        a Test used to filter the classes that are discovered
312     * @param parent
313     *        the parent package under which classes must be in order to be considered
314     * @param jarFile
315     *        the jar file to be examined for classes
316     */
317    private void loadImplementationsInJar(final Test test, final String parent, final File jarFile) {
318        JarInputStream jarStream = null;
319        try {
320            jarStream = new JarInputStream(new FileInputStream(jarFile));
321            loadImplementationsInJar(test, parent, jarFile.getPath(), jarStream);
322        } catch (final IOException ex) {
323            LOGGER.error("Could not search JAR file '{}' for classes matching criteria {}, file not found", jarFile,
324                    test, ex);
325        } finally {
326            close(jarStream, jarFile);
327        }
328    }
329
330    /**
331     * @param jarStream
332     * @param source
333     */
334    private void close(final JarInputStream jarStream, final Object source) {
335        if (jarStream != null) {
336            try {
337                jarStream.close();
338            } catch (final IOException e) {
339                LOGGER.error("Error closing JAR file stream for {}", source, e);
340            }
341        }
342    }
343
344    /**
345     * Finds matching classes within a jar files that contains a folder structure matching the package structure. If the
346     * File is not a JarFile or does not exist a warning will be logged, but no error will be raised.
347     *
348     * @param test
349     *        a Test used to filter the classes that are discovered
350     * @param parent
351     *        the parent package under which classes must be in order to be considered
352     * @param stream
353     *        The jar InputStream
354     */
355    private void loadImplementationsInJar(final Test test, final String parent, final String path,
356            final JarInputStream stream) {
357
358        try {
359            JarEntry entry;
360
361            while ((entry = stream.getNextJarEntry()) != null) {
362                final String name = entry.getName();
363                if (!entry.isDirectory() && name.startsWith(parent) && isTestApplicable(test, name)) {
364                    addIfMatching(test, name);
365                }
366            }
367        } catch (final IOException ioe) {
368            LOGGER.error("Could not search JAR file '{}' for classes matching criteria {} due to an IOException", path,
369                    test, ioe);
370        }
371    }
372
373    /**
374     * Add the class designated by the fully qualified class name provided to the set of resolved classes if and only if
375     * it is approved by the Test supplied.
376     *
377     * @param test
378     *        the test used to determine if the class matches
379     * @param fqn
380     *        the fully qualified name of a class
381     */
382    protected void addIfMatching(final Test test, final String fqn) {
383        try {
384            final ClassLoader loader = getClassLoader();
385            if (test.doesMatchClass()) {
386                final String externalName = fqn.substring(0, fqn.indexOf('.')).replace('/', '.');
387                if (LOGGER.isDebugEnabled()) {
388                    LOGGER.debug("Checking to see if class {} matches criteria {}", externalName, test);
389                }
390
391                final Class<?> type = loader.loadClass(externalName);
392                if (test.matches(type)) {
393                    classMatches.add(type);
394                }
395            }
396            if (test.doesMatchResource()) {
397                URL url = loader.getResource(fqn);
398                if (url == null) {
399                    url = loader.getResource(fqn.substring(1));
400                }
401                if (url != null && test.matches(url.toURI())) {
402                    resourceMatches.add(url.toURI());
403                }
404            }
405        } catch (final Throwable t) {
406            LOGGER.warn("Could not examine class {}", fqn, t);
407        }
408    }
409
410    /**
411     * A simple interface that specifies how to test classes to determine if they are to be included in the results
412     * produced by the ResolverUtil.
413     */
414    public interface Test {
415        /**
416         * Will be called repeatedly with candidate classes. Must return True if a class is to be included in the
417         * results, false otherwise.
418         * 
419         * @param type
420         *        The Class to match against.
421         * @return true if the Class matches.
422         */
423        boolean matches(Class<?> type);
424
425        /**
426         * Test for a resource.
427         * 
428         * @param resource
429         *        The URI to the resource.
430         * @return true if the resource matches.
431         */
432        boolean matches(URI resource);
433
434        boolean doesMatchClass();
435
436        boolean doesMatchResource();
437    }
438
439}