/*
 * 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 org.apache.catalina.webresources;

import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;

import org.apache.catalina.LifecycleException;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.compat.JrePlatform;
import org.apache.tomcat.util.http.RequestUtil;

public abstract class AbstractFileResourceSet extends AbstractResourceSet {

    private static final Log log = LogFactory.getLog(AbstractFileResourceSet.class);

    protected static final String[] EMPTY_STRING_ARRAY = new String[0];

    private File fileBase;
    private String absoluteBase;
    private String canonicalBase;
    private boolean readOnly = false;
    private Boolean allowLinking;

    protected AbstractFileResourceSet(String internalPath) {
        setInternalPath(internalPath);
    }

    protected final File getFileBase() {
        return fileBase;
    }

    @Override
    public void setReadOnly(boolean readOnly) {
        this.readOnly = readOnly;
    }

    @Override
    public boolean isReadOnly() {
        return readOnly;
    }

    @Override
    public void setAllowLinking(boolean allowLinking) {
        this.allowLinking = Boolean.valueOf(allowLinking);
    }

    @Override
    public boolean getAllowLinking() {
        if (allowLinking == null) {
            return getRoot().getAllowLinking();
        }
        return allowLinking.booleanValue();
    }

    protected final File file(String name, boolean mustExist) {

        if (name.equals("/")) {
            name = "";
        }
        File file = new File(fileBase, name);

        // If the requested names ends in '/', the Java File API will return a
        // matching file if one exists. This isn't what we want as it is not
        // consistent with the Servlet spec rules for request mapping.
        if (name.endsWith("/") && file.isFile()) {
            return null;
        }

        // If the file/dir must exist but the identified file/dir can't be read
        // then signal that the resource was not found
        if (mustExist && !file.canRead()) {
            return null;
        }

        // If allow linking is enabled, files are not limited to being located
        // under the fileBase so all further checks are disabled.
        if (getAllowLinking()) {
            return file;
        }

        // Additional Windows specific checks to handle known problems with
        // File.getCanonicalPath() and other issues
        if (JrePlatform.IS_WINDOWS && isInvalidWindowsFilename(name)) {
            return null;
        }

        // Check that this file is located under the WebResourceSet's base
        String canPath = null;
        try {
            canPath = file.getCanonicalPath();
        } catch (IOException ignore) {
            // Ignore
        }
        if (canPath == null || !canPath.startsWith(canonicalBase)) {
            return null;
        }

        /*
         * Ensure that the file is not outside the fileBase. This should not be possible for standard requests (the
         * request is normalized early in the request processing) but might be possible for some access via the Servlet
         * API (e.g. RequestDispatcher) therefore these checks are retained as an additional safety measure.
         * absoluteBase has been normalized so absPath needs to be normalized as well.
         */
        String absPath = normalize(file.getAbsolutePath());
        if (absPath == null || absoluteBase.length() > absPath.length()) {
            return null;
        }

        // Remove the fileBase location from the start of the paths since that
        // was not part of the requested path and the remaining check only
        // applies to the request path
        absPath = absPath.substring(absoluteBase.length());
        canPath = canPath.substring(canonicalBase.length());

        // The remaining request path must start with '/' if it has non-zero length
        if (!canPath.isEmpty() && canPath.charAt(0) != File.separatorChar) {
            return null;
        }

        // Case sensitivity check
        // The normalized requested path should be an exact match the equivalent
        // canonical path. If it is not, possible reasons include:
        // - case differences on case-insensitive file systems
        // - Windows removing a trailing ' ' or '.' from the file name
        //
        // In all cases, a mismatch here results in the resource not being
        // found
        //
        // absPath is normalized so canPath needs to be normalized as well
        // Can't normalize canPath earlier as canonicalBase is not normalized
        if (!canPath.isEmpty()) {
            canPath = normalize(canPath);
        }
        if (!canPath.equals(absPath)) {
            if (!canPath.equalsIgnoreCase(absPath)) {
                // Typically means symlinks are in use but being ignored. Given
                // the symlink was likely created for a reason, log a warning
                // that it was ignored.
                logIgnoredSymlink(getRoot().getContext().getName(), absPath, canPath);
            }
            return null;
        }

        return file;
    }


    protected void logIgnoredSymlink(String contextPath, String absPath, String canPath) {
        // Log issues with configuration files at a higher level
        if (absPath.startsWith("/META-INF/") || absPath.startsWith("/WEB-INF/")) {
            log.error(sm.getString("abstractFileResourceSet.canonicalfileCheckFailed", contextPath, absPath, canPath));
        } else {
            log.warn(sm.getString("abstractFileResourceSet.canonicalfileCheckFailed", contextPath, absPath, canPath));
        }
    }


    private boolean isInvalidWindowsFilename(String name) {
        final int len = name.length();
        if (len == 0) {
            return false;
        }
        // This is consistently ~10 times faster than the equivalent regular expression irrespective of input length.
        for (int i = 0; i < len; i++) {
            char c = name.charAt(i);
            /*
             * '\"', ':', '<' and '>' are disallowed in Windows file names and there are known problems with these
             * characters when using File#getCanonicalPath().
             *
             * Control characters (0x00-0x31) are not permitted and tend to be display strangely in log messages and
             * similar.
             *
             * '*', '?' and '|' are also not allowed and, while they are not currently known to cause other
             * difficulties, they are checked here rather than wasting cycles trying to find an invalid file later.
             *
             * The file separators ('/' and '\\') are not allowed in file names but are not excluded here as paths are
             * passed to this method.
             *
             * Note: Characters are listed in ASCII order.
             */
            if (c < 32 || c == '\"' || c == '*' || c == ':' || c == '<' || c == '>' || c == '?' || c == '|') {
                return true;
            }
        }
        /*
         * Windows does not allow file names to end in ' ' unless specific low-level APIs are used to create the files
         * that bypass various checks. File names that end in ' ' are known to cause problems when using
         * File#getCanonicalPath().
         */
        return name.charAt(len - 1) == ' ';
    }


    /**
     * Return a context-relative path, beginning with a "/", that represents the canonical version of the specified path
     * after ".." and "." elements are resolved out. If the specified path attempts to go outside the boundaries of the
     * current context (i.e. too many ".." path elements are present), return <code>null</code> instead.
     *
     * @param path Path to be normalized
     */
    private String normalize(String path) {
        return RequestUtil.normalize(path, File.separatorChar == '\\');
    }

    @Override
    public URL getBaseUrl() {
        try {
            return getFileBase().toURI().toURL();
        } catch (MalformedURLException e) {
            return null;
        }
    }

    /**
     * {@inheritDoc}
     * <p>
     * This is a NO-OP by default for File based resource sets.
     */
    @Override
    public void gc() {
        // NO-OP
    }


    // -------------------------------------------------------- Lifecycle methods

    @Override
    protected void initInternal() throws LifecycleException {
        fileBase = new File(getBase(), getInternalPath());
        checkType(fileBase);

        this.absoluteBase = normalize(fileBase.getAbsolutePath());

        try {
            this.canonicalBase = fileBase.getCanonicalPath();
        } catch (IOException ioe) {
            throw new IllegalArgumentException(ioe);
        }

        // Need to handle mapping of the file system root as a special case
        if ("/".equals(this.absoluteBase)) {
            this.absoluteBase = "";
        }
        if ("/".equals(this.canonicalBase)) {
            this.canonicalBase = "";
        }
    }


    protected abstract void checkType(File file);
}