ExecUtil.java

package org.mozilla.javascript.tools.shell;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.ScriptRuntime;
import org.mozilla.javascript.Scriptable;
import org.mozilla.javascript.ScriptableObject;
import org.mozilla.javascript.Undefined;
import org.mozilla.javascript.Wrapper;

/**
 * Helper class for <code>global.runCommand</code>.
 *
 * @author Roland Praml, Foconis Analytics GmbH
 */
@SuppressWarnings("AndroidJdkLibsChecker")
class ExecUtil {
    private ExecUtil() {}

    private static final Object[] emptyArray = new Object[0];

    static int runCommand(Global global, Scriptable thisObj, Object[] args) throws IOException {
        int len = args.length;
        if (len == 0 || (len == 1 && args[0] instanceof Scriptable)) {
            throw Global.reportRuntimeError("msg.runCommand.bad.args");
        }

        String[] environment = null;
        File wd = null;
        InputStream in = null;
        OutputStream out = null;
        OutputStream err = null;
        Scriptable params = null;
        Global.CommandExecutor commandExecutor = null;
        Object[] addArgs = emptyArray;
        int timeout = 0;

        if (args[len - 1] instanceof Scriptable) {
            // if the last argument is an object, parse
            // "env", "dir", "input", "output", "err", "timeout" and
            // additional "args"
            params = (Scriptable) args[--len];
            environment = parseEnvironment(params);
            wd = parseWorkingDir(params);
            in = parseInput(params);
            out = parseOutput(params, "output");
            err = parseOutput(params, "err");
            timeout = parseTimeout(params);
            addArgs = parseAddArgs(params, thisObj);
            commandExecutor = parseCommandLauncher(params);
        }

        if (out == null) {
            out = global.getOut();
        }
        if (err == null) {
            err = global.getErr();
        }
        if (commandExecutor == null) {
            commandExecutor = Runtime.getRuntime()::exec;
        }

        // If no explicit input stream, do not send any input to process,
        // in particular, do not use System.in to avoid deadlocks
        // when waiting for user input to send to process which is already
        // terminated as it is not always possible to interrupt read method.

        String[] cmd = new String[len + addArgs.length];
        for (int i = 0; i != len; ++i) {
            cmd[i] = ScriptRuntime.toString(args[i]);
        }
        for (int i = 0; i != addArgs.length; ++i) {
            cmd[len + i] = ScriptRuntime.toString(addArgs[i]);
        }
        try {
            Process p = commandExecutor.exec(cmd, environment, wd);
            return waitForProcess(p, in, out, err, timeout);
        } finally {
            if (out instanceof ReturnBuffer) {
                ScriptableObject.putProperty(params, "output", out.toString());
            }
            if (err instanceof ReturnBuffer) {
                ScriptableObject.putProperty(params, "err", out.toString());
            }
        }
    }

    private static String[] parseEnvironment(Scriptable params) {
        Object obj = ScriptableObject.getProperty(params, "env");
        if (obj == Scriptable.NOT_FOUND) {
            return null;
        }
        if (obj == null) {
            return new String[0];
        } else {
            if (!(obj instanceof Scriptable)) {
                throw Global.reportRuntimeError("msg.runCommand.bad.env");
            }
            Scriptable envHash = (Scriptable) obj;
            Object[] ids = ScriptableObject.getPropertyIds(envHash);
            String[] environment = new String[ids.length];
            for (int i = 0; i != ids.length; ++i) {
                Object keyObj = ids[i], val;
                String key;
                if (keyObj instanceof String) {
                    key = (String) keyObj;
                    val = ScriptableObject.getProperty(envHash, key);
                } else {
                    int ikey = ((Number) keyObj).intValue();
                    key = Integer.toString(ikey);
                    val = ScriptableObject.getProperty(envHash, ikey);
                }
                if (val == ScriptableObject.NOT_FOUND) {
                    val = Undefined.instance;
                }
                environment[i] = key + '=' + ScriptRuntime.toString(val);
            }
            return environment;
        }
    }

    private static File parseWorkingDir(Scriptable params) {
        Object obj = ScriptableObject.getProperty(params, "dir");
        if (obj == Scriptable.NOT_FOUND) {
            return null;
        }
        if (obj instanceof Wrapper) {
            obj = ((Wrapper) obj).unwrap();
        }
        if (obj instanceof File) {
            return (File) obj;
        }
        return new File(ScriptRuntime.toString(obj));
    }

    private static InputStream parseInput(Scriptable params) throws IOException {
        Object obj = ScriptableObject.getProperty(params, "input");
        if (obj == Scriptable.NOT_FOUND) {
            return null;
        }
        if (obj instanceof Wrapper) {
            obj = ((Wrapper) obj).unwrap();
        }
        if (obj instanceof InputStream) {
            return (InputStream) obj;
        }
        if (obj instanceof byte[]) {
            return new ByteArrayInputStream((byte[]) obj);
        }
        String s;
        if (obj instanceof Reader) {
            s = Global.readReader((Reader) obj);
        } else if (obj instanceof char[]) {
            s = new String((char[]) obj);
        } else {
            s = ScriptRuntime.toString(obj);
        }
        return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8));
    }

    private static OutputStream parseOutput(Scriptable params, String type) {
        Object obj = ScriptableObject.getProperty(params, type);
        if (obj == Scriptable.NOT_FOUND) {
            return null;
        }
        if (obj instanceof Wrapper) {
            obj = ((Wrapper) obj).unwrap();
        }
        if (obj instanceof OutputStream) {
            return (OutputStream) obj;
        }
        return new ReturnBuffer(ScriptRuntime.toString(obj));
    }

    private static int parseTimeout(Scriptable params) {
        Object obj = ScriptableObject.getProperty(params, "timeout");
        if (obj == Scriptable.NOT_FOUND) {
            return -1;
        }
        return ScriptRuntime.toInt32(obj);
    }

    private static Object[] parseAddArgs(Scriptable params, Scriptable scope) {
        Object obj = ScriptableObject.getProperty(params, "args");
        if (obj == Scriptable.NOT_FOUND) {
            return emptyArray;
        }
        Scriptable s = Context.toObject(obj, Global.getTopLevelScope(scope));
        return ScriptRuntime.getArrayElements(s);
    }

    private static Global.CommandExecutor parseCommandLauncher(Scriptable params) {
        Object obj = ScriptableObject.getProperty(params, "commandExecutor");
        if (obj == Scriptable.NOT_FOUND) {
            return null;
        }
        if (obj instanceof Wrapper) {
            obj = ((Wrapper) obj).unwrap();
        }
        return (Global.CommandExecutor) obj;
    }

    /**
     * Waits for the process and passes input- and outputStream. If any of in, out, err is null, the
     * corresponding process stream will be closed immediately, otherwise it will be closed as soon
     * as all data will be read from/written to process
     *
     * @return Exit value of process.
     * @throws IOException If there was an error executing the process.
     */
    private static int waitForProcess(
            Process p, InputStream in, OutputStream out, OutputStream err, int timeout)
            throws IOException {

        // use try-with-resources with sophisticated error handling
        // When multiple streams will throw an error, the errors are added as suppressed exceptions
        // and do not hide the original exception.
        try (PipeThread inThread = startPipeThread(in, p.getOutputStream(), p.getOutputStream());
                PipeThread outThread =
                        startPipeThread(p.getInputStream(), out, p.getInputStream());
                PipeThread errThread =
                        startPipeThread(p.getErrorStream(), err, p.getErrorStream());
                KillThread killThread = startKillThread(p, timeout)) {

            try {
                // wait for process completion
                p.waitFor();
            } catch (InterruptedException ignore) {
                Thread.currentThread().interrupt();
            }

            int exitCode = p.exitValue();
            if (killThread != null && killThread.killed) {
                // on abnormal process termination - do not throw errors of streams in
                // try-with-resources
                if (inThread != null) inThread.reportErrors = false;
                if (outThread != null) outThread.reportErrors = false;
                if (errThread != null) errThread.reportErrors = false;
            }
            return exitCode;
        } finally {
            p.destroy();
        }
    }

    /** Creates a kill-thread, that Kills the process after specified timeout. */
    private static KillThread startKillThread(Process process, int timeout) {
        if (timeout <= 0) {
            return null;
        }
        KillThread killThread = new KillThread(process, timeout);
        killThread.start();
        return killThread;
    }

    /**
     * Creates a pipe-thread, that transfers all data from <code>in</code> to <code>out</code>.
     *
     * <p>The <code>processStream</code> is closed, when everything is transferred and may signalize
     * the process, to terminate.
     */
    private static PipeThread startPipeThread(
            InputStream in, OutputStream out, Closeable processStream) throws IOException {
        if (in == null) {
            out.close();
            return null;
        } else if (out == null) {
            in.close();
            return null;
        } else {
            PipeThread pipeThread = new PipeThread(in, out, processStream);
            pipeThread.start();
            return pipeThread;
        }
    }

    /**
     * A PipeThread transfers data from <code>in</code> to <code>out</code>. When an error occurs,
     * the error is stored and thrown in close().
     */
    private static class PipeThread extends Thread implements AutoCloseable {
        private final OutputStream out;
        private final InputStream in;
        private final Closeable streamOfProcess;
        private Throwable error;
        boolean reportErrors = true;

        /**
         * Creates a new PipeThread that transfers data from <code>in</code> to <code>out</code>
         *
         * @param in the source
         * @param out the destination
         * @param streamOfProcess the stream of the process (must be either <code>in</code> or
         *     <code>out</code>) this stream will be closed and IOExceptions on this stream will be
         *     ignored. The other stream will not be closed!
         */
        PipeThread(InputStream in, OutputStream out, Closeable streamOfProcess) {
            setDaemon(true);
            this.in = in;
            this.out = out;
            this.streamOfProcess = streamOfProcess;
        }

        @Override
        public final void run() {
            byte[] buffer = new byte[8192];
            int read;
            try {
                // normally we would use in.transferTo(out), but we do not want
                // capture exceptions of the process-stream, so there are two code
                // paths to handle this
                if (in == streamOfProcess) {
                    while ((read = readNoThrow(buffer)) >= 0) {
                        out.write(buffer, 0, read);
                        out.flush();
                    }
                } else {
                    assert out == streamOfProcess;
                    while ((read = in.read(buffer, 0, buffer.length)) >= 0) {
                        try {
                            out.write(buffer, 0, read);
                            out.flush();
                        } catch (IOException e) {
                            break;
                        }
                    }
                }
            } catch (Throwable t) {
                error = t;
            } finally {
                try {
                    streamOfProcess.close();
                } catch (IOException ignore) {
                    // ignore exception at end.
                }
            }
        }

        // helper: reads from stdOut/stdErr without throwing exceptions
        private int readNoThrow(byte[] buffer) {
            try {
                return in.read(buffer, 0, buffer.length);
            } catch (IOException e) {
                return -1;
            }
        }

        @Override
        public void close() {
            for (; ; ) {
                try {
                    join();
                    break;
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
            if (reportErrors && error != null) {
                throw Context.throwAsScriptRuntimeEx(error);
            }
        }
    }

    /** Used to kill process after timeout. */
    private static class KillThread extends Thread implements AutoCloseable {
        private final Process process;
        private final int timeout;
        volatile boolean killed;

        public KillThread(Process process, int timeout) {
            setDaemon(true);
            this.process = process;
            this.timeout = timeout;
        }

        @Override
        public void run() {
            try {
                Thread.sleep(timeout);
                killed = true;
                process.destroy();
            } catch (InterruptedException e) {
                interrupt(); // re-interrupt this thread.
            }
        }

        @Override
        public void close() {
            interrupt();
        }
    }

    /**
     * Used as marked ByteArrayOutputStream. It indicates, that the content should be returned in
     * the "args" object.
     */
    private static class ReturnBuffer extends ByteArrayOutputStream {

        public ReturnBuffer(String init) {
            writeBytes(init.getBytes(StandardCharsets.UTF_8));
        }

        @Override
        public synchronized String toString() {
            return super.toString(StandardCharsets.UTF_8);
        }
    }
}