/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package freemarker.ext.jsp;

import java.beans.IntrospectionException;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FilenameFilter;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.JarURLConnection;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Stack;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipInputStream;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.jsp.tagext.Tag;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParserFactory;

import org.xml.sax.Attributes;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;

import freemarker.core.BugException;
import freemarker.core.Environment;
import freemarker.ext.beans.BeansWrapper;
import freemarker.ext.servlet.FreemarkerServlet;
import freemarker.ext.servlet.HttpRequestHashModel;
import freemarker.log.Logger;
import freemarker.template.DefaultObjectWrapper;
import freemarker.template.ObjectWrapper;
import freemarker.template.TemplateHashModel;
import freemarker.template.TemplateMethodModelEx;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
import freemarker.template.TemplateTransformModel;
import freemarker.template.utility.ClassUtil;
import freemarker.template.utility.NullArgumentException;
import freemarker.template.utility.SecurityUtilities;
import freemarker.template.utility.StringUtil;

/**
 * A hash model associated with a servlet context that can load JSP tag libraries associated with that servlet context.
 * An instance of this class is made available in the root data model of templates executed by
 * {@link freemarker.ext.servlet.FreemarkerServlet} under key {@code JspTaglibs}. It can be added to custom servlets as
 * well to enable JSP taglib integration in them as well.
 */
public class TaglibFactory implements TemplateHashModel {

    /**
     * The default of {@link #getClasspathTlds()}; an empty list.
     * 
     * @since 2.3.22
     */
    public static final List DEFAULT_CLASSPATH_TLDS = Collections.EMPTY_LIST;
    
    /**
     * The default of {@link #getMetaInfTldSources()}; a list that contains
     * {@link WebInfPerLibJarMetaInfTldSource#INSTANCE}, which gives the behavior described in the JSP 2.2
     * specification.
     * 
     * @since 2.3.22
     */
    public static final List/*<? extends MetaInfTldSource>*/ DEFAULT_META_INF_TLD_SOURCES
            = Collections.singletonList(WebInfPerLibJarMetaInfTldSource.INSTANCE);

    private static final Logger LOG = Logger.getLogger("freemarker.jsp");

    private static final int URL_TYPE_FULL = 0;
    private static final int URL_TYPE_ABSOLUTE = 1;
    private static final int URL_TYPE_RELATIVE = 2;

    private static final String META_INF_REL_PATH = "META-INF/";
    private static final String META_INF_ABS_PATH = "/META-INF/";
    private static final String DEFAULT_TLD_RESOURCE_PATH = META_INF_ABS_PATH + "taglib.tld";
    private static final String JAR_URL_ENTRY_PATH_START = "!/";

    private static final String PLATFORM_FILE_ENCODING = SecurityUtilities.getSystemProperty("file.encoding", "utf-8");

    private final ServletContext servletContext;

    private ObjectWrapper objectWrapper;
    private List/*<MetaInfTldSource>*/ metaInfTldSources = DEFAULT_META_INF_TLD_SOURCES;
    private List/*<String>*/ classpathTlds = DEFAULT_CLASSPATH_TLDS;
    
    boolean test_emulateNoUrlToFileConversions = false;
    boolean test_emulateNoJarURLConnections = false;
    boolean test_emulateJarEntryUrlOpenStreamFails = false;    

    private final Object lock = new Object(); 
    private final Map taglibs = new HashMap();
    private final Map tldLocations = new HashMap();
    private List/*<String>*/ failedTldLocations = new ArrayList();
    private int nextTldLocationLookupPhase = 0;

    /**
    /**
     * Creates a new JSP taglib factory that will be used to load JSP tag libraries and functions for the web
     * application represented by the passed in {@link ServletContext}.
     * You should at least call {@link #setObjectWrapper(ObjectWrapper)} before start using this object.
     * 
     * <p>This object is only thread-safe after you have stopped calling its setter methods (and it was properly
     * published to the other threads; see JSR 133 (Java Memory Model)).
     * 
     * @param ctx
     *            The servlet context whose JSP tag libraries this factory will load.
     */
    public TaglibFactory(ServletContext ctx) {
        this.servletContext = ctx;
    }

    /**
     * Retrieves a JSP tag library identified by an URI. The matching of the URI to a JSP taglib is done as described in
     * the JSP 1.2 FCS specification.
     * 
     * @param taglibUri
     *            The URI used in templates to refer to the taglib (like {@code <%@ taglib uri="..." ... %>} in
     *            JSP). It can be any of the three forms allowed by the JSP specification: absolute URI (like
     *            {@code http://example.com/foo}), root relative URI (like {@code /bar/foo.tld}) and non-root relative
     *            URI (like {@code bar/foo.tld}). Note that if a non-root relative URI is used it's resolved relative to
     *            the URL of the current request. In this case, the current request is obtained by looking up a
     *            {@link HttpRequestHashModel} object named <tt>Request</tt> in the root data model.
     *            {@link FreemarkerServlet} provides this object under the expected name, and custom servlets that want
     *            to integrate JSP taglib support should do the same.
     * 
     * @return a {@link TemplateHashModel} representing the JSP taglib. Each element of this hash represents a single
     *         custom tag or EL function from the library, implemented as a {@link TemplateTransformModel} or
     *         {@link TemplateMethodModelEx}, respectively.
     */
    @Override
    public TemplateModel get(final String taglibUri) throws TemplateModelException {
        synchronized (lock) {
            {
                final Taglib taglib = (Taglib) taglibs.get(taglibUri);
                if (taglib != null) {
                    return taglib;
                }
            }

            boolean failedTldListAlreadyIncluded = false;
            final TldLocation tldLocation;
            final String normalizedTaglibUri;
            try {
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Locating TLD for taglib URI " + StringUtil.jQuoteNoXSS(taglibUri) + ".");
                }
                
                TldLocation explicitlyMappedTldLocation = getExplicitlyMappedTldLocation(taglibUri);
                if (explicitlyMappedTldLocation != null) {
                    tldLocation = explicitlyMappedTldLocation;
                    normalizedTaglibUri = taglibUri;
                } else {
                    // Taglib URI must be directly the path (no mapping).
                    
                    final int urlType;
                    try {
                        urlType = getUriType(taglibUri);
                    } catch (MalformedURLException e) {
                        throw new TaglibGettingException("Malformed taglib URI: " + StringUtil.jQuote(taglibUri), e);
                    }
                    if (urlType == URL_TYPE_RELATIVE) {
                        normalizedTaglibUri = resolveRelativeUri(taglibUri);
                    } else if (urlType == URL_TYPE_ABSOLUTE) {
                        normalizedTaglibUri = taglibUri;
                    } else if (urlType == URL_TYPE_FULL) {
                        // Per spec., full URI-s can only be resolved through explicit mapping
                        String failedTLDsList = getFailedTLDsList();
                        failedTldListAlreadyIncluded = true;
                        throw new TaglibGettingException("No TLD was found for the "
                                + StringUtil.jQuoteNoXSS(taglibUri) + " JSP taglib URI. (TLD-s are searched according "
                                + "the JSP 2.2 specification. In development- and embedded-servlet-container "
                                + "setups you may also need the "
                                + "\"" + FreemarkerServlet.INIT_PARAM_META_INF_TLD_LOCATIONS + "\" and "
                                + "\"" + FreemarkerServlet.INIT_PARAM_CLASSPATH_TLDS + "\" "
                                + FreemarkerServlet.class.getName() + " init-params or the similar system "
                                + "properites."
                                + (failedTLDsList == null
                                        ? ""
                                        : " Also note these TLD-s were skipped earlier due to errors; "
                                                + "see error in the log: " + failedTLDsList
                                ) + ")");
                    } else {
                        throw new BugException();
                    }

                    if (!normalizedTaglibUri.equals(taglibUri)) {
                        final Taglib taglib = (Taglib) taglibs.get(normalizedTaglibUri);
                        if (taglib != null) {
                            return taglib;
                        }
                    }

                    tldLocation = isJarPath(normalizedTaglibUri)
                                ? new ServletContextJarEntryTldLocation(
                                        normalizedTaglibUri, DEFAULT_TLD_RESOURCE_PATH)
                                : new ServletContextTldLocation(normalizedTaglibUri);
                }
            } catch (Exception e) {
                String failedTLDsList = failedTldListAlreadyIncluded ? null : getFailedTLDsList();
                throw new TemplateModelException(
                        "Error while looking for TLD file for " + StringUtil.jQuoteNoXSS(taglibUri)
                        + "; see cause exception."
                        + (failedTLDsList == null
                                ? ""
                                : " (Note: These TLD-s were skipped earlier due to errors; "
                                + "see errors in the log: " + failedTLDsList + ")"),
                        e);
            }

            try {
                return loadTaglib(tldLocation, normalizedTaglibUri);
            } catch (Exception e) {
                throw new TemplateModelException("Error while loading tag library for URI "
                        + StringUtil.jQuoteNoXSS(normalizedTaglibUri) + " from TLD location "
                        + StringUtil.jQuoteNoXSS(tldLocation) + "; see cause exception.",
                        e);
            }
        }
    }

    /**
     * Returns the joined list of failed TLD-s, or {@code null} if there was none.
     */
    private String getFailedTLDsList() {
        synchronized (failedTldLocations) {
            if (failedTldLocations.isEmpty()) {
                return null;
            }
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < failedTldLocations.size(); i++) {
                if (i != 0) {
                    sb.append(", ");
                }
                sb.append(StringUtil.jQuote(failedTldLocations.get(i)));
            }
            return sb.toString();
        }
    }

    /**
     * Returns false.
     */
    @Override
    public boolean isEmpty() {
        return false;
    }
    
    /**
     * See {@link #setObjectWrapper(ObjectWrapper)}.
     * 
     * @since 2.3.22
     */
    public ObjectWrapper getObjectWrapper() {
        return objectWrapper;
    }

    /**
     * Sets the {@link ObjectWrapper} used when building the JSP tag library {@link TemplateHashModel}-s from the TLD-s.
     * Usually, it should be the same {@link ObjectWrapper} that will be used inside the templates. {@code null} value
     * is only supported for backward compatibility. For custom EL functions to be exposed, it must be non-{@code null}
     * and an {@code intanceof} {@link BeansWrapper} (like typically, a {@link DefaultObjectWrapper}).
     * 
     * @since 2.3.22
     */
    public void setObjectWrapper(ObjectWrapper objectWrapper) {
        checkNotStarted();
        this.objectWrapper = objectWrapper;
    }

    /**
     * See {@link #setMetaInfTldSources(List)}.
     * 
     * @since 2.3.22
     */
    public List/*<Pattern>*/ getMetaInfTldSources() {
        return metaInfTldSources;
    }

    /**
     * Sets the list of places where we will look for {@code META-INF/**}{@code /*.tld} files. By default this is a list
     * that only contains {@link WebInfPerLibJarMetaInfTldSource#INSTANCE}. This corresponds to the behavior that the
     * JSP specification describes. See the {@link MetaInfTldSource} subclasses for the possible values and their
     * meanings.
     * 
     * <p>
     * This is usually set via the init-params of {@link FreemarkerServlet}.
     * 
     * @param metaInfTldSources
     *            The list of {@link MetaInfTldSource} subclass instances. Their order matters if multiple TLD-s define
     *            a taglib with the same {@code taglib-uri}. In that case, the one found by the earlier
     *            {@link MetaInfTldSource} wins.
     * 
     * @see #setClasspathTlds(List)
     * 
     * @since 2.3.22
     */
    public void setMetaInfTldSources(List/*<? extends MetaInfTldSource>*/ metaInfTldSources) {
        checkNotStarted();
        NullArgumentException.check("metaInfTldSources", metaInfTldSources);
        this.metaInfTldSources = metaInfTldSources;
    }

    /**
     * See {@link #setClasspathTlds(List)}.
     * 
     * @since 2.3.22
     */
    public List/*<String>*/ getClasspathTlds() {
        return classpathTlds;
    }

    /**
     * Sets the class-loader resource paths of the TLD-s that aren't inside the locations covered by
     * {@link #setMetaInfTldSources(List)}, yet you want them to be discovered. They will be loaded with the class
     * loader provided by the servlet container.
     * 
     * <p>
     * This is usually set via the init-params of {@link FreemarkerServlet}. Otherwise it defaults to an empty list.
     * 
     * @param classpathTlds
     *            List of {@code String}-s, maybe {@code null}. Each item is a resource path, like
     *            {@code "/META-INF/my.tld"}. (Relative resource paths will be interpreted as root-relative.)
     * 
     * @see #setMetaInfTldSources(List)
     * 
     * @since 2.3.22
     */
    public void setClasspathTlds(List/*<String>*/ classpathTlds) {
        checkNotStarted();
        NullArgumentException.check("classpathTlds", classpathTlds);
        this.classpathTlds = classpathTlds;
    }

    private void checkNotStarted() {
        synchronized (lock) {
            if (nextTldLocationLookupPhase != 0) {
                throw new IllegalStateException(TaglibFactory.class.getName() + " object was already in use.");
            }
        }
    }

    private TldLocation getExplicitlyMappedTldLocation(final String uri) throws SAXException, IOException,
            TaglibGettingException {
        while (true) {
            final TldLocation tldLocation = (TldLocation) tldLocations.get(uri);
            if (tldLocation != null) {
                return tldLocation;
            }

            switch (nextTldLocationLookupPhase) {
            case 0:
                // Not in JSP spec.
                addTldLocationsFromClasspathTlds();
                break;
            case 1:
                // JSP 2.2 spec / JSP.7.3.3 (also JSP.3.2)
                addTldLocationsFromWebXml();
                break;
            case 2:
                // JSP 2.2 spec / JSP.7.3.4, FM-specific TLD processing order #1
                addTldLocationsFromWebInfTlds();
                break;
            case 3:
                // JSP 2.2 spec / JSP.7.3.4, FM-specific TLD processing order #2
                addTldLocationsFromMetaInfTlds();
                break;
            case 4:
                return null;
            default:
                throw new BugException();
            }
            nextTldLocationLookupPhase++;
        }
    }

    private void addTldLocationsFromWebXml() throws SAXException, IOException {
        LOG.debug("Looking for TLD locations in servletContext:/WEB-INF/web.xml");

        WebXmlParser webXmlParser = new WebXmlParser();
        InputStream in = servletContext.getResourceAsStream("/WEB-INF/web.xml");
        if (in == null) {
            LOG.debug("No web.xml was found in servlet context");
            return;
        }
        try {
            parseXml(in, servletContext.getResource("/WEB-INF/web.xml").toExternalForm(), webXmlParser);
        } finally {
            in.close();
        }
    }

    private void addTldLocationsFromWebInfTlds()
            throws IOException, SAXException {
        LOG.debug("Looking for TLD locations in servletContext:/WEB-INF/**/*.tld");
        addTldLocationsFromServletContextResourceTlds("/WEB-INF");
    }

    private void addTldLocationsFromServletContextResourceTlds(String basePath)
            throws IOException, SAXException {
        Set unsortedResourcePaths = servletContext.getResourcePaths(basePath);
        if (unsortedResourcePaths != null) {
            List/*<String>*/ resourcePaths = new ArrayList/*<String>*/(unsortedResourcePaths);
            Collections.sort(resourcePaths);
            // First process the files...
            for (Iterator it = resourcePaths.iterator(); it.hasNext(); ) {
                String resourcePath = (String) it.next();
                if (resourcePath.endsWith(".tld")) {
                    addTldLocationFromTld(new ServletContextTldLocation(resourcePath));
                }
            }
            // ... only later the directories
            for (Iterator it = resourcePaths.iterator(); it.hasNext(); ) {
                String resourcePath = (String) it.next();
                if (resourcePath.endsWith("/")) {
                    addTldLocationsFromServletContextResourceTlds(resourcePath);
                }
            }
        }
    }
    
    private void addTldLocationsFromMetaInfTlds() throws IOException, SAXException {
        if (metaInfTldSources == null || metaInfTldSources.isEmpty()) {
            return;
        }

        Set/*<URLWithExternalForm>*/ cpMetaInfDirUrlsWithEF = null;
        
        // Skip past the last "clear":
        int srcIdxStart = 0;
        for (int i = metaInfTldSources.size() - 1; i >= 0; i--) {
            if (metaInfTldSources.get(i) instanceof ClearMetaInfTldSource) {
                srcIdxStart = i + 1;
                break;
            }
        }
        
        for (int srcIdx = srcIdxStart; srcIdx < metaInfTldSources.size(); srcIdx++) {
            MetaInfTldSource miTldSource = (MetaInfTldSource) metaInfTldSources.get(srcIdx);
            
            if (miTldSource == WebInfPerLibJarMetaInfTldSource.INSTANCE) {
                addTldLocationsFromWebInfPerLibJarMetaInfTlds();
            } else if (miTldSource instanceof ClasspathMetaInfTldSource) {
                ClasspathMetaInfTldSource cpMiTldLocation = (ClasspathMetaInfTldSource) miTldSource;
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Looking for TLD-s in "
                            + "classpathRoots[" + cpMiTldLocation.getRootContainerPattern() + "]"
                            + META_INF_ABS_PATH + "**/*.tld");
                }
                
                if (cpMetaInfDirUrlsWithEF == null) {
                    cpMetaInfDirUrlsWithEF = collectMetaInfUrlsFromClassLoaders();
                }

                for (Iterator iterator = cpMetaInfDirUrlsWithEF.iterator(); iterator.hasNext(); ) {
                    URLWithExternalForm urlWithEF = (URLWithExternalForm) iterator.next();
                    final URL url = urlWithEF.getUrl();
                    final boolean isJarUrl = isJarUrl(url);
                    final String urlEF = urlWithEF.externalForm;
                    
                    final String rootContainerUrl;
                    if (isJarUrl) {
                        int sep = urlEF.indexOf(JAR_URL_ENTRY_PATH_START);
                        rootContainerUrl = sep != -1 ? urlEF.substring(0, sep) : urlEF;
                    } else {
                        rootContainerUrl = urlEF.endsWith(META_INF_ABS_PATH)
                                ? urlEF.substring(0, urlEF.length() - META_INF_REL_PATH.length())
                                : urlEF;
                    }
                    
                    if (cpMiTldLocation.getRootContainerPattern().matcher(rootContainerUrl).matches()) {
                        final File urlAsFile = urlToFileOrNull(url);
                        if (urlAsFile != null) {
                            addTldLocationsFromFileDirectory(urlAsFile);
                        } else if (isJarUrl) {
                            addTldLocationsFromJarDirectoryEntryURL(url);
                        } else {
                            if (LOG.isDebugEnabled()) {
                                LOG.debug("Can't list entries under this URL; TLD-s won't be discovered here: "
                                        + urlWithEF.getExternalForm());
                            }
                        }
                    }
                }
            } else {
                throw new BugException();
            }
        }
    }
    
    private void addTldLocationsFromWebInfPerLibJarMetaInfTlds() throws IOException, SAXException {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Looking for TLD locations in servletContext:/WEB-INF/lib/*.{jar,zip}" + META_INF_ABS_PATH
                    + "*.tld");
        }

        Set libEntPaths = servletContext.getResourcePaths("/WEB-INF/lib");
        if (libEntPaths != null) {
            for (Iterator iter = libEntPaths.iterator(); iter.hasNext(); ) {
                final String libEntryPath = (String) iter.next();
                if (isJarPath(libEntryPath)) {
                    addTldLocationsFromServletContextJar(libEntryPath);
                }
            }
        }
    }

    private void addTldLocationsFromClasspathTlds() throws SAXException, IOException, TaglibGettingException {
        if (classpathTlds == null || classpathTlds.size() == 0) {
            return;
        }
        
        LOG.debug("Looking for TLD locations in TLD-s specified in cfg.classpathTlds");
        
        for (Iterator it = classpathTlds.iterator(); it.hasNext(); ) {
            String tldResourcePath = (String) it.next();
            if (tldResourcePath.trim().length() == 0) {
                throw new TaglibGettingException("classpathTlds can't contain empty item"); 
            }
            
            if (!tldResourcePath.startsWith("/")) {
                tldResourcePath = "/" + tldResourcePath;
            }
            if (tldResourcePath.endsWith("/")) {
                throw new TaglibGettingException("classpathTlds can't specify a directory: " + tldResourcePath); 
            }
            
            ClasspathTldLocation tldLocation = new ClasspathTldLocation(tldResourcePath);
            InputStream in;
            try {
                in = tldLocation.getInputStream();
            } catch (IOException e) {
                if (LOG.isWarnEnabled()) {
                    LOG.warn("Ignored classpath TLD location " + StringUtil.jQuoteNoXSS(tldResourcePath)
                            + " because of error", e);
                }
                in = null;
            }
            if (in != null) {
                try {
                    addTldLocationFromTld(in, tldLocation);
                } finally {
                    in.close();
                }
            }
        }
    }

    /**
     * Finds and processes *.tld inside a jar in the servet context.
     */
    private void addTldLocationsFromServletContextJar(
            final String jarResourcePath)
            throws IOException, MalformedURLException, SAXException {
        final String metaInfEntryPath = normalizeJarEntryPath(META_INF_ABS_PATH, true);
        
        // Null for non-random-access backing resource:
        final JarFile jarFile = servletContextResourceToFileOrNull(jarResourcePath);
        if (jarFile != null) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Scanning for " + META_INF_ABS_PATH + "*.tld-s in JarFile: servletContext:"
                        + jarResourcePath);
            }
            for (Enumeration/*<JarEntry>*/ entries = jarFile.entries(); entries.hasMoreElements(); ) {
                final JarEntry curEntry = (JarEntry) entries.nextElement();
                final String curEntryPath = normalizeJarEntryPath(curEntry.getName(), false);
                if (curEntryPath.startsWith(metaInfEntryPath) && curEntryPath.endsWith(".tld")) {
                    addTldLocationFromTld(new ServletContextJarEntryTldLocation(jarResourcePath, curEntryPath));
                }
            }
        } else {  // jarFile == null => fall back to streamed access
            if (LOG.isDebugEnabled()) {
                LOG.debug("Scanning for " + META_INF_ABS_PATH
                        + "*.tld-s in ZipInputStream (slow): servletContext:" + jarResourcePath);
            }
    
            final InputStream in = servletContext.getResourceAsStream(jarResourcePath);
            if (in == null) {
                throw new IOException("ServletContext resource not found: " + jarResourcePath);
            }
            try {
                try (ZipInputStream zipIn = new ZipInputStream(in)) {
                    while (true) {
                        ZipEntry curEntry = zipIn.getNextEntry();
                        if (curEntry == null) break;

                        String curEntryPath = normalizeJarEntryPath(curEntry.getName(), false);
                        if (curEntryPath.startsWith(metaInfEntryPath) && curEntryPath.endsWith(".tld")) {
                            addTldLocationFromTld(zipIn,
                                    new ServletContextJarEntryTldLocation(jarResourcePath, curEntryPath));
                        }
                    }
                }
            } finally {
                in.close();
            }
        }
    }

    /**
     * Finds and processes *.tld inside a directory in a jar.
     * 
     * @param jarBaseEntryUrl
     *            Something like "jar:file:/C:/foo%20bar/baaz.jar!/META-INF/". If this is not a jar(-like) URL, the
     *            behavior is undefined.
     */
    private void addTldLocationsFromJarDirectoryEntryURL(final URL jarBaseEntryUrl)
            throws IOException, MalformedURLException, SAXException {
        // Null for non-random-access backing resource:
        final JarFile jarFile;
        // Not null; the path of the directory *inside* the JAR where we will search
        // (like "/META-INF/" in "jar:file:/C:/foo%20bar/baaz.jar!/META-INF/"):
        final String baseEntryPath;
        // Null when URLConnection is used
        // (like "file:/C:/foo%20bar/baaz.jar" in "jar:file:/C:/foo%20bar/baaz.jar!/META-INF/"):
        final String rawJarContentUrlEF;
        {
            final URLConnection urlCon = jarBaseEntryUrl.openConnection();
            if (!test_emulateNoJarURLConnections && urlCon instanceof JarURLConnection) {
                final JarURLConnection jarCon = (JarURLConnection) urlCon;
                jarFile = jarCon.getJarFile();
                rawJarContentUrlEF = null; // Not used as we have a JarURLConnection
                baseEntryPath = normalizeJarEntryPath(jarCon.getEntryName(), true);
                if (baseEntryPath == null) {
                    throw newFailedToExtractEntryPathException(jarBaseEntryUrl);
                }
            } else {
                final String jarBaseEntryUrlEF = jarBaseEntryUrl.toExternalForm();
                final int jarEntrySepIdx = jarBaseEntryUrlEF.indexOf(JAR_URL_ENTRY_PATH_START);
                if (jarEntrySepIdx == -1) {
                    throw newFailedToExtractEntryPathException(jarBaseEntryUrl);
                }
                rawJarContentUrlEF = jarBaseEntryUrlEF.substring(jarBaseEntryUrlEF.indexOf(':') + 1, jarEntrySepIdx);
                baseEntryPath = normalizeJarEntryPath(
                        jarBaseEntryUrlEF.substring(jarEntrySepIdx + JAR_URL_ENTRY_PATH_START.length()), true);
    
                File rawJarContentAsFile = urlToFileOrNull(new URL(rawJarContentUrlEF));
                jarFile = rawJarContentAsFile != null ? new JarFile(rawJarContentAsFile) : null;
            }
        }
        if (jarFile != null) {  // jarFile == null => fall back to streamed access
            if (LOG.isDebugEnabled()) {
                LOG.debug("Scanning for " + META_INF_ABS_PATH + "**/*.tld-s in random access mode: "
                        + jarBaseEntryUrl);
            }
            for (Enumeration/*<JarEntry>*/ entries = jarFile.entries(); entries.hasMoreElements(); ) {
                final JarEntry curEntry = (JarEntry) entries.nextElement();
                final String curEntryPath = normalizeJarEntryPath(curEntry.getName(), false);
                if (curEntryPath.startsWith(baseEntryPath) && curEntryPath.endsWith(".tld")) {
                    final String curEntryBaseRelativePath = curEntryPath.substring(baseEntryPath.length());
                    final URL tldUrl = createJarEntryUrl(jarBaseEntryUrl, curEntryBaseRelativePath);
                    addTldLocationFromTld(new JarEntryUrlTldLocation(tldUrl, null));
                }
            }
        } else {
            // Not a random-access file, so we fall back to the slower ZipInputStream approach.
            if (LOG.isDebugEnabled()) {
                LOG.debug("Scanning for " + META_INF_ABS_PATH + "**/*.tld-s in stream mode (slow): "
                        + rawJarContentUrlEF);
            }

            try (InputStream in = new URL(rawJarContentUrlEF).openStream()) {
                ZipInputStream zipIn = new ZipInputStream(in);
                try {
                    while (true) {
                        ZipEntry curEntry = zipIn.getNextEntry();
                        if (curEntry == null) break;

                        String curEntryPath = normalizeJarEntryPath(curEntry.getName(), false);
                        if (curEntryPath.startsWith(baseEntryPath) && curEntryPath.endsWith(".tld")) {
                            final String curEntryBaseRelativePath = curEntryPath.substring(baseEntryPath.length());
                            final URL tldUrl = createJarEntryUrl(jarBaseEntryUrl, curEntryBaseRelativePath);
                            addTldLocationFromTld(zipIn, new JarEntryUrlTldLocation(tldUrl, null));
                        }
                    }
                } finally {
                    zipIn.close();
                }
            } catch (ZipException e) {
                // ZipException messages miss the zip URL
                IOException ioe = new IOException("Error reading ZIP (see cause excepetion) from: "
                        + rawJarContentUrlEF);
                try {
                    ioe.initCause(e);
                } catch (Exception e2) {
                    throw e;
                }
                throw ioe;
            }
        }
    }

    private void addTldLocationsFromFileDirectory(final File dir) throws IOException, SAXException {
        if (dir.isDirectory()) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Scanning for *.tld-s in File directory: " + StringUtil.jQuoteNoXSS(dir));
            }
            File[] tldFiles = dir.listFiles(new FilenameFilter() {
    
                @Override
                public boolean accept(File urlAsFile, String name) {
                    return isTldFileNameIgnoreCase(name);
                }
    
            });
            if (tldFiles == null) {
                throw new IOException("Can't list this directory for some reason: " + dir);
            }
            for (int i = 0; i < tldFiles.length; i++) {
                final File file = tldFiles[i];
                addTldLocationFromTld(new FileTldLocation(file));
            }
        } else {
            LOG.warn("Skipped scanning for *.tld for non-existent directory: " + StringUtil.jQuoteNoXSS(dir));
        }
    }
    
    /**
     * Adds the TLD location mapping from the TLD itself.
     */
    private void addTldLocationFromTld(TldLocation tldLocation) throws IOException, SAXException {
        try (InputStream in = tldLocation.getInputStream()) {
            addTldLocationFromTld(in, tldLocation);
        }
    }

    /**
     * Use this overload only if you already have the {@link InputStream} for some reason, otherwise use
     * {@link #addTldLocationFromTld(TldLocation)}. 
     * 
     * @param reusedIn
     *            The stream that we already had (so we don't have to open a new one from the {@code tldLocation}).
     */
    private void addTldLocationFromTld(InputStream reusedIn, TldLocation tldLocation) throws SAXException,
            IOException {
        String taglibUri;
        try {
            taglibUri = getTaglibUriFromTld(reusedIn, tldLocation.getXmlSystemId());
        } catch (SAXException e) {
            LOG.error("Error while parsing TLD; skipping: " + tldLocation, e);
            synchronized (failedTldLocations) {
                failedTldLocations.add(tldLocation.toString());
            }
            taglibUri = null;
        }
        if (taglibUri != null) {
                addTldLocation(tldLocation, taglibUri);
        }
    }

    private void addTldLocation(TldLocation tldLocation, String taglibUri) {
        if (tldLocations.containsKey(taglibUri)) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Ignored duplicate mapping of taglib URI " + StringUtil.jQuoteNoXSS(taglibUri)
                        + " to TLD location " + StringUtil.jQuoteNoXSS(tldLocation));
            }
        } else {
            tldLocations.put(taglibUri, tldLocation);
            if (LOG.isDebugEnabled()) {
                LOG.debug("Mapped taglib URI " + StringUtil.jQuoteNoXSS(taglibUri)
                        + " to TLD location " + StringUtil.jQuoteNoXSS(tldLocation));
            }
        }
    }

    private static Set/*<URLWithExternalForm>*/ collectMetaInfUrlsFromClassLoaders() throws IOException {
        final Set/*<URLWithExternalForm>*/ metainfDirUrls = new TreeSet();
    
        final ClassLoader tccl = tryGetThreadContextClassLoader();
        if (tccl != null) {
            collectMetaInfUrlsFromClassLoader(tccl, metainfDirUrls);
        }
    
        final ClassLoader cccl = TaglibFactory.class.getClassLoader();
        if (!isDescendantOfOrSameAs(tccl, cccl)) {
            collectMetaInfUrlsFromClassLoader(cccl, metainfDirUrls);
        }
        return metainfDirUrls;
    }

    private static void collectMetaInfUrlsFromClassLoader(ClassLoader cl, Set/* <URLWithExternalForm> */metainfDirUrls)
            throws IOException {
        Enumeration/*<URL>*/ urls = cl.getResources(META_INF_REL_PATH);
        if (urls != null) {
            while (urls.hasMoreElements()) {
                metainfDirUrls.add(new URLWithExternalForm((URL) urls.nextElement()));
            }
        }
    }

    private String getTaglibUriFromTld(InputStream tldFileIn, String tldFileXmlSystemId) throws SAXException, IOException {
        TldParserForTaglibUriExtraction tldParser = new TldParserForTaglibUriExtraction();
        parseXml(tldFileIn, tldFileXmlSystemId, tldParser);
        return tldParser.getTaglibUri();
    }

    /**
     * @param tldLocation
     *            The physical location of the TLD file
     * @param taglibUri
     *            The URI used in templates to refer to the taglib (like {@code <%@ taglib uri="..." ... %>} in JSP).
     */
    private TemplateHashModel loadTaglib(TldLocation tldLocation, String taglibUri) throws IOException, SAXException {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Loading taglib for URI " + StringUtil.jQuoteNoXSS(taglibUri)
                    + " from TLD location " + StringUtil.jQuoteNoXSS(tldLocation));
        }
        final Taglib taglib = new Taglib(servletContext, tldLocation, objectWrapper);
        taglibs.put(taglibUri, taglib);
        tldLocations.remove(taglibUri);
        return taglib;
    }

    private static void parseXml(InputStream in, String systemId, DefaultHandler handler)
            throws SAXException, IOException {
        InputSource inSrc = new InputSource();
        inSrc.setSystemId(systemId);
        inSrc.setByteStream(toCloseIgnoring(in));
        
        SAXParserFactory factory = SAXParserFactory.newInstance();
        factory.setNamespaceAware(false);
        factory.setValidating(false); // Especially as we use dummy empty DTD-s
        XMLReader reader;
        try {
            reader = factory.newSAXParser().getXMLReader();
        } catch (ParserConfigurationException e) {
            // Not expected
            throw new RuntimeException("XML parser setup failed", e);
        }
        reader.setEntityResolver(new EmptyContentEntityResolver()); // To deal with referred DTD-s
        reader.setContentHandler(handler);
        reader.setErrorHandler(handler);
        
        reader.parse(inSrc);
    }

    private static String resolveRelativeUri(String uri) throws TaglibGettingException {
        TemplateModel reqHash;
        try {
            reqHash = Environment.getCurrentEnvironment().getVariable(
                    FreemarkerServlet.KEY_REQUEST_PRIVATE);
        } catch (TemplateModelException e) {
            throw new TaglibGettingException("Failed to get FreemarkerServlet request information", e);
        }
        if (reqHash instanceof HttpRequestHashModel) {
            HttpServletRequest req =
                    ((HttpRequestHashModel) reqHash).getRequest();
            String pi = req.getPathInfo();
            String reqPath = req.getServletPath();
            if (reqPath == null) {
                reqPath = "";
            }
            reqPath += (pi == null ? "" : pi);
            // We don't care about paths with ".." in them. If the container
            // wishes to resolve them on its own, let it be.
            int lastSlash = reqPath.lastIndexOf('/');
            if (lastSlash != -1) {
                return reqPath.substring(0, lastSlash + 1) + uri;
            } else {
                return '/' + uri;
            }
        }
        throw new TaglibGettingException(
                "Can't resolve relative URI " + uri + " as request URL information is unavailable.");
    }

    /**
     * Ignores attempts to close the stream.
     */
    private static FilterInputStream toCloseIgnoring(InputStream in) {
        return new FilterInputStream(in) {
            @Override
            public void close() {
                // Do nothing 
            }
        };
    }
    
    private static int getUriType(String uri) throws MalformedURLException {
        if (uri == null) {
            throw new IllegalArgumentException("null is not a valid URI");
        }
        if (uri.length() == 0) {
            throw new MalformedURLException("empty string is not a valid URI");
        }
        final char c0 = uri.charAt(0);
        if (c0 == '/') {
            return URL_TYPE_ABSOLUTE;
        }
        // Check if it conforms to RFC 3986 3.1 in order to qualify as ABS_URI
        if (c0 < 'a' || c0 > 'z') { // First char of scheme must be alpha
            return URL_TYPE_RELATIVE;
        }
        final int colon = uri.indexOf(':');
        if (colon == -1) { // Must have a colon
            return URL_TYPE_RELATIVE;
        }
        // Subsequent chars must be [a-z,0-9,+,-,.]
        for (int i = 1; i < colon; ++i) {
            final char c = uri.charAt(i);
            if ((c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '+' && c != '-' && c != '.') {
                return URL_TYPE_RELATIVE;
            }
        }
        return URL_TYPE_FULL;
    }

    private static boolean isJarPath(final String uriPath) {
        return uriPath.endsWith(".jar") || uriPath.endsWith(".zip");
    }
    
    private static boolean isJarUrl(URL url) {
        final String scheme = url.getProtocol();
        return "jar".equals(scheme) || "zip".equals(scheme)
                || "vfszip".equals(scheme) // JBoss AS
                || "wsjar".equals(scheme); // WebSphere
    }

    private static URL createJarEntryUrl(final URL jarBaseEntryUrl, String relativeEntryPath)
            throws MalformedURLException {
        if (relativeEntryPath.startsWith("/")) {
            relativeEntryPath = relativeEntryPath.substring(1);
        }
        try {
            return new URL(jarBaseEntryUrl, StringUtil.URLPathEnc(relativeEntryPath, PLATFORM_FILE_ENCODING));
        } catch (UnsupportedEncodingException e) {
            throw new BugException();
        }
    }

    /**
     * Trying to hide any JarFile implementation inconsistencies.
     */
    private static String normalizeJarEntryPath(String jarEntryDirPath, boolean directory) {
        // Not know to be a problem, but to be in the safe side:
        if (!jarEntryDirPath.startsWith("/")) {
            jarEntryDirPath = "/" + jarEntryDirPath;
        }
    
        // Known to be a problem:
        if (directory && !jarEntryDirPath.endsWith("/")) {
            jarEntryDirPath = jarEntryDirPath + "/";
        }
    
        return jarEntryDirPath;
    }

    private static MalformedURLException newFailedToExtractEntryPathException(final URL url) {
        return new MalformedURLException("Failed to extract jar entry path from: " + url);
    }

    /**
     * Converts an URL to a {@code File} object, if the URL format (scheme) makes is possible.
     */
    private File urlToFileOrNull(URL url) {
        if (test_emulateNoUrlToFileConversions) {
            return null;
        }
        
        if (!"file".equals(url.getProtocol())) {
            return null;
        }
    
        String filePath;
        try {
            // Using URI instead of URL, so we get an URL-decoded path.
            filePath = url.toURI().getSchemeSpecificPart();
        } catch (URISyntaxException e) { // Can happen, as URI-s are stricter than legacy URL-s.
            // URL.getFile() doesn't decode %XX-s (used for spaces and non-US-ASCII letters usually), so we do.
            // As it was originally created for a file somewhere, we hope that it uses the platform default encoding.
            try {
                filePath = URLDecoder.decode(url.getFile(), PLATFORM_FILE_ENCODING);
            } catch (UnsupportedEncodingException e2) {
                throw new BugException(e2);
            }
        }
        return new File(filePath);
    }

    /**
     * Gets a servlet context resource as a {@link JarFile} if possible, return {@code null} otherwise.
     * For BC only, we try to get over errors during URL/JarFile construction, so then the caller can fall back to the
     * legacy ZipInputStream-based approach.
     */
    private JarFile servletContextResourceToFileOrNull(final String jarResourcePath) throws MalformedURLException,
            IOException {
        URL jarResourceUrl = servletContext.getResource(jarResourcePath);
        if (jarResourceUrl == null) {
            LOG.error("ServletContext resource URL was null (missing resource?): " + jarResourcePath);
            return null;
        }

        File jarResourceAsFile = urlToFileOrNull(jarResourceUrl);
        if (jarResourceAsFile == null) {
            // Expected - it's just not File
            return null;
        }

        if (!jarResourceAsFile.isFile()) {
            LOG.error("Jar file doesn't exist - falling back to stream mode: " + jarResourceAsFile);
            return null;
        }

        return new JarFile(jarResourceAsFile);
    }

    private static URL tryCreateServletContextJarEntryUrl(
            ServletContext servletContext, final String servletContextJarFilePath, final String entryPath) {
        try {
            final URL jarFileUrl = servletContext.getResource(servletContextJarFilePath);
            if (jarFileUrl == null) {
                throw new IOException("Servlet context resource not found: " + servletContextJarFilePath);
            }
            return new URL(
                    "jar:"
                    + jarFileUrl.toURI()
                    + JAR_URL_ENTRY_PATH_START
                    + URLEncoder.encode(
                            entryPath.startsWith("/") ? entryPath.substring(1) : entryPath,
                            PLATFORM_FILE_ENCODING));
        } catch (Exception e) {
            LOG.error("Couldn't get URL for serlvetContext resource "
                        + StringUtil.jQuoteNoXSS(servletContextJarFilePath)
                        + " / jar entry " + StringUtil.jQuoteNoXSS(entryPath),
                    e);
            return null;
        }
    }

    private static boolean isTldFileNameIgnoreCase(String name) {
        final int dotIdx = name.lastIndexOf('.');
        if (dotIdx < 0) return false;
        final String extension = name.substring(dotIdx + 1).toLowerCase();
        return extension.equalsIgnoreCase("tld");
    }

    private static ClassLoader tryGetThreadContextClassLoader() {
        ClassLoader tccl;
        try {
            tccl = Thread.currentThread().getContextClassLoader();
        } catch (SecurityException e) {
            // Suppress
            tccl = null;
            LOG.warn("Can't access Thread Context ClassLoader", e);
        }
        return tccl;
    }
    
    private static boolean isDescendantOfOrSameAs(ClassLoader descendant, ClassLoader parent) {
        while (true) {
            if (descendant == null) {
                return false;
            }
            if (descendant == parent) {
                return true;
            }
            descendant = descendant.getParent();
        }
    }
    
    /**
     * A location within which we will look for {@code META-INF/**}{@code /*.tld}-s. Used in the parameter to
     * {@link #setMetaInfTldSources}. See concrete subclasses for more.
     * 
     * @since 2.3.22
     */
    public static abstract class MetaInfTldSource {
        private MetaInfTldSource() { }
    }

    /**
     * To search TLD-s under <tt>sevletContext:/WEB-INF/lib/*.{jar,zip}/META-INF/**</tt><tt>/*.tld</tt>, as requested by
     * the JSP specification. Note that these also used to be in the classpath, so it's redundant to use this together
     * with a sufficiently permissive {@link ClasspathMetaInfTldSource}.
     * 
     * @since 2.3.22
     */
    public static final class WebInfPerLibJarMetaInfTldSource extends MetaInfTldSource {
        public final static WebInfPerLibJarMetaInfTldSource INSTANCE = new WebInfPerLibJarMetaInfTldSource();
        private WebInfPerLibJarMetaInfTldSource() { }; 
    }

    /**
     * To search TLD-s under {@code META-INF/**}{@code /*.tld} inside classpath root containers, that is, in directories
     * and jar-s that are in the classpath (or are visible for the class loader otherwise). It will only search inside
     * those roots whose URL matches the pattern specified in the constructor. It correctly handles when multiple roots
     * contain a TLD with the same name (typically, {@code META-INF/taglib.tld}), that is, those TLD-s won't shadow each
     * other, all of them will be loaded independently.
     * 
     * <p>
     * Note that this TLD discovery mechanism is not part of the JSP specification.
     * 
     * @since 2.3.22
     */
    public static final class ClasspathMetaInfTldSource extends MetaInfTldSource {
        
        private final Pattern rootContainerPattern; 
        
        /**
         * @param rootContainerPattern
         *            The pattern against which the classpath root container URL-s will be matched. For example, to only
         *            search in jar-s whose name ends with "taglib", the patter should be {@code ".*taglib\.jar$"}. To
         *            search everywhere, the pattern should be {@code ".*"}. The pattern need to match the whole URL,
         *            not just part of it.
         */
        public ClasspathMetaInfTldSource(Pattern rootContainerPattern) {
            this.rootContainerPattern = rootContainerPattern;
        }

        /**
         * See constructor argument: {@link #ClasspathMetaInfTldSource(Pattern)}.
         */
        public Pattern getRootContainerPattern() {
            return rootContainerPattern;
        };
        
    }

    /**
     * When it occurs in the {@link MetaInfTldSource} list, all {@link MetaInfTldSource}-s before it will be disabled.
     * This is useful when the list is assembled from multiple sources, and some want to re-start it, rather than append
     * to the end of it.
     * 
     * @see FreemarkerServlet#SYSTEM_PROPERTY_META_INF_TLD_SOURCES
     * @see TaglibFactory#setMetaInfTldSources(List)
     */
    public static final class ClearMetaInfTldSource extends MetaInfTldSource {
        public final static ClearMetaInfTldSource INSTANCE = new ClearMetaInfTldSource();
        private ClearMetaInfTldSource() { }; 
    }
    
    private interface TldLocation {
        
        /**
         * Reads the TLD file.
         * @return Not {@code null}
         */
        public abstract InputStream getInputStream() throws IOException;
        
        /**
         * The absolute URL of the TLD file.
         * @return Not {@code null}
         */
        public abstract String getXmlSystemId() throws IOException;
    }

    private interface InputStreamFactory {
        InputStream getInputStream();
    
    }

    private class ServletContextTldLocation implements TldLocation {
        
        private final String fileResourcePath;
    
        public ServletContextTldLocation(String fileResourcePath) {
            this.fileResourcePath = fileResourcePath;
        }
    
        @Override
        public InputStream getInputStream() throws IOException {
            final InputStream in = servletContext.getResourceAsStream(fileResourcePath);
            if (in == null) {
                throw newResourceNotFoundException();
            }
            return in;
        }
    
        @Override
        public String getXmlSystemId() throws IOException {
            final URL url = servletContext.getResource(fileResourcePath);
            return url != null ? url.toExternalForm() : null;
        }
        
        private IOException newResourceNotFoundException() {
            return new IOException("Resource not found: servletContext:" + fileResourcePath);
        }
        
        @Override
        public final String toString() {
            return "servletContext:" + fileResourcePath;
        }
    
    }
    

    /**
     * Points to plain class loader resource (regardless of if in what classpath root container it's in).
     */
    private static class ClasspathTldLocation implements TldLocation {
        
        private final String resourcePath;
    
        public ClasspathTldLocation(String resourcePath) {
            if (!resourcePath.startsWith("/")) {
                throw new IllegalArgumentException("\"resourcePath\" must start with /");
            }
            this.resourcePath = resourcePath;
        }
    
        @Override
        public String toString() {
            return "classpath:" + resourcePath;
        }
    
        @Override
        public InputStream getInputStream() throws IOException {
            ClassLoader tccl = tryGetThreadContextClassLoader();
            if (tccl != null) {
                InputStream ins = ClassUtil.getReasourceAsStream(tccl, resourcePath, true);
                if (ins != null) {
                    return ins;
                }
            }
            
            return ClassUtil.getReasourceAsStream(getClass(), resourcePath, false);
        }

        @Override
        public String getXmlSystemId() throws IOException {
            ClassLoader tccl = tryGetThreadContextClassLoader();
            if (tccl != null) {
                final URL url = tccl.getResource(resourcePath);
                if (url != null) { 
                    return url.toExternalForm();
                }
            }
            
            final URL url = getClass().getResource(resourcePath);
            return url == null ? null : url.toExternalForm();
        }
    
    }

    private abstract class JarEntryTldLocation implements TldLocation {

        /**
         * Can be {@code null} if there was some technical problem, but then
         * {@link #fallbackRawJarContentInputStreamFactory} and {@link #entryPath} will be non-{@code null}
         */
        private final URL entryUrl;
        private final InputStreamFactory fallbackRawJarContentInputStreamFactory;
        private final String entryPath;
        
        public JarEntryTldLocation(URL entryUrl, InputStreamFactory fallbackRawJarContentInputStreamFactory,
                String entryPath) {
            if (entryUrl == null) {
                NullArgumentException.check(fallbackRawJarContentInputStreamFactory);
                NullArgumentException.check(entryPath);
            }
            
            this.entryUrl = entryUrl;
            this.fallbackRawJarContentInputStreamFactory = fallbackRawJarContentInputStreamFactory;
            this.entryPath = entryPath != null ? normalizeJarEntryPath(entryPath, false) : null;
        }

        @Override
        public InputStream getInputStream() throws IOException {
            if (entryUrl != null) {
                try {
                    if (test_emulateJarEntryUrlOpenStreamFails) {
                        throw new RuntimeException("Test only");
                    }
                    return entryUrl.openStream();
                } catch (Exception e) {
                    if (fallbackRawJarContentInputStreamFactory == null) {
                        // Java 7 (Java 6?): We could just re-throw `e`
                        if (e instanceof IOException) {
                            throw (IOException) e;
                        }
                        if (e instanceof RuntimeException) {
                            throw (RuntimeException) e;
                        }
                        throw new RuntimeException(e);
                    }
                    LOG.error("Failed to open InputStream for URL (will try fallback stream): " + entryUrl);
                }
                // Retry with the fallbackRawJarContentInputStreamFactory comes.
            }
            
            final String entryPath;
            if (this.entryPath != null) {
                entryPath = this.entryPath;
            } else {
                if (entryUrl == null) {
                    throw new IOException("Nothing to deduce jar entry path from.");
                }
                String urlEF = entryUrl.toExternalForm();
                int sepIdx = urlEF.indexOf(JAR_URL_ENTRY_PATH_START);
                if (sepIdx == -1) {
                    throw new IOException("Couldn't extract jar entry path from: " + urlEF);
                }
                entryPath = normalizeJarEntryPath(
                        URLDecoder.decode(
                                urlEF.substring(sepIdx + JAR_URL_ENTRY_PATH_START.length()),
                                PLATFORM_FILE_ENCODING),
                        false);
            }
            
            InputStream rawIn = null;
            ZipInputStream zipIn = null;
            boolean returnedZipIn = false;
            try {
                rawIn = fallbackRawJarContentInputStreamFactory.getInputStream();
                if (rawIn == null) {
                    throw new IOException("Jar's InputStreamFactory (" + fallbackRawJarContentInputStreamFactory
                            + ") says the resource doesn't exist.");
                }
                zipIn = new ZipInputStream(rawIn);
                while (true) {
                    final ZipEntry macthedJarEntry = zipIn.getNextEntry();
                    if (macthedJarEntry == null) {
                        throw new IOException("Could not find JAR entry " + StringUtil.jQuoteNoXSS(entryPath) + ".");
                    }
                    if (entryPath.equals(normalizeJarEntryPath(macthedJarEntry.getName(), false))) {
                        returnedZipIn = true;
                        return zipIn;
                    }
                }
            } finally {
                if (!returnedZipIn) {
                    if (zipIn != null) {
                        zipIn.close();
                    }
                    if (rawIn != null) {
                        rawIn.close();
                    }
                }
            }
        }
    
        @Override
        public String getXmlSystemId() {
            return entryUrl != null ? entryUrl.toExternalForm() : null;
        }
    
        @Override
        public String toString() {
            return entryUrl != null
                    ? entryUrl.toExternalForm()
                    : "jar:{" + fallbackRawJarContentInputStreamFactory + "}!" + entryPath;
        }
        
    }
    
    private class JarEntryUrlTldLocation extends JarEntryTldLocation {
        
        private JarEntryUrlTldLocation(URL entryUrl, InputStreamFactory fallbackRawJarContentInputStreamFactory) {
            super(entryUrl, fallbackRawJarContentInputStreamFactory, null);
        }
        
    }

    /**
     * Points to a file entry inside a jar, with optional {@link ZipInputStream} fallback.
     */
    private class ServletContextJarEntryTldLocation extends JarEntryTldLocation {
        
        /**
         * For creating instance based on the servlet context resource path of a jar.
         * While it tries to construct and use an URL that points directly to the target entry inside the jar, it will
         * operate even if these URL-related operations fail. 
         */
        private ServletContextJarEntryTldLocation(final String servletContextJarFilePath, final String entryPath) {
            super(
                    tryCreateServletContextJarEntryUrl(servletContext, servletContextJarFilePath, entryPath),
                    new InputStreamFactory() {
                        @Override
                        public InputStream getInputStream() {
                            return servletContext.getResourceAsStream(servletContextJarFilePath);
                        }

                        @Override
                        public String toString() {
                            return "servletContext:" + servletContextJarFilePath;
                        }
                    },
                    entryPath);
        }
        
    }

    private static class FileTldLocation implements TldLocation {

        private final File file;

        public FileTldLocation(File file) {
            this.file = file;
        }

        @Override
        public InputStream getInputStream() throws IOException {
            return new FileInputStream(file);
        }

        @Override
        public String getXmlSystemId() throws IOException {
            return file.toURI().toURL().toExternalForm();
        }

        @Override
        public String toString() {
            return file.toString();
        }

    }
    
    private static final class Taglib implements TemplateHashModel {
        private final Map tagsAndFunctions;

        Taglib(ServletContext ctx, TldLocation tldPath, ObjectWrapper wrapper) throws IOException, SAXException {
            tagsAndFunctions = parseToTagsAndFunctions(ctx, tldPath, wrapper);
        }

        @Override
        public TemplateModel get(String key) {
            return (TemplateModel) tagsAndFunctions.get(key);
        }

        @Override
        public boolean isEmpty() {
            return tagsAndFunctions.isEmpty();
        }

        private static final Map parseToTagsAndFunctions(
                ServletContext ctx, TldLocation tldLocation, ObjectWrapper objectWrapper) throws IOException, SAXException {
            final TldParserForTaglibBuilding tldParser = new TldParserForTaglibBuilding(objectWrapper);

            try (InputStream in = tldLocation.getInputStream()) {
                parseXml(in, tldLocation.getXmlSystemId(), tldParser);
            }
            
            EventForwarding eventForwarding = EventForwarding.getInstance(ctx);
            if (eventForwarding != null) {
                eventForwarding.addListeners(tldParser.getListeners());
            } else if (tldParser.getListeners().size() > 0) {
                throw new TldParsingSAXException(
                        "Event listeners specified in the TLD could not be " +
                                " registered since the web application doesn't have a" +
                                " listener of class " + EventForwarding.class.getName() +
                                ". To remedy this, add this element to web.xml:\n" +
                                "| <listener>\n" +
                                "|   <listener-class>" + EventForwarding.class.getName() + "</listener-class>\n" +
                                "| </listener>", null);
            }
            return tldParser.getTagsAndFunctions();
        }
    }

    private class WebXmlParser extends DefaultHandler {
        private static final String E_TAGLIB = "taglib";
        private static final String E_TAGLIB_LOCATION = "taglib-location";
        private static final String E_TAGLIB_URI = "taglib-uri";

        private StringBuilder cDataCollector;
        private String taglibUriCData;
        private String taglibLocationCData;
        private Locator locator;

        @Override
        public void setDocumentLocator(Locator locator) {
            this.locator = locator;
        }

        @Override
        public void startElement(
                String nsuri,
                String localName,
                String qName,
                Attributes atts) {
            if (E_TAGLIB_URI.equals(qName) || E_TAGLIB_LOCATION.equals(qName)) {
                cDataCollector = new StringBuilder();
            }
        }

        @Override
        public void characters(char[] chars, int off, int len) {
            if (cDataCollector != null) {
                cDataCollector.append(chars, off, len);
            }
        }

        @Override
        public void endElement(String nsUri, String localName, String qName) throws TldParsingSAXException {
            if (E_TAGLIB_URI.equals(qName)) {
                taglibUriCData = cDataCollector.toString().trim();
                cDataCollector = null;
            } else if (E_TAGLIB_LOCATION.equals(qName)) {
                taglibLocationCData = cDataCollector.toString().trim();
                if (taglibLocationCData.length() == 0) {
                    throw new TldParsingSAXException("Required \"" + E_TAGLIB_URI + "\" element was missing or empty",
                            locator);
                }
                try {
                    if (getUriType(taglibLocationCData) == URL_TYPE_RELATIVE) {
                        taglibLocationCData = "/WEB-INF/" + taglibLocationCData;
                    }
                } catch (MalformedURLException e) {
                    throw new TldParsingSAXException("Failed to detect URI type for: " + taglibLocationCData, locator, e);
                }
                cDataCollector = null;
            } else if (E_TAGLIB.equals(qName)) {
                addTldLocation(
                        isJarPath(taglibLocationCData)
                                ? new ServletContextJarEntryTldLocation(
                                        taglibLocationCData, DEFAULT_TLD_RESOURCE_PATH)
                                : new ServletContextTldLocation(taglibLocationCData),
                        taglibUriCData);
            }
        }
    }

    private static class TldParserForTaglibUriExtraction extends DefaultHandler {
        private static final String E_URI = "uri";

        private StringBuilder cDataCollector;
        private String uri;

        TldParserForTaglibUriExtraction() {
        }

        String getTaglibUri() {
            return uri;
        }

        @Override
        public void startElement(
                String nsuri,
                String localName,
                String qName,
                Attributes atts) {
            if (E_URI.equals(qName)) {
                cDataCollector = new StringBuilder();
            }
        }

        @Override
        public void characters(char[] chars, int off, int len) {
            if (cDataCollector != null) {
                cDataCollector.append(chars, off, len);
            }
        }

        @Override
        public void endElement(String nsuri, String localName, String qName) {
            if (E_URI.equals(qName)) {
                uri = cDataCollector.toString().trim();
                cDataCollector = null;
            }
        }
    }

    static final class TldParserForTaglibBuilding extends DefaultHandler {
        private static final String E_TAG = "tag";
        private static final String E_NAME = "name";
        private static final String E_TAG_CLASS = "tag-class";
        private static final String E_TAG_CLASS_LEGACY = "tagclass";

        private static final String E_FUNCTION = "function";
        private static final String E_FUNCTION_CLASS = "function-class";
        private static final String E_FUNCTION_SIGNATURE = "function-signature";

        private static final String E_LISTENER = "listener";
        private static final String E_LISTENER_CLASS = "listener-class";

        private final BeansWrapper beansWrapper;

        private final Map<String, TemplateModel> tagsAndFunctions = new HashMap<>();
        private final List listeners = new ArrayList();

        private Locator locator;
        private StringBuilder cDataCollector;

        private Stack stack = new Stack();

        private String tagNameCData;
        private String tagClassCData;
        private String functionNameCData;
        private String functionClassCData;
        private String functionSignatureCData;
        private String listenerClassCData;

        TldParserForTaglibBuilding(ObjectWrapper wrapper) {
            if (wrapper instanceof BeansWrapper) {
                beansWrapper = (BeansWrapper) wrapper;
            } else {
                beansWrapper = null;
                if (LOG.isWarnEnabled()) {
                    LOG.warn("Custom EL functions won't be loaded because "
                            + (wrapper == null
                                    ? "no ObjectWrapper was specified for the TaglibFactory "
                                            + "(via TaglibFactory.setObjectWrapper(...), exists since 2.3.22)"
                                    : "the ObjectWrapper wasn't instance of " + BeansWrapper.class.getName())
                            + ".");
                }
            }
        }

        Map<String, TemplateModel> getTagsAndFunctions() {
            return tagsAndFunctions;
        }

        List getListeners() {
            return listeners;
        }

        @Override
        public void setDocumentLocator(Locator locator) {
            this.locator = locator;
        }

        @Override
        public void startElement(String nsUri, String localName, String qName, Attributes atts) {
            stack.push(qName);
            if (stack.size() == 3) {
                if (E_NAME.equals(qName) || E_TAG_CLASS_LEGACY.equals(qName) || E_TAG_CLASS.equals(qName)
                        || E_LISTENER_CLASS.equals(qName) || E_FUNCTION_CLASS.equals(qName)
                        || E_FUNCTION_SIGNATURE.equals(qName)) {
                    cDataCollector = new StringBuilder();
                }
            }
        }

        @Override
        public void characters(char[] chars, int off, int len) {
            if (cDataCollector != null) {
                cDataCollector.append(chars, off, len);
            }
        }

        @Override
        public void endElement(String nsuri, String localName, String qName) throws TldParsingSAXException {
            if (!stack.peek().equals(qName)) {
                throw new TldParsingSAXException("Unbalanced tag nesting at \"" + qName + "\" end-tag.", locator);
            }

            if (stack.size() == 3) {
                if (E_NAME.equals(qName)) {
                    if (E_TAG.equals(stack.get(1))) {
                        tagNameCData = pullCData();
                    } else if (E_FUNCTION.equals(stack.get(1))) {
                        functionNameCData = pullCData();
                    }
                } else if (E_TAG_CLASS_LEGACY.equals(qName) || E_TAG_CLASS.equals(qName)) {
                    tagClassCData = pullCData();
                } else if (E_LISTENER_CLASS.equals(qName)) {
                    listenerClassCData = pullCData();
                } else if (E_FUNCTION_CLASS.equals(qName)) {
                    functionClassCData = pullCData();
                } else if (E_FUNCTION_SIGNATURE.equals(qName)) {
                    functionSignatureCData = pullCData();
                }
            } else if (stack.size() == 2) {
                if (E_TAG.equals(qName)) {
                    checkChildElementNotNull(qName, E_NAME, tagNameCData);
                    checkChildElementNotNull(qName, E_TAG_CLASS, tagClassCData);

                    final Class tagClass = resoveClassFromTLD(tagClassCData, "custom tag", tagNameCData);

                    final TemplateModel customTagModel;
                    try {
                        if (Tag.class.isAssignableFrom(tagClass)) {
                            customTagModel = new TagTransformModel(tagNameCData, tagClass);
                        } else {
                            customTagModel = new SimpleTagDirectiveModel(tagNameCData, tagClass);
                        }
                    } catch (IntrospectionException e) {
                        throw new TldParsingSAXException(
                                "JavaBean introspection failed on custom tag class " + tagClassCData,
                                locator,
                                e);
                    }

                    TemplateModel replacedTagOrFunction = tagsAndFunctions.put(tagNameCData, customTagModel);
                    if (replacedTagOrFunction != null) {
                        if (CustomTagAndELFunctionCombiner.canBeCombinedAsELFunction(replacedTagOrFunction)) {
                            tagsAndFunctions.put(tagNameCData, CustomTagAndELFunctionCombiner.combine(
                                    customTagModel, (TemplateMethodModelEx) replacedTagOrFunction));
                        } else {
                            LOG.warn("TLD contains multiple tags with name " + StringUtil.jQuote(tagNameCData)
                                    + "; keeping only the last one.");
                        }
                    }

                    tagNameCData = null;
                    tagClassCData = null;
                } else if (E_FUNCTION.equals(qName) && beansWrapper != null) {
                    checkChildElementNotNull(qName, E_FUNCTION_CLASS, functionClassCData);
                    checkChildElementNotNull(qName, E_FUNCTION_SIGNATURE, functionSignatureCData);
                    checkChildElementNotNull(qName, E_NAME, functionNameCData);

                    final Class functionClass = resoveClassFromTLD(
                            functionClassCData, "custom EL function", functionNameCData);

                    final Method functionMethod;
                    try {
                        functionMethod = TaglibMethodUtil.getMethodByFunctionSignature(
                                functionClass, functionSignatureCData);
                    } catch (Exception e) {
                        throw new TldParsingSAXException(
                                "Error while trying to resolve signature " + StringUtil.jQuote(functionSignatureCData)
                                        + " on class " + StringUtil.jQuote(functionClass.getName())
                                        + " for custom EL function " + StringUtil.jQuote(functionNameCData) + ".",
                                locator,
                                e);
                    }

                    final int modifiers = functionMethod.getModifiers();
                    if (!Modifier.isPublic(modifiers) || !Modifier.isStatic(modifiers)) {
                        throw new TldParsingSAXException(
                                "The custom EL function method must be public and static: " + functionMethod,
                                locator);
                    }

                    final TemplateMethodModelEx elFunctionModel;
                    try {
                        elFunctionModel = beansWrapper.wrap(null, functionMethod);
                    } catch (Exception e) {
                        throw new TldParsingSAXException(
                                "FreeMarker object wrapping failed on method : " + functionMethod,
                                locator);
                    }

                    TemplateModel replacedTagOrFunction = tagsAndFunctions.put(functionNameCData, elFunctionModel);
                    if (replacedTagOrFunction != null) {
                        if (CustomTagAndELFunctionCombiner.canBeCombinedAsCustomTag(replacedTagOrFunction)) {
                            tagsAndFunctions.put(functionNameCData, CustomTagAndELFunctionCombiner.combine(
                                    replacedTagOrFunction, elFunctionModel));
                        } else {
                            LOG.warn("TLD contains multiple functions with name " + StringUtil.jQuote(functionNameCData)
                                    + "; keeping only the last one.");
                        }
                    }

                    functionNameCData = null;
                    functionClassCData = null;
                    functionSignatureCData = null;
                } else if (E_LISTENER.equals(qName)) {
                    checkChildElementNotNull(qName, E_LISTENER_CLASS, listenerClassCData);

                    final Class listenerClass = resoveClassFromTLD(listenerClassCData, E_LISTENER, null);

                    final Object listener;
                    try {
                        listener = listenerClass.newInstance();
                    } catch (Exception e) {
                        throw new TldParsingSAXException(
                                "Failed to create new instantiate from listener class " + listenerClassCData,
                                locator,
                                e);
                    }

                    listeners.add(listener);

                    listenerClassCData = null;
                }
            }

            stack.pop();
        }
        
        private String pullCData() {
            String r = cDataCollector.toString().trim();
            cDataCollector = null;
            return r;
        }

        private void checkChildElementNotNull(String parentElementName, String childElementName, String value)
                throws TldParsingSAXException {
            if (value == null) {
                throw new TldParsingSAXException(
                        "Missing required \"" + childElementName + "\" element inside the \""
                                + parentElementName + "\" element.", locator);
            }
        }

        private Class resoveClassFromTLD(String className, String entryType, String entryName)
                throws TldParsingSAXException {
            try {
                return ClassUtil.forName(className);
            } catch (LinkageError | ClassNotFoundException e) {
                throw newTLDEntryClassLoadingException(e, className, entryType, entryName);
            }
        }

        private TldParsingSAXException newTLDEntryClassLoadingException(Throwable e, String className,
                String entryType, String entryName)
                throws TldParsingSAXException {
            int dotIdx = className.lastIndexOf('.');
            if (dotIdx != -1) {
                dotIdx = className.lastIndexOf('.', dotIdx - 1);
            }
            boolean looksLikeNestedClass =
                    dotIdx != -1 && className.length() > dotIdx + 1
                            && Character.isUpperCase(className.charAt(dotIdx + 1));
            return new TldParsingSAXException(
                    (e instanceof ClassNotFoundException ? "Not found class " : "Can't load class ")
                            + StringUtil.jQuote(className) + " for " + entryType
                            + (entryName != null ? " " + StringUtil.jQuote(entryName) : "") + "."
                            + (looksLikeNestedClass
                                    ? " Hint: Before nested classes, use \"$\", not \".\"."
                                    : ""),
                    locator,
                    e);
        }

    }

    /**
     * Dummy resolver that returns 0 length content for all requests.
     */
    private static final class EmptyContentEntityResolver implements EntityResolver {
        
        @Override
        public InputSource resolveEntity(String publicId, String systemId) {
            InputSource is = new InputSource(new ByteArrayInputStream(new byte[0]));
            is.setPublicId(publicId);
            is.setSystemId(systemId);
            return is;
        }
    }

    /**
     * Redefines {@code SAXParseException#toString()} and {@code SAXParseException#getCause()} because it's broken on
     * Java 1.6 and earlier.
     */
    private static class TldParsingSAXException extends SAXParseException {
    
        private final Throwable cause;
    
        TldParsingSAXException(String message, Locator locator) {
            this(message, locator, null);
        }
    
        TldParsingSAXException(String message, Locator locator, Throwable e) {
            super(message, locator, e instanceof Exception ? (Exception) e : new Exception(
                    "Unchecked exception; see cause", e));
            cause = e;
        }
    
        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder(getClass().getName());
            sb.append(": ");
            int startLn = sb.length();
    
            String systemId = getSystemId();
            String publicId = getPublicId();
            if (systemId != null || publicId != null) {
                sb.append("In ");
                if (systemId != null) {
                    sb.append(systemId);
                }
                if (publicId != null) {
                    if (systemId != null) {
                        sb.append(" (public ID: ");
                    }
                    sb.append(publicId);
                    if (systemId != null) {
                        sb.append(')');
                    }
                }
            }
    
            int line = getLineNumber();
            if (line != -1) {
                sb.append(sb.length() != startLn ? ", at " : "At ");
                sb.append("line ");
                sb.append(line);
                int col = getColumnNumber();
                if (col != -1) {
                    sb.append(", column ");
                    sb.append(col);
                }
            }
    
            String message = getLocalizedMessage();
            if (message != null) {
                if (sb.length() != startLn) {
                    sb.append(":\n");
                }
                sb.append(message);
            }
    
            return sb.toString();
        }
    
        @Override
        public Throwable getCause() {
            Throwable superCause = super.getCause();
            return superCause == null ? this.cause : superCause;
        }
    
    }
    
    private static class URLWithExternalForm implements Comparable {

        private final URL url;
        private final String externalForm;

        public URLWithExternalForm(URL url) {
            this.url = url;
            this.externalForm = url.toExternalForm();
        }

        public URL getUrl() {
            return url;
        }

        public String getExternalForm() {
            return externalForm;
        }

        @Override
        public int hashCode() {
            return externalForm.hashCode();
        }

        @Override
        public boolean equals(Object that) {
            if (this == that) return true;
            if (that == null) return false;
            if (getClass() != that.getClass()) return false;
            return !externalForm.equals(((URLWithExternalForm) that).externalForm);
        }

        @Override
        public String toString() {
            return "URLWithExternalForm(" + externalForm + ")";
        }

        @Override
        public int compareTo(Object that) {
            return this.getExternalForm().compareTo(((URLWithExternalForm) that).getExternalForm());
        }

    }
    
    private static class TaglibGettingException extends Exception {

        public TaglibGettingException(String message, Throwable cause) {
            super(message, cause);
        }

        public TaglibGettingException(String message) {
            super(message);
        }
        
    }

}
