/*
 * 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.servlets;

import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.List;
import java.util.Locale;
import java.util.Map.Entry;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.regex.Pattern;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.UnavailableException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.apache.catalina.Globals;
import org.apache.catalina.WebResource;
import org.apache.catalina.WebResourceRoot;
import org.apache.catalina.util.IOTools;
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.Method;
import org.apache.tomcat.util.res.StringManager;


/**
 * CGI-invoking servlet for web applications, used to execute scripts which comply to the Common Gateway Interface (CGI)
 * specification and are named in the path-info used to invoke this servlet.
 * <p>
 * <i>Note: This code compiles and even works for simple CGI cases. Exhaustive testing has not been done. Please
 * consider it beta quality. Feedback is appreciated to the author (see below).</i>
 * </p>
 * <p>
 * <b>Example</b>:<br>
 * If an instance of this servlet was mapped (using <code>&lt;web-app&gt;/WEB-INF/web.xml</code>) to:
 * </p>
 * <p>
 * <code>
 * &lt;web-app&gt;/cgi-bin/*
 * </code>
 * </p>
 * <p>
 * then the following request:
 * </p>
 * <p>
 * <code>
 * http://localhost:8080/&lt;web-app&gt;/cgi-bin/dir1/script/pathinfo1
 * </code>
 * </p>
 * <p>
 * would result in the execution of the script
 * </p>
 * <p>
 * <code>
 * &lt;web-app-root&gt;/WEB-INF/cgi/dir1/script
 * </code>
 * </p>
 * <p>
 * with the script's <code>PATH_INFO</code> set to <code>/pathinfo1</code>.
 * </p>
 * <p>
 * Recommendation: House all your CGI scripts under <code>&lt;webapp&gt;/WEB-INF/cgi</code>. This will ensure that you
 * do not accidentally expose your cgi scripts' code to the outside world and that your cgis will be cleanly ensconced
 * underneath the WEB-INF (i.e., non-content) area.
 * </p>
 * <p>
 * The default CGI location is mentioned above. You have the flexibility to put CGIs wherever you want, however:
 * </p>
 * <p>
 * The CGI search path will start at webAppRootDir + File.separator + cgiPathPrefix (or webAppRootDir alone if
 * cgiPathPrefix is null).
 * </p>
 * <p>
 * cgiPathPrefix is defined by setting this servlet's cgiPathPrefix init parameter
 * </p>
 * <p>
 * <B>CGI Specification</B>:<br>
 * derived from <a href="http://cgi-spec.golux.com">http://cgi-spec.golux.com</a>. A work-in-progress &amp; expired
 * Internet Draft. Note no actual RFC describing the CGI specification exists. Where the behavior of this servlet
 * differs from the specification cited above, it is either documented here, a bug, or an instance where the
 * specification cited differs from Best Community Practice (BCP). Such instances should be well-documented here. Please
 * email the <a href="https://tomcat.apache.org/lists.html">Tomcat group</a> with amendments.
 * </p>
 * <p>
 * <b>Canonical metavariables</b>:<br>
 * The CGI specification defines the following canonical metavariables: <br>
 * [excerpt from CGI specification]
 *
 * <PRE>
 *  AUTH_TYPE
 *  CONTENT_LENGTH
 *  CONTENT_TYPE
 *  GATEWAY_INTERFACE
 *  PATH_INFO
 *  PATH_TRANSLATED
 *  QUERY_STRING
 *  REMOTE_ADDR
 *  REMOTE_HOST
 *  REMOTE_IDENT
 *  REMOTE_USER
 *  REQUEST_METHOD
 *  SCRIPT_NAME
 *  SERVER_NAME
 *  SERVER_PORT
 *  SERVER_PROTOCOL
 *  SERVER_SOFTWARE
 * </PRE>
 * <p>
 * Metavariables with names beginning with the protocol name (<EM>e.g.</EM>, "HTTP_ACCEPT") are also canonical in their
 * description of request header fields. The number and meaning of these fields may change independently of this
 * specification. (See also section 6.1.5 [of the CGI specification].)
 * </p>
 * [end excerpt]
 * <h2>Implementation notes</h2>
 * <p>
 * <b>standard input handling</b>: If your script accepts standard input, then the client must start sending input
 * within a certain timeout period, otherwise the servlet will assume no input is coming and carry on running the
 * script. The script's the standard input will be closed and handling of any further input from the client is
 * undefined. Most likely it will be ignored. If this behavior becomes undesirable, then this servlet needs to be
 * enhanced to handle threading of the spawned process' stdin, stdout, and stderr (which should not be too hard). <br>
 * If you find your cgi scripts are timing out receiving input, you can set the init parameter
 * <code>stderrTimeout</code> of your webapps' cgi-handling servlet.
 * </p>
 * <p>
 * <b>Metavariable Values</b>: According to the CGI specification, implementations may choose to represent both null or
 * missing values in an implementation-specific manner, but must define that manner. This implementation chooses to
 * always define all required metavariables, but set the value to "" for all metavariables whose value is either null or
 * undefined. PATH_TRANSLATED is the sole exception to this rule, as per the CGI Specification.
 * </p>
 * <p>
 * <b>NPH -- Non-parsed-header implementation</b>: This implementation does not support the CGI NPH concept, whereby
 * server ensures that the data supplied to the script are precisely as supplied by the client and unaltered by the
 * server.
 * </p>
 * <p>
 * The function of a servlet container (including Tomcat) is specifically designed to parse and possible alter
 * CGI-specific variables, and as such makes NPH functionality difficult to support.
 * </p>
 * <p>
 * The CGI specification states that compliant servers MAY support NPH output. It does not state servers MUST support
 * NPH output to be unconditionally compliant. Thus, this implementation maintains unconditional compliance with the
 * specification though NPH support is not present.
 * </p>
 * <p>
 * The CGI specification is located at <a href="http://cgi-spec.golux.com">http://cgi-spec.golux.com</a>.
 * </p>
 * <h3>TODO:</h3>
 * <ul>
 * <li>Support for setting headers (for example, Location headers don't work)
 * <li>Support for collapsing multiple header lines (per RFC 2616)
 * <li>Ensure handling of POST method does not interfere with 2.3 Filters
 * <li>Refactor some debug code out of core
 * <li>Ensure header handling preserves encoding
 * <li>Possibly rewrite CGIRunner.run()?
 * <li>Possibly refactor CGIRunner and CGIEnvironment as non-inner classes?
 * <li>Document handling of cgi stdin when there is no stdin
 * <li>Revisit IOException handling in CGIRunner.run()
 * <li>Better documentation
 * <li>Confirm use of ServletInputStream.available() in CGIRunner.run() is not needed
 * <li>[add more to this TODO list]
 * </ul>
 */
public final class CGIServlet extends HttpServlet {

    private static final Log log = LogFactory.getLog(CGIServlet.class);
    private static final StringManager sm = StringManager.getManager(CGIServlet.class);

    private static final long serialVersionUID = 1L;

    private static final Set<String> DEFAULT_SUPER_METHODS = new HashSet<>();
    private static final Pattern DEFAULT_CMD_LINE_ARGUMENTS_DECODED_PATTERN;
    private static final String ALLOW_ANY_PATTERN = ".*";

    static {
        DEFAULT_SUPER_METHODS.add(Method.HEAD);
        DEFAULT_SUPER_METHODS.add(Method.OPTIONS);
        DEFAULT_SUPER_METHODS.add(Method.TRACE);

        if (JrePlatform.IS_WINDOWS) {
            DEFAULT_CMD_LINE_ARGUMENTS_DECODED_PATTERN = Pattern.compile("[\\w\\Q-.\\/:\\E]+");
        } else {
            // No restrictions
            DEFAULT_CMD_LINE_ARGUMENTS_DECODED_PATTERN = null;
        }

    }


    /**
     * The CGI search path will start at webAppRootDir + File.separator + cgiPathPrefix (or webAppRootDir alone if
     * cgiPathPrefix is null)
     */
    private String cgiPathPrefix = null;

    /** the executable to use with the script */
    private String cgiExecutable = "perl";

    /** additional arguments for the executable */
    private List<String> cgiExecutableArgs = null;

    /** the encoding to use for parameters */
    private String parameterEncoding = System.getProperty("file.encoding", "UTF-8");

    /* The HTTP methods this Servlet will pass to the CGI script */
    private final Set<String> cgiMethods = new HashSet<>();
    private boolean cgiMethodsAll = false;

    private transient WebResourceRoot resources = null;


    /**
     * The time (in milliseconds) to wait for the reading of stderr to complete before terminating the CGI process.
     */
    private long stderrTimeout = 2000;

    /**
     * The regular expression used to select HTTP headers to be passed to the CGI process as environment variables. The
     * name of the environment variable will be the name of the HTTP header converter to upper case, prefixed with
     * <code>HTTP_</code> and with all <code>-</code> characters converted to <code>_</code>.
     */
    private Pattern envHttpHeadersPattern =
            Pattern.compile("ACCEPT[-0-9A-Z]*|CACHE-CONTROL|COOKIE|HOST|IF-[-0-9A-Z]*|REFERER|USER-AGENT");

    /** object used to ensure multiple threads don't try to expand same file */
    private static final Object expandFileLock = new Object();

    /** the shell environment variables to be passed to the CGI script */
    private final Hashtable<String,String> shellEnv = new Hashtable<>();

    /**
     * Enable creation of script command line arguments from query-string. See
     * https://tools.ietf.org/html/rfc3875#section-4.4 4.4. The Script Command Line
     */
    private boolean enableCmdLineArguments = false;

    /**
     * Limits the encoded form of individual command line arguments. By default, values are limited to those allowed by
     * the RFC. See https://tools.ietf.org/html/rfc3875#section-4.4 Uses \Q...\E to avoid individual quoting.
     */
    private Pattern cmdLineArgumentsEncodedPattern = Pattern.compile("[\\w\\Q%;/?:@&,$-.!~*'()\\E]+");

    /**
     * Limits the decoded form of individual command line arguments. Default varies by platform.
     */
    private Pattern cmdLineArgumentsDecodedPattern = DEFAULT_CMD_LINE_ARGUMENTS_DECODED_PATTERN;


    /**
     * Sets instance variables.
     *
     * @param config a <code>ServletConfig</code> object containing the servlet's configuration and initialization
     *                   parameters
     *
     * @exception ServletException if an exception has occurred that interferes with the servlet's normal operation
     */
    @Override
    public void init(ServletConfig config) throws ServletException {

        super.init(config);

        // Set our properties from the initialization parameters
        cgiPathPrefix = getServletConfig().getInitParameter("cgiPathPrefix");
        boolean passShellEnvironment =
                Boolean.parseBoolean(getServletConfig().getInitParameter("passShellEnvironment"));

        if (passShellEnvironment) {
            shellEnv.putAll(System.getenv());
        }

        Enumeration<String> e = config.getInitParameterNames();
        while (e.hasMoreElements()) {
            String initParamName = e.nextElement();
            if (initParamName.startsWith("environment-variable-")) {
                if (initParamName.length() == 21) {
                    throw new ServletException(sm.getString("cgiServlet.emptyEnvVarName"));
                }
                shellEnv.put(initParamName.substring(21), config.getInitParameter(initParamName));
            }
        }

        if (getServletConfig().getInitParameter("executable") != null) {
            cgiExecutable = getServletConfig().getInitParameter("executable");
        }

        if (getServletConfig().getInitParameter("executable-arg-1") != null) {
            List<String> args = new ArrayList<>();
            for (int i = 1;; i++) {
                String arg = getServletConfig().getInitParameter("executable-arg-" + i);
                if (arg == null) {
                    break;
                }
                args.add(arg);
            }
            cgiExecutableArgs = args;
        }

        if (getServletConfig().getInitParameter("parameterEncoding") != null) {
            parameterEncoding = getServletConfig().getInitParameter("parameterEncoding");
        }

        if (getServletConfig().getInitParameter("stderrTimeout") != null) {
            stderrTimeout = Long.parseLong(getServletConfig().getInitParameter("stderrTimeout"));
        }

        if (getServletConfig().getInitParameter("envHttpHeaders") != null) {
            envHttpHeadersPattern = Pattern.compile(getServletConfig().getInitParameter("envHttpHeaders"));
        }

        if (getServletConfig().getInitParameter("enableCmdLineArguments") != null) {
            enableCmdLineArguments = Boolean.parseBoolean(config.getInitParameter("enableCmdLineArguments"));
        }

        if (getServletConfig().getInitParameter("cgiMethods") != null) {
            String paramValue = getServletConfig().getInitParameter("cgiMethods");
            paramValue = paramValue.trim();
            if ("*".equals(paramValue)) {
                cgiMethodsAll = true;
            } else {
                String[] methods = paramValue.split(",");
                for (String method : methods) {
                    String trimmedMethod = method.trim();
                    cgiMethods.add(trimmedMethod);
                }
            }
        } else {
            cgiMethods.add(Method.GET);
            cgiMethods.add(Method.POST);
        }

        if (getServletConfig().getInitParameter("cmdLineArgumentsEncoded") != null) {
            cmdLineArgumentsEncodedPattern =
                    Pattern.compile(getServletConfig().getInitParameter("cmdLineArgumentsEncoded"));
        }

        String value = getServletConfig().getInitParameter("cmdLineArgumentsDecoded");
        if (ALLOW_ANY_PATTERN.equals(value)) {
            // Optimisation for case where anything is allowed
            cmdLineArgumentsDecodedPattern = null;
        } else if (value != null) {
            cmdLineArgumentsDecodedPattern = Pattern.compile(value);
        }

        // Load the web resources
        resources = (WebResourceRoot) getServletContext().getAttribute(Globals.RESOURCES_ATTR);

        if (resources == null) {
            throw new UnavailableException(sm.getString("cgiServlet.noResources"));
        }
    }


    /**
     * Logs important Servlet API and container information.
     *
     * @param req HttpServletRequest object used as source of information
     */
    private void printServletEnvironment(HttpServletRequest req) {

        // Document the properties from ServletRequest
        log.trace("ServletRequest Properties");
        Enumeration<String> attrs = req.getAttributeNames();
        while (attrs.hasMoreElements()) {
            String attr = attrs.nextElement();
            log.trace("Request Attribute: " + attr + ": [ " + req.getAttribute(attr) + "]");
        }
        log.trace("Character Encoding: [" + req.getCharacterEncoding() + "]");
        log.trace("Content Length: [" + req.getContentLengthLong() + "]");
        log.trace("Content Type: [" + req.getContentType() + "]");
        Enumeration<Locale> locales = req.getLocales();
        while (locales.hasMoreElements()) {
            Locale locale = locales.nextElement();
            log.trace("Locale: [" + locale + "]");
        }
        Enumeration<String> params = req.getParameterNames();
        while (params.hasMoreElements()) {
            String param = params.nextElement();
            for (String value : req.getParameterValues(param)) {
                log.trace("Request Parameter: " + param + ":  [" + value + "]");
            }
        }
        log.trace("Protocol: [" + req.getProtocol() + "]");
        log.trace("Remote Address: [" + req.getRemoteAddr() + "]");
        log.trace("Remote Host: [" + req.getRemoteHost() + "]");
        log.trace("Scheme: [" + req.getScheme() + "]");
        log.trace("Secure: [" + req.isSecure() + "]");
        log.trace("Server Name: [" + req.getServerName() + "]");
        log.trace("Server Port: [" + req.getServerPort() + "]");

        // Document the properties from HttpServletRequest
        log.trace("HttpServletRequest Properties");
        log.trace("Auth Type: [" + req.getAuthType() + "]");
        log.trace("Context Path: [" + req.getContextPath() + "]");
        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                log.trace("Cookie: " + cookie.getName() + ": [" + cookie.getValue() + "]");
            }
        }
        Enumeration<String> headers = req.getHeaderNames();
        while (headers.hasMoreElements()) {
            String header = headers.nextElement();
            log.trace("HTTP Header: " + header + ": [" + req.getHeader(header) + "]");
        }
        log.trace("Method: [" + req.getMethod() + "]");
        log.trace("Path Info: [" + req.getPathInfo() + "]");
        log.trace("Path Translated: [" + req.getPathTranslated() + "]");
        log.trace("Query String: [" + req.getQueryString() + "]");
        log.trace("Remote User: [" + req.getRemoteUser() + "]");
        log.trace("Requested Session ID: [" + req.getRequestedSessionId() + "]");
        log.trace("Requested Session ID From Cookie: [" + req.isRequestedSessionIdFromCookie() + "]");
        log.trace("Requested Session ID From URL: [" + req.isRequestedSessionIdFromURL() + "]");
        log.trace("Requested Session ID Valid: [" + req.isRequestedSessionIdValid() + "]");
        log.trace("Request URI: [" + req.getRequestURI() + "]");
        log.trace("Servlet Path: [" + req.getServletPath() + "]");
        log.trace("User Principal: [" + req.getUserPrincipal() + "]");

        // Process the current session (if there is one)
        HttpSession session = req.getSession(false);
        if (session != null) {

            // Document the session properties
            log.trace("HttpSession Properties");
            log.trace("ID: [" + session.getId() + "]");
            log.trace("Creation Time: [" + new Date(session.getCreationTime()) + "]");
            log.trace("Last Accessed Time: [" + new Date(session.getLastAccessedTime()) + "]");
            log.trace("Max Inactive Interval: [" + session.getMaxInactiveInterval() + "]");

            // Document the session attributes
            attrs = session.getAttributeNames();
            while (attrs.hasMoreElements()) {
                String attr = attrs.nextElement();
                log.trace("Session Attribute: " + attr + ": [" + session.getAttribute(attr) + "]");
            }
        }

        // Document the servlet configuration properties
        log.trace("ServletConfig Properties");
        log.trace("Servlet Name: [" + getServletConfig().getServletName() + "]");

        // Document the servlet configuration initialization parameters
        params = getServletConfig().getInitParameterNames();
        while (params.hasMoreElements()) {
            String param = params.nextElement();
            String value = getServletConfig().getInitParameter(param);
            log.trace("Servlet Init Param: " + param + ": [" + value + "]");
        }

        // Document the servlet context properties
        log.trace("ServletContext Properties");
        log.trace("Major Version: [" + getServletContext().getMajorVersion() + "]");
        log.trace("Minor Version: [" + getServletContext().getMinorVersion() + "]");
        log.trace("Real Path for '/': [" + getServletContext().getRealPath("/") + "]");
        log.trace("Server Info: [" + getServletContext().getServerInfo() + "]");

        // Document the servlet context initialization parameters
        log.trace("ServletContext Initialization Parameters");
        params = getServletContext().getInitParameterNames();
        while (params.hasMoreElements()) {
            String param = params.nextElement();
            String value = getServletContext().getInitParameter(param);
            log.trace("Servlet Context Init Param: " + param + ": [" + value + "]");
        }

        // Document the servlet context attributes
        log.trace("ServletContext Attributes");
        attrs = getServletContext().getAttributeNames();
        while (attrs.hasMoreElements()) {
            String attr = attrs.nextElement();
            log.trace("Servlet Context Attribute: " + attr + ": [" + getServletContext().getAttribute(attr) + "]");
        }
    }


    @Override
    protected void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {

        String method = req.getMethod();
        if (cgiMethodsAll || cgiMethods.contains(method)) {
            doGet(req, res);
        } else if (DEFAULT_SUPER_METHODS.contains(method)) {
            // If the CGI servlet is explicitly configured to handle one of
            // these methods it will be handled in the previous condition
            super.service(req, res);
        } else {
            // Unsupported method
            res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
        }
    }


    /**
     * Provides CGI Gateway service.
     *
     * @param req HttpServletRequest passed in by servlet container
     * @param res HttpServletResponse passed in by servlet container
     *
     * @exception ServletException if a servlet-specific exception occurs
     * @exception IOException      if a read/write exception occurs
     */
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {

        CGIEnvironment cgiEnv = new CGIEnvironment(req, getServletContext());

        if (cgiEnv.isValid()) {
            CGIRunner cgi = new CGIRunner(cgiEnv.getCommand(), cgiEnv.getEnvironment(), cgiEnv.getWorkingDirectory(),
                    cgiEnv.getParameters());

            if (Method.POST.equals(req.getMethod())) {
                cgi.setInput(req.getInputStream());
            }
            cgi.setResponse(res);
            cgi.run();
        } else {
            res.sendError(404);
        }

        if (log.isTraceEnabled()) {
            String[] cgiEnvLines = cgiEnv.toString().split(System.lineSeparator());
            for (String cgiEnvLine : cgiEnvLines) {
                log.trace(cgiEnvLine);
            }

            printServletEnvironment(req);
        }
    }


    @Override
    protected void doOptions(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
        // Note: This method will never be called if cgiMethods is "*" so that
        // case does not need to be handled here.
        Set<String> allowedMethods = new HashSet<>();
        allowedMethods.addAll(cgiMethods);
        allowedMethods.addAll(DEFAULT_SUPER_METHODS);

        StringBuilder headerValue = new StringBuilder();

        for (String method : allowedMethods) {
            headerValue.append(method);
            headerValue.append(',');
        }

        // Remove trailing comma
        headerValue.deleteCharAt(headerValue.length() - 1);

        res.setHeader("allow", headerValue.toString());
    }


    /*
     * Behaviour depends on the status code.
     *
     * Status < 400 - Calls setStatus. Returns false. CGI servlet will provide the response body.
     *
     * Status >= 400 - Calls sendError(status), returns true. Standard error page mechanism will provide the response
     * body.
     */
    private boolean setStatus(HttpServletResponse response, int status) throws IOException {
        if (status >= HttpServletResponse.SC_BAD_REQUEST) {
            response.sendError(status);
            return true;
        } else {
            response.setStatus(status);
            return false;
        }
    }


    /**
     * Encapsulates the CGI environment and rules to derive that environment from the servlet container and request
     * information.
     */
    protected class CGIEnvironment {


        /** context of the enclosing servlet */
        private ServletContext context = null;

        /** context path of enclosing servlet */
        private String contextPath = null;

        /** servlet URI of the enclosing servlet */
        private String servletPath = null;

        /** pathInfo for the current request */
        private String pathInfo = null;

        /** tempdir for context - used to expand scripts in unexpanded wars */
        private File tmpDir = null;

        /** derived cgi environment */
        private Hashtable<String,String> env = null;

        /** cgi command to be invoked */
        private String command = null;

        /** cgi command's desired working directory */
        private final File workingDirectory;

        /** cgi command's command line parameters */
        private final ArrayList<String> cmdLineParameters = new ArrayList<>();

        /** whether or not this object is valid or not */
        private final boolean valid;


        /**
         * Creates a CGIEnvironment and derives the necessary environment, query parameters, working directory, cgi
         * command, etc.
         *
         * @param req     HttpServletRequest for information provided by the Servlet API
         * @param context ServletContext for information provided by the Servlet API
         *
         * @throws IOException an IO error occurred
         */
        protected CGIEnvironment(HttpServletRequest req, ServletContext context) throws IOException {
            setupFromContext(context);
            boolean valid = setupFromRequest(req);

            if (valid) {
                valid = setCGIEnvironment(req);
            }

            if (valid) {
                workingDirectory = new File(command.substring(0, command.lastIndexOf(File.separator)));
            } else {
                workingDirectory = null;
            }

            this.valid = valid;
        }


        /**
         * Uses the ServletContext to set some CGI variables
         *
         * @param context ServletContext for information provided by the Servlet API
         */
        protected void setupFromContext(ServletContext context) {
            this.context = context;
            this.tmpDir = (File) context.getAttribute(ServletContext.TEMPDIR);
        }


        /**
         * Uses the HttpServletRequest to set most CGI variables
         *
         * @param req HttpServletRequest for information provided by the Servlet API
         *
         * @return true if the request was parsed without error, false if there was a problem
         *
         * @throws UnsupportedEncodingException Unknown encoding
         */
        protected boolean setupFromRequest(HttpServletRequest req) throws UnsupportedEncodingException {

            boolean isIncluded = req.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) != null;

            // Look to see if this request is an include
            if (isIncluded) {
                this.contextPath = (String) req.getAttribute(RequestDispatcher.INCLUDE_CONTEXT_PATH);
                this.servletPath = (String) req.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH);
                this.pathInfo = (String) req.getAttribute(RequestDispatcher.INCLUDE_PATH_INFO);
            } else {
                this.contextPath = req.getContextPath();
                this.servletPath = req.getServletPath();
                this.pathInfo = req.getPathInfo();
            }
            // If getPathInfo() returns null, must be using extension mapping
            // In this case, pathInfo should be same as servletPath
            if (this.pathInfo == null) {
                this.pathInfo = this.servletPath;
            }

            // If the request method is GET, POST or HEAD and the query string
            // does not contain an unencoded "=" this is an indexed query.
            // The parsed query string becomes the command line parameters
            // for the cgi command.
            if (enableCmdLineArguments && (Method.GET.equals(req.getMethod()) || Method.POST.equals(req.getMethod()) ||
                    Method.HEAD.equals(req.getMethod()))) {
                String qs;
                if (isIncluded) {
                    qs = (String) req.getAttribute(RequestDispatcher.INCLUDE_QUERY_STRING);
                } else {
                    qs = req.getQueryString();
                }
                if (qs != null && qs.indexOf('=') == -1) {
                    StringTokenizer qsTokens = new StringTokenizer(qs, "+");
                    while (qsTokens.hasMoreTokens()) {
                        String encodedArgument = qsTokens.nextToken();
                        if (!cmdLineArgumentsEncodedPattern.matcher(encodedArgument).matches()) {
                            if (log.isDebugEnabled()) {
                                log.debug(sm.getString("cgiServlet.invalidArgumentEncoded", encodedArgument,
                                        cmdLineArgumentsEncodedPattern.toString()));
                            }
                            return false;
                        }

                        String decodedArgument = URLDecoder.decode(encodedArgument, parameterEncoding);
                        if (cmdLineArgumentsDecodedPattern != null &&
                                !cmdLineArgumentsDecodedPattern.matcher(decodedArgument).matches()) {
                            if (log.isDebugEnabled()) {
                                log.debug(sm.getString("cgiServlet.invalidArgumentDecoded", decodedArgument,
                                        cmdLineArgumentsDecodedPattern.toString()));
                            }
                            return false;
                        }

                        cmdLineParameters.add(decodedArgument);
                    }
                }
            }

            return true;
        }


        /**
         * Resolves core information about the cgi script.
         * <p>
         * Example URI:
         * </p>
         *
         * <PRE>
         *  /servlet/cgigateway/dir1/realCGIscript/pathinfo1
         * </PRE>
         * <ul>
         * <LI><b>path</b> = $CATALINA_HOME/mywebapp/dir1/realCGIscript
         * <LI><b>scriptName</b> = /servlet/cgigateway/dir1/realCGIscript
         * <LI><b>cgiName</b> = /dir1/realCGIscript
         * <LI><b>name</b> = realCGIscript
         * </ul>
         * <p>
         * CGI search algorithm: search the real path below &lt;my-webapp-root&gt; and find the first non-directory in
         * the getPathTranslated("/"), reading/searching from left-to-right.
         * </p>
         * <p>
         * The CGI search path will start at webAppRootDir + File.separator + cgiPathPrefix (or webAppRootDir alone if
         * cgiPathPrefix is null).
         * </p>
         * <p>
         * cgiPathPrefix is defined by setting this servlet's cgiPathPrefix init parameter
         * </p>
         *
         * @param contextPath   String as from HttpServletRequest.getContextPath()
         * @param servletPath   String as from HttpServletRequest.getServletPath()
         * @param pathInfo      String from HttpServletRequest.getPathInfo()
         * @param cgiPathPrefix subdirectory of webAppRootDir below which the web app's CGIs may be stored; can be null.
         *                          The CGI search path will start at webAppRootDir + File.separator + cgiPathPrefix (or
         *                          webAppRootDir alone if cgiPathPrefix is null). cgiPathPrefix is defined by setting
         *                          the servlet's cgiPathPrefix init parameter.
         *
         * @return
         *             <ul>
         *             <li><code>path</code> - full file-system path to valid cgi script, or null if no cgi was found
         *             <li><code>scriptName</code> - CGI variable SCRIPT_NAME; the full URL path to valid cgi script or
         *             null if no cgi was found
         *             <li><code>cgiName</code> - servlet pathInfo fragment corresponding to the cgi script itself, or
         *             null if not found
         *             <li><code>name</code> - simple name (no directories) of the cgi script, or null if no cgi was
         *             found
         *             </ul>
         */
        protected String[] findCGI(String contextPath, String servletPath, String pathInfo, String cgiPathPrefix) {

            StringBuilder cgiPath = new StringBuilder();
            StringBuilder urlPath = new StringBuilder();

            WebResource cgiScript = null;

            if (cgiPathPrefix == null || cgiPathPrefix.isEmpty()) {
                cgiPath.append(servletPath);
            } else {
                cgiPath.append('/');
                cgiPath.append(cgiPathPrefix);
            }
            urlPath.append(servletPath);

            StringTokenizer pathWalker = new StringTokenizer(pathInfo, "/");

            while (pathWalker.hasMoreElements() && (cgiScript == null || !cgiScript.isFile())) {
                String urlSegment = pathWalker.nextToken();
                cgiPath.append('/');
                cgiPath.append(urlSegment);
                urlPath.append('/');
                urlPath.append(urlSegment);
                if (log.isTraceEnabled()) {
                    log.trace(sm.getString("cgiServlet.find.location", cgiPath.toString()));
                }
                cgiScript = resources.getResource(cgiPath.toString());
            }

            // No script was found
            if (cgiScript == null || !cgiScript.isFile()) {
                return new String[] { null, null, null, null };
            }

            // Set-up return values
            String path = null;
            String scriptName = null;
            String cgiName = null;
            String name = null;

            path = cgiScript.getCanonicalPath();
            if (path == null) {
                /*
                 * The script doesn't exist directly on the file system. It might be located in an archive or similar.
                 * Such scripts are extracted to the web application's temporary file location.
                 */
                File tmpCgiFile = new File(tmpDir + cgiPath.toString());
                if (!tmpCgiFile.exists()) {

                    // Create directories
                    File parent = tmpCgiFile.getParentFile();
                    if (!parent.mkdirs() && !parent.isDirectory()) {
                        log.warn(sm.getString("cgiServlet.expandCreateDirFail", parent.getAbsolutePath()));
                        return new String[] { null, null, null, null };
                    }

                    try (InputStream is = cgiScript.getInputStream()) {
                        synchronized (expandFileLock) {
                            // Check if file was created by concurrent request
                            if (!tmpCgiFile.exists()) {
                                try {
                                    Files.copy(is, tmpCgiFile.toPath());
                                } catch (IOException ioe) {
                                    log.warn(sm.getString("cgiServlet.expandFail", cgiScript.getURL(),
                                            tmpCgiFile.getAbsolutePath()), ioe);
                                    if (tmpCgiFile.exists()) {
                                        if (!tmpCgiFile.delete()) {
                                            log.warn(sm.getString("cgiServlet.expandDeleteFail",
                                                    tmpCgiFile.getAbsolutePath()));
                                        }
                                    }
                                    return new String[] { null, null, null, null };
                                }
                                if (log.isDebugEnabled()) {
                                    log.debug(sm.getString("cgiServlet.expandOk", cgiScript.getURL(),
                                            tmpCgiFile.getAbsolutePath()));
                                }
                            }
                        }
                    } catch (IOException ioe) {
                        log.warn(sm.getString("cgiServlet.expandCloseFail", cgiScript.getURL()), ioe);
                    }
                }
                path = tmpCgiFile.getAbsolutePath();
            }

            scriptName = urlPath.toString();
            cgiName = scriptName.substring(servletPath.length());
            name = scriptName.substring(scriptName.lastIndexOf('/') + 1);

            if (log.isTraceEnabled()) {
                log.trace(sm.getString("cgiServlet.find.found", name, path, scriptName, cgiName));
            }

            return new String[] { path, scriptName, cgiName, name };
        }


        /**
         * Constructs the CGI environment to be supplied to the invoked CGI script; relies heavily on Servlet API
         * methods and findCGI
         *
         * @param req request associated with the CGI Invocation
         *
         * @return true if environment was set OK, false if there was a problem and no environment was set
         *
         * @throws IOException an IO error occurred
         */
        protected boolean setCGIEnvironment(HttpServletRequest req) throws IOException {

            /*
             * This method is slightly ugly; c'est la vie.
             * "You cannot stop [ugliness], you can only hope to contain [it]" (apologies to Marv Albert regarding MJ)
             */

            // Add the shell environment variables (if any)
            Hashtable<String,String> envp = new Hashtable<>(shellEnv);

            // Add the CGI environment variables
            String sPathInfoOrig;
            String sPathInfoCGI;
            String sPathTranslatedCGI = null;
            String sCGIFullPath;
            String sCGIScriptName;
            String sCGIFullName;
            String sCGIName;
            String[] sCGINames;


            sPathInfoOrig = this.pathInfo;
            sPathInfoOrig = sPathInfoOrig == null ? "" : sPathInfoOrig;

            sCGINames = findCGI(contextPath, servletPath, sPathInfoOrig, cgiPathPrefix);

            sCGIFullPath = sCGINames[0];
            sCGIScriptName = sCGINames[1];
            sCGIFullName = sCGINames[2];
            sCGIName = sCGINames[3];

            if (sCGIFullPath == null || sCGIScriptName == null || sCGIFullName == null || sCGIName == null) {
                return false;
            }

            envp.put("SERVER_SOFTWARE", "TOMCAT");

            envp.put("SERVER_NAME", nullsToBlanks(req.getServerName()));

            envp.put("GATEWAY_INTERFACE", "CGI/1.1");

            envp.put("SERVER_PROTOCOL", nullsToBlanks(req.getProtocol()));

            int port = req.getServerPort();
            Integer iPort = (port == 0 ? Integer.valueOf(-1) : Integer.valueOf(port));
            envp.put("SERVER_PORT", iPort.toString());

            envp.put("REQUEST_METHOD", nullsToBlanks(req.getMethod()));

            envp.put("REQUEST_URI", nullsToBlanks(req.getRequestURI()));


            /*-
             * PATH_INFO should be determined by using sCGIFullName:
             * 1) Let sCGIFullName not end in a "/" (see method findCGI)
             * 2) Let sCGIFullName equal the pathInfo fragment which
             *    corresponds to the actual cgi script.
             * 3) Thus, PATH_INFO = request.getPathInfo().substring(
             *                      sCGIFullName.length())
             *
             * (see method findCGI, where the real work is done)
             *
             */
            if (pathInfo == null || (pathInfo.substring(sCGIFullName.length()).isEmpty())) {
                sPathInfoCGI = "";
            } else {
                sPathInfoCGI = pathInfo.substring(sCGIFullName.length());
            }
            envp.put("PATH_INFO", sPathInfoCGI);


            /*-
             * PATH_TRANSLATED must be determined after PATH_INFO (and the
             * implied real cgi-script) has been taken into account.
             *
             * The following example demonstrates:
             *
             * servlet info   = /servlet/cgigw/dir1/dir2/cgi1/trans1/trans2
             * cgifullpath    = /servlet/cgigw/dir1/dir2/cgi1
             * path_info      = /trans1/trans2
             * webAppRootDir  = servletContext.getRealPath("/")
             *
             * path_translated = servletContext.getRealPath("/trans1/trans2")
             *
             * That is, PATH_TRANSLATED = webAppRootDir + sPathInfoCGI
             * (unless sPathInfoCGI is null or blank), then the CGI
             * specification dictates that the PATH_TRANSLATED metavariable
             * SHOULD NOT be defined.
             *
             */
            if (!sPathInfoCGI.isEmpty()) {
                sPathTranslatedCGI = context.getRealPath(sPathInfoCGI);
            }
            if (sPathTranslatedCGI == null || sPathTranslatedCGI.isEmpty()) {
                // NOOP
            } else {
                envp.put("PATH_TRANSLATED", nullsToBlanks(sPathTranslatedCGI));
            }


            envp.put("SCRIPT_NAME", nullsToBlanks(sCGIScriptName));

            envp.put("QUERY_STRING", nullsToBlanks(req.getQueryString()));

            envp.put("REMOTE_HOST", nullsToBlanks(req.getRemoteHost()));

            envp.put("REMOTE_ADDR", nullsToBlanks(req.getRemoteAddr()));

            envp.put("AUTH_TYPE", nullsToBlanks(req.getAuthType()));

            envp.put("REMOTE_USER", nullsToBlanks(req.getRemoteUser()));

            envp.put("REMOTE_IDENT", ""); // not necessary for full compliance

            envp.put("CONTENT_TYPE", nullsToBlanks(req.getContentType()));


            /*
             * Note CGI spec says CONTENT_LENGTH must be NULL ("") or undefined if there is no content, so we cannot put
             * 0 or -1 in as per the Servlet API spec.
             */
            long contentLength = req.getContentLengthLong();
            String sContentLength = (contentLength <= 0 ? "" : Long.toString(contentLength));
            envp.put("CONTENT_LENGTH", sContentLength);


            Enumeration<String> headers = req.getHeaderNames();
            String header;
            while (headers.hasMoreElements()) {
                header = headers.nextElement().toUpperCase(Locale.ENGLISH);
                // REMIND: rewrite multiple headers as if received as single
                // REMIND: change character set
                // REMIND: I forgot what the previous REMIND means
                if (envHttpHeadersPattern.matcher(header).matches()) {
                    envp.put("HTTP_" + header.replace('-', '_'), req.getHeader(header));
                }
            }

            File fCGIFullPath = new File(sCGIFullPath);
            command = fCGIFullPath.getCanonicalPath();

            envp.put("X_TOMCAT_SCRIPT_PATH", command); // for kicks

            envp.put("SCRIPT_FILENAME", command); // for PHP

            this.env = envp;

            return true;
        }

        /**
         * Returns important CGI environment information in a multi-line text format.
         *
         * @return CGI environment info
         */
        @Override
        public String toString() {

            StringBuilder sb = new StringBuilder();

            sb.append("CGIEnvironment Info:");
            sb.append(System.lineSeparator());

            if (isValid()) {
                sb.append("Validity: [true]");
                sb.append(System.lineSeparator());

                sb.append("Environment values:");
                sb.append(System.lineSeparator());
                for (Entry<String,String> entry : env.entrySet()) {
                    sb.append("  ");
                    sb.append(entry.getKey());
                    sb.append(": [");
                    sb.append(blanksToString(entry.getValue(), "will be set to blank"));
                    sb.append(']');
                    sb.append(System.lineSeparator());
                }

                sb.append("Derived Command :[");
                sb.append(nullsToBlanks(command));
                sb.append(']');
                sb.append(System.lineSeparator());


                sb.append("Working Directory: [");
                if (workingDirectory != null) {
                    sb.append(workingDirectory.toString());
                }
                sb.append(']');
                sb.append(System.lineSeparator());

                sb.append("Command Line Params:");
                sb.append(System.lineSeparator());
                for (String param : cmdLineParameters) {
                    sb.append("  [");
                    sb.append(param);
                    sb.append(']');
                    sb.append(System.lineSeparator());
                }
            } else {
                sb.append("Validity: [false]");
                sb.append(System.lineSeparator());
                sb.append("CGI script not found or not specified.");
                sb.append(System.lineSeparator());
                sb.append("Check the HttpServletRequest pathInfo property to see if it is what ");
                sb.append(System.lineSeparator());
                sb.append("you meant it to be. You must specify an existent and executable file ");
                sb.append(System.lineSeparator());
                sb.append("as part of the path-info.");
                sb.append(System.lineSeparator());
            }

            return sb.toString();
        }


        /**
         * Gets derived command string
         *
         * @return command string
         */
        protected String getCommand() {
            return command;
        }


        /**
         * Gets derived CGI working directory
         *
         * @return working directory
         */
        protected File getWorkingDirectory() {
            return workingDirectory;
        }


        /**
         * Gets derived CGI environment
         *
         * @return CGI environment
         */
        protected Hashtable<String,String> getEnvironment() {
            return env;
        }


        /**
         * Gets derived CGI query parameters
         *
         * @return CGI query parameters
         */
        protected ArrayList<String> getParameters() {
            return cmdLineParameters;
        }


        /**
         * Gets validity status
         *
         * @return true if this environment is valid, false otherwise
         */
        protected boolean isValid() {
            return valid;
        }


        /**
         * Converts null strings to blank strings ("")
         *
         * @param s string to be converted if necessary
         *
         * @return a non-null string, either the original or the empty string ("") if the original was <code>null</code>
         */
        protected String nullsToBlanks(String s) {
            return nullsToString(s, "");
        }


        /**
         * Converts null strings to another string
         *
         * @param couldBeNull string to be converted if necessary
         * @param subForNulls string to return instead of a null string
         *
         * @return a non-null string, either the original or the substitute string if the original was <code>null</code>
         */
        protected String nullsToString(String couldBeNull, String subForNulls) {
            return (couldBeNull == null ? subForNulls : couldBeNull);
        }


        /**
         * Converts blank strings to another string
         *
         * @param couldBeBlank string to be converted if necessary
         * @param subForBlanks string to return instead of a blank string
         *
         * @return a non-null string, either the original or the substitute string if the original was <code>null</code>
         *             or empty ("")
         */
        protected String blanksToString(String couldBeBlank, String subForBlanks) {
            return (couldBeBlank == null || couldBeBlank.isEmpty()) ? subForBlanks : couldBeBlank;
        }


    } // class CGIEnvironment


    /**
     * Encapsulates the knowledge of how to run a CGI script, given the script's desired environment and (optionally)
     * input/output streams
     * <p>
     * Exposes a <code>run</code> method used to actually invoke the CGI.
     * </p>
     * <p>
     * The CGI environment and settings are derived from the information passed to the constructor.
     * </p>
     * <p>
     * The input and output streams can be set by the <code>setInput</code> and <code>setResponse</code> methods,
     * respectively.
     * </p>
     */
    protected class CGIRunner {

        /** script/command to be executed */
        private final String command;

        /** environment used when invoking the cgi script */
        private final Hashtable<String,String> env;

        /** working directory used when invoking the cgi script */
        private final File wd;

        /** command line parameters to be passed to the invoked script */
        private final ArrayList<String> params;

        /** stdin to be passed to cgi script */
        private InputStream stdin = null;

        /** response object used to set headers & get output stream */
        private HttpServletResponse response = null;

        /** boolean tracking whether this object has enough info to run() */
        private boolean readyToRun = false;


        /**
         * Creates a CGIRunner and initializes its environment, working directory, and query parameters. <BR>
         * Input/output streams (optional) are set using the <code>setInput</code> and <code>setResponse</code> methods,
         * respectively.
         *
         * @param command string full path to command to be executed
         * @param env     Hashtable with the desired script environment
         * @param wd      File with the script's desired working directory
         * @param params  ArrayList with the script's query command line parameters as strings
         */
        protected CGIRunner(String command, Hashtable<String,String> env, File wd, ArrayList<String> params) {
            this.command = command;
            this.env = env;
            this.wd = wd;
            this.params = params;
            updateReadyStatus();
        }


        /**
         * Checks and sets ready status
         */
        protected void updateReadyStatus() {
            readyToRun = command != null && env != null && wd != null && params != null && response != null;
        }


        /**
         * Gets ready status
         *
         * @return false if not ready (<code>run</code> will throw an exception), true if ready
         */
        protected boolean isReady() {
            return readyToRun;
        }


        /**
         * Sets HttpServletResponse object used to set headers and send output to
         *
         * @param response HttpServletResponse to be used
         */
        protected void setResponse(HttpServletResponse response) {
            this.response = response;
            updateReadyStatus();
        }


        /**
         * Sets standard input to be passed on to the invoked cgi script
         *
         * @param stdin InputStream to be used
         */
        protected void setInput(InputStream stdin) {
            this.stdin = stdin;
            updateReadyStatus();
        }


        /**
         * Converts a Hashtable to a String array by converting each key/value pair in the Hashtable to a String in the
         * form "key=value" (hashkey + "=" + hash.get(hashkey).toString())
         *
         * @param h Hashtable to convert
         *
         * @return converted string array
         *
         * @exception NullPointerException if a hash key has a null value
         */
        protected String[] hashToStringArray(Hashtable<String,?> h) throws NullPointerException {
            List<String> list = new ArrayList<>(h.size());
            Enumeration<String> e = h.keys();
            while (e.hasMoreElements()) {
                String k = e.nextElement();
                list.add(k + "=" + h.get(k).toString());
            }
            return list.toArray(new String[0]);
        }


        /**
         * Executes a CGI script with the desired environment, current working directory, and input/output streams
         * <p>
         * This implements the following CGI specification recommendations:
         * </p>
         * <UL>
         * <LI>Servers SHOULD provide the "<code>query</code>" component of the script-URI as command-line arguments to
         * scripts if it does not contain any unencoded "=" characters and the command-line arguments can be generated
         * in an unambiguous manner.
         * <LI>Servers SHOULD set the AUTH_TYPE metavariable to the value of the "<code>auth-scheme</code>" token of the
         * "<code>Authorization</code>" if it was supplied as part of the request header. See
         * <code>getCGIEnvironment</code> method.
         * <LI>Where applicable, servers SHOULD set the current working directory to the directory in which the script
         * is located before invoking it.
         * <LI>Server implementations SHOULD define their behavior for the following cases:
         * <ul>
         * <LI><u>Allowed characters in pathInfo</u>: This implementation does not allow ASCII NUL nor any character
         * which cannot be URL-encoded according to internet standards;
         * <LI><u>Allowed characters in path segments</u>: This implementation does not allow non-terminal NULL segments
         * in the path -- IOExceptions may be thrown;
         * <LI><u>"<code>.</code>" and "<code>..</code>" path segments</u>: This implementation does not allow
         * "<code>.</code>" and "<code>..</code>" in the path, and such characters will result in an IOException being
         * thrown (this should never happen since Tomcat normalises the requestURI before determining the contextPath,
         * servletPath and pathInfo);
         * <LI><u>Implementation limitations</u>: This implementation does not impose any limitations except as
         * documented above. This implementation may be limited by the servlet container used to house this
         * implementation. In particular, all the primary CGI variable values are derived either directly or indirectly
         * from the container's implementation of the Servlet API methods.
         * </ul>
         * </UL>
         *
         * @exception IOException if problems during reading/writing occur
         *
         * @see java.lang.Runtime#exec(String[] command, String[] envp, File dir)
         */
        protected void run() throws IOException {

            /*
             * REMIND: this method feels too big; should it be re-written?
             */

            if (!isReady()) {
                throw new IOException(sm.getString("cgiServlet.notReady"));
            }

            if (log.isTraceEnabled()) {
                log.trace("envp: [" + env + "], command: [" + command + "]");
            }

            if ((command.contains(File.separator + "." + File.separator)) ||
                    (command.contains(File.separator + "..")) || (command.contains(".." + File.separator))) {
                throw new IOException(sm.getString("cgiServlet.invalidCommand", command));
            }

            Runtime rt;
            BufferedReader cgiHeaderReader = null;
            InputStream cgiOutput = null;
            BufferedReader commandsStdErr;
            Thread errReaderThread = null;
            BufferedOutputStream commandsStdIn;
            Process proc = null;
            int bufRead = -1;

            List<String> cmdAndArgs = new ArrayList<>();
            if (!cgiExecutable.isEmpty()) {
                cmdAndArgs.add(cgiExecutable);
            }
            if (cgiExecutableArgs != null) {
                cmdAndArgs.addAll(cgiExecutableArgs);
            }
            cmdAndArgs.add(command);
            cmdAndArgs.addAll(params);

            try {
                rt = Runtime.getRuntime();
                proc = rt.exec(cmdAndArgs.toArray(new String[0]), hashToStringArray(env), wd);

                String sContentLength = env.get("CONTENT_LENGTH");

                if (!"".equals(sContentLength)) {
                    commandsStdIn = new BufferedOutputStream(proc.getOutputStream());
                    IOTools.flow(stdin, commandsStdIn);
                    commandsStdIn.flush();
                    commandsStdIn.close();
                }

                /*
                 * we want to wait for the process to exit, Process.waitFor() is useless in our situation; see
                 * http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4223650
                 */

                boolean isRunning = true;
                commandsStdErr = new BufferedReader(new InputStreamReader(proc.getErrorStream()));
                final BufferedReader stdErrRdr = commandsStdErr;

                errReaderThread = new Thread(() -> sendToLog(stdErrRdr));
                errReaderThread.start();

                InputStream cgiHeaderStream = new HTTPHeaderInputStream(proc.getInputStream());
                cgiHeaderReader = new BufferedReader(new InputStreamReader(cgiHeaderStream));

                // Need to be careful here. If sendError() is called the
                // response body should be provided by the standard error page
                // process. But, if the output of the CGI process isn't read
                // then that process can hang.
                boolean skipBody = false;

                while (isRunning) {
                    try {
                        // set headers
                        String line;
                        while (((line = cgiHeaderReader.readLine()) != null) && !line.isEmpty()) {
                            if (log.isTraceEnabled()) {
                                log.trace("addHeader(\"" + line + "\")");
                            }
                            if (line.startsWith("HTTP")) {
                                skipBody = setStatus(response, getSCFromHttpStatusLine(line));
                            } else if (line.indexOf(':') >= 0) {
                                String header = line.substring(0, line.indexOf(':')).trim();
                                String value = line.substring(line.indexOf(':') + 1).trim();
                                if (header.equalsIgnoreCase("status")) {
                                    skipBody = setStatus(response, getSCFromCGIStatusHeader(value));
                                } else {
                                    response.addHeader(header, value);
                                }
                            } else {
                                log.info(sm.getString("cgiServlet.runBadHeader", line));
                            }
                        }

                        // write output
                        byte[] bBuf = new byte[2048];

                        OutputStream out = response.getOutputStream();
                        cgiOutput = proc.getInputStream();

                        try {
                            while (!skipBody && (bufRead = cgiOutput.read(bBuf)) != -1) {
                                if (log.isTraceEnabled()) {
                                    log.trace("output " + bufRead + " bytes of data");
                                }
                                out.write(bBuf, 0, bufRead);
                            }
                        } finally {
                            // Attempt to consume any leftover byte if something bad happens,
                            // such as a socket disconnect on the servlet side; otherwise, the
                            // external process could hang
                            if (bufRead != -1) {
                                while ((bufRead = cgiOutput.read(bBuf)) != -1) {
                                    // NOOP - just read the data
                                }
                            }
                        }

                        proc.exitValue(); // Throws exception if alive

                        isRunning = false;

                    } catch (IllegalThreadStateException e) {
                        try {
                            Thread.sleep(500);
                        } catch (InterruptedException ignored) {
                            // Ignore
                        }
                    }
                } // replacement for Process.waitFor()

            } catch (IOException ioe) {
                log.warn(sm.getString("cgiServlet.runFail"), ioe);
                throw ioe;
            } finally {
                // Close the header reader
                if (cgiHeaderReader != null) {
                    try {
                        cgiHeaderReader.close();
                    } catch (IOException ioe) {
                        log.warn(sm.getString("cgiServlet.runHeaderReaderFail"), ioe);
                    }
                }
                // Close the output stream if used
                if (cgiOutput != null) {
                    try {
                        cgiOutput.close();
                    } catch (IOException ioe) {
                        log.warn(sm.getString("cgiServlet.runOutputStreamFail"), ioe);
                    }
                }
                // Make sure the error stream reader has finished
                if (errReaderThread != null) {
                    try {
                        errReaderThread.join(stderrTimeout);
                    } catch (InterruptedException e) {
                        log.warn(sm.getString("cgiServlet.runReaderInterrupt"), e);
                    }
                }
                if (proc != null) {
                    proc.destroy();
                }
            }
        }

        /**
         * Parses the Status-Line and extracts the status code.
         *
         * @param line The HTTP Status-Line (RFC2616, section 6.1)
         *
         * @return The extracted status code or the code representing an internal error if a valid status code cannot be
         *             extracted.
         */
        private int getSCFromHttpStatusLine(String line) {
            int statusStart = line.indexOf(' ') + 1;

            if (statusStart < 1 || line.length() < statusStart + 3) {
                // Not a valid HTTP Status-Line
                log.warn(sm.getString("cgiServlet.runInvalidStatus", line));
                return HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
            }

            String status = line.substring(statusStart, statusStart + 3);

            int statusCode;
            try {
                statusCode = Integer.parseInt(status);
            } catch (NumberFormatException nfe) {
                // Not a valid status code
                log.warn(sm.getString("cgiServlet.runInvalidStatus", status));
                return HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
            }

            return statusCode;
        }

        /**
         * Parses the CGI Status Header value and extracts the status code.
         *
         * @param value The CGI Status value of the form <code>
         *             digit digit digit SP reason-phrase</code>
         *
         * @return The extracted status code or the code representing an internal error if a valid status code cannot be
         *             extracted.
         */
        private int getSCFromCGIStatusHeader(String value) {
            if (value.length() < 3) {
                // Not a valid status value
                log.warn(sm.getString("cgiServlet.runInvalidStatus", value));
                return HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
            }

            String status = value.substring(0, 3);

            int statusCode;
            try {
                statusCode = Integer.parseInt(status);
            } catch (NumberFormatException nfe) {
                // Not a valid status code
                log.warn(sm.getString("cgiServlet.runInvalidStatus", status));
                return HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
            }

            return statusCode;
        }

        private void sendToLog(BufferedReader rdr) {
            String line;
            int lineCount = 0;
            try {
                while ((line = rdr.readLine()) != null) {
                    log.warn(sm.getString("cgiServlet.runStdErr", line));
                    lineCount++;
                }
            } catch (IOException ioe) {
                log.warn(sm.getString("cgiServlet.runStdErrFail"), ioe);
            } finally {
                try {
                    rdr.close();
                } catch (IOException ioe) {
                    log.warn(sm.getString("cgiServlet.runStdErrFail"), ioe);
                }
            }
            if (lineCount > 0) {
                log.warn(sm.getString("cgiServlet.runStdErrCount", Integer.valueOf(lineCount)));
            }
        }
    } // class CGIRunner

    /**
     * This is an input stream specifically for reading HTTP headers. It reads up to and including the two blank lines
     * terminating the headers. It allows the content to be read using bytes or characters as appropriate.
     */
    protected static class HTTPHeaderInputStream extends InputStream {
        private static final int STATE_CHARACTER = 0;
        private static final int STATE_FIRST_CR = 1;
        private static final int STATE_FIRST_LF = 2;
        private static final int STATE_SECOND_CR = 3;
        private static final int STATE_HEADER_END = 4;

        private final InputStream input;
        private int state;

        HTTPHeaderInputStream(InputStream theInput) {
            input = theInput;
            state = STATE_CHARACTER;
        }

        /**
         * @see java.io.InputStream#read()
         */
        @Override
        public int read() throws IOException {
            if (state == STATE_HEADER_END) {
                return -1;
            }

            int i = input.read();

            /*
             * Update the state
             * State machine looks like this
             * @formatter:off
             *
             *    -------->--------
             *   |      (CR)       |
             *   |                 |
             *  CR1--->---         |
             *   |        |        |
             *   ^(CR)    |(LF)    |
             *   |        |        |
             * CHAR--->--LF1--->--EOH
             *      (LF)  |  (LF)  |
             *            |(CR)    ^(LF)
             *            |        |
             *          (CR2)-->---
             *
             * @formatter:on
             */
            if (i == 10) {
                // LF
                switch (state) {
                    case STATE_CHARACTER:
                        state = STATE_FIRST_LF;
                        break;
                    case STATE_FIRST_CR:
                        state = STATE_FIRST_LF;
                        break;
                    case STATE_FIRST_LF:
                    case STATE_SECOND_CR:
                        state = STATE_HEADER_END;
                        break;
                }

            } else if (i == 13) {
                // CR
                switch (state) {
                    case STATE_CHARACTER:
                        state = STATE_FIRST_CR;
                        break;
                    case STATE_FIRST_CR:
                        state = STATE_HEADER_END;
                        break;
                    case STATE_FIRST_LF:
                        state = STATE_SECOND_CR;
                        break;
                }

            } else {
                state = STATE_CHARACTER;
            }

            return i;
        }
    } // class HTTPHeaderInputStream

} // class CGIServlet
