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