/*
 * Copyright (C) 2009 The Android Open Source Project
 *
 * Licensed 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 libcore.java.lang;

import android.system.ErrnoException;
import android.system.Os;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
import java.lang.ProcessBuilder.Redirect;
import java.lang.ProcessBuilder.Redirect.Type;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import junit.framework.TestCase;
import libcore.io.IoUtils;

import static java.lang.ProcessBuilder.Redirect.INHERIT;
import static java.lang.ProcessBuilder.Redirect.PIPE;

public class ProcessBuilderTest extends TestCase {
    private static final String TAG = ProcessBuilderTest.class.getSimpleName();

    /**
     * Returns the path to a command that is in /system/bin/ on Android but
     * /bin/ elsewhere.
     *
     * @param desktopPath the command path outside Android; must start with /bin/.
     */
    private static String commandPath(String desktopPath) {
        if (!desktopPath.startsWith("/bin/")) {
            throw new IllegalArgumentException(desktopPath);
        }
        String devicePath = System.getenv("ANDROID_ROOT") + desktopPath;
        return new File(devicePath).exists() ? devicePath : desktopPath;
    }

    private static String shell() {
        return commandPath("/bin/sh");
    }

    private static void assertRedirectErrorStream(boolean doRedirect,
            String expectedOut, String expectedErr) throws Exception {
        ProcessBuilder pb = new ProcessBuilder(shell(), "-c", "echo out; echo err 1>&2");
        pb.redirectErrorStream(doRedirect);
        checkProcessExecution(pb, ResultCodes.ZERO,
                "" /* processInput */, expectedOut, expectedErr);
    }

    public void test_redirectErrorStream_true() throws Exception {
        assertRedirectErrorStream(true, "out\nerr\n", "");
    }

    public void test_redirectErrorStream_false() throws Exception {
        assertRedirectErrorStream(false, "out\n", "err\n");
    }

    public void testRedirectErrorStream_outputAndErrorAreMerged() throws Exception {
        Process process = new ProcessBuilder(shell())
                .redirectErrorStream(true)
                .start();
        try {
            int pid = getChildProcessPid(process);
            String path = "/proc/" + pid + "/fd/";
            assertEquals("stdout and stderr should point to the same socket",
                    Os.stat(path + "1").st_ino, Os.stat(path + "2").st_ino);
        } finally {
            process.destroy();
        }
    }

    /**
     * Tests that a child process can INHERIT this parent process's
     * stdin / stdout / stderr file descriptors.
     */
    public void testRedirectInherit() throws Exception {
        // We can't run shell() here because that exits when run with INHERITed
        // file descriptors from this process; "sleep" is less picky.
        Process process = new ProcessBuilder()
                .command(commandPath("/bin/sleep"), "5") // in seconds
                .redirectInput(Redirect.INHERIT)
                .redirectOutput(Redirect.INHERIT)
                .redirectError(Redirect.INHERIT)
                .start();
        try {
            List<Long> parentInodes = Arrays.asList(
                    Os.fstat(FileDescriptor.in).st_ino,
                    Os.fstat(FileDescriptor.out).st_ino,
                    Os.fstat(FileDescriptor.err).st_ino);
            int childPid = getChildProcessPid(process);
            // Get the inode numbers of the ends of the symlink chains
            List<Long> childInodes = Arrays.asList(
                    Os.stat("/proc/" + childPid + "/fd/0").st_ino,
                    Os.stat("/proc/" + childPid + "/fd/1").st_ino,
                    Os.stat("/proc/" + childPid + "/fd/2").st_ino);

            assertEquals(parentInodes, childInodes);
        } catch (ErrnoException e) {
            // Either (a) Os.fstat on our PID, or (b) Os.stat on our child's PID, failed.
            throw new AssertionError("stat failed; child process: " + process, e);
        } finally {
            process.destroy();
        }
    }

    public void testRedirectFile_input() throws Exception {
        String inputFileContents = "process input for testing\n" + TAG;
        File file = File.createTempFile(TAG, "in");
        try (Writer writer = new FileWriter(file)) {
            writer.write(inputFileContents);
        }
        ProcessBuilder pb = new ProcessBuilder(shell(), "-c", "cat").redirectInput(file);
        checkProcessExecution(pb, ResultCodes.ZERO, /* processInput */ "",
                /* expectedOutput */ inputFileContents, /* expectedError */ "");
        assertTrue(file.delete());
    }

    public void testRedirectFile_output() throws Exception {
        File file = File.createTempFile(TAG, "out");
        String processInput = TAG + "\narbitrary string for testing!";
        ProcessBuilder pb = new ProcessBuilder(shell(), "-c", "cat").redirectOutput(file);
        checkProcessExecution(pb, ResultCodes.ZERO, processInput,
                /* expectedOutput */ "", /* expectedError */ "");

        String fileContents = new String(IoUtils.readFileAsByteArray(
                file.getAbsolutePath()));
        assertEquals(processInput, fileContents);
        assertTrue(file.delete());
    }

    public void testRedirectFile_error() throws Exception {
        File file = File.createTempFile(TAG, "err");
        String processInput = "";
        String missingFilePath = "/test-missing-file-" + TAG;
        ProcessBuilder pb = new ProcessBuilder("ls", missingFilePath).redirectError(file);
        checkProcessExecution(pb, ResultCodes.NONZERO, processInput,
                /* expectedOutput */ "", /* expectedError */ "");

        String fileContents = new String(IoUtils.readFileAsByteArray(file.getAbsolutePath()));
        assertTrue(file.delete());
        // We assume that the path of the missing file occurs in the ls stderr.
        assertTrue("Unexpected output: " + fileContents,
                fileContents.contains(missingFilePath) && !fileContents.equals(missingFilePath));
    }

    public void testRedirectPipe_inputAndOutput() throws Exception {
        //checkProcessExecution(pb, expectedResultCode, processInput, expectedOutput, expectedError)

        String testString = "process input and output for testing\n" + TAG;
        {
            ProcessBuilder pb = new ProcessBuilder(shell(), "-c", "cat")
                    .redirectInput(PIPE)
                    .redirectOutput(PIPE);
            checkProcessExecution(pb, ResultCodes.ZERO, testString, testString, "");
        }

        // Check again without specifying PIPE explicitly, since that is the default
        {
        ProcessBuilder pb = new ProcessBuilder(shell(), "-c", "cat");
        checkProcessExecution(pb, ResultCodes.ZERO, testString, testString, "");
        }

        // Because the above test is symmetric regarding input vs. output, test
        // another case where input and output are different.
        {
            ProcessBuilder pb = new ProcessBuilder("echo", testString);
            checkProcessExecution(pb, ResultCodes.ZERO, "", testString + "\n", "");
        }
    }

    public void testRedirectPipe_error() throws Exception {
        String missingFilePath = "/test-missing-file-" + TAG;

        // Can't use checkProcessExecution() because we don't want to rely on an exact error content
        Process process = new ProcessBuilder("ls", missingFilePath)
                .redirectError(Redirect.PIPE).start();
        process.getOutputStream().close(); // no process input
        int resultCode = process.waitFor();
        ResultCodes.NONZERO.assertMatches(resultCode);
        assertEquals("", readAsString(process.getInputStream())); // no process output
        String errorString = readAsString(process.getErrorStream());
        // We assume that the path of the missing file occurs in the ls stderr.
        assertTrue("Unexpected output: " + errorString,
                errorString.contains(missingFilePath) && !errorString.equals(missingFilePath));
    }

    public void testRedirect_nullStreams() throws IOException {
        Process process = new ProcessBuilder()
                .command(shell())
                .inheritIO()
                .start();
        try {
            assertNullInputStream(process.getInputStream());
            assertNullOutputStream(process.getOutputStream());
            assertNullInputStream(process.getErrorStream());
        } finally {
            process.destroy();
        }
    }

    public void testRedirectErrorStream_nullStream() throws IOException {
        Process process = new ProcessBuilder()
                .command(shell())
                .redirectErrorStream(true)
                .start();
        try {
            assertNullInputStream(process.getErrorStream());
        } finally {
            process.destroy();
        }
    }

    public void testEnvironment() throws Exception {
        ProcessBuilder pb = new ProcessBuilder(shell(), "-c", "echo $A");
        pb.environment().put("A", "android");
        checkProcessExecution(pb, ResultCodes.ZERO, "", "android\n", "");
    }

    public void testDestroyClosesEverything() throws IOException {
        Process process = new ProcessBuilder(shell(), "-c", "echo out; echo err 1>&2").start();
        InputStream in = process.getInputStream();
        InputStream err = process.getErrorStream();
        OutputStream out = process.getOutputStream();
        process.destroy();

        try {
            in.read();
            fail();
        } catch (IOException expected) {
        }
        try {
            err.read();
            fail();
        } catch (IOException expected) {
        }
        try {
            /*
             * We test write+flush because the RI returns a wrapped stream, but
             * only bothers to close the underlying stream.
             */
            out.write(1);
            out.flush();
            fail();
        } catch (IOException expected) {
        }
    }

    public void testDestroyDoesNotLeak() throws IOException {
        Process process = new ProcessBuilder(shell(), "-c", "echo out; echo err 1>&2").start();
        process.destroy();
    }

    public void testEnvironmentMapForbidsNulls() throws Exception {
        ProcessBuilder pb = new ProcessBuilder(shell(), "-c", "echo $A");
        Map<String, String> environment = pb.environment();
        Map<String, String> before = new HashMap<String, String>(environment);
        try {
            environment.put("A", null);
            fail();
        } catch (NullPointerException expected) {
        }
        try {
            environment.put(null, "android");
            fail();
        } catch (NullPointerException expected) {
        }
        try {
            environment.containsKey(null);
            fail("Attempting to check the presence of a null key should throw");
        } catch (NullPointerException expected) {
        }
        try {
            environment.containsValue(null);
            fail("Attempting to check the presence of a null value should throw");
        } catch (NullPointerException expected) {
        }
        assertEquals(before, environment);
    }

    /**
     * Tests attempting to query the presence of a non-String key or value
     * in the environment map. Since that is a {@code Map<String, String>},
     * it's hard to imagine this ever breaking, but it's good to have a test
     * since it's called out in the documentation.
     */
    @SuppressWarnings("CollectionIncompatibleType")
    public void testEnvironmentMapForbidsNonStringKeysAndValues() {
        ProcessBuilder pb = new ProcessBuilder("echo", "Hello, world!");
        Map<String, String> environment = pb.environment();
        Integer nonString = Integer.valueOf(23);
        try {
            environment.containsKey(nonString);
            fail("Attempting to query the presence of a non-String key should throw");
        } catch (ClassCastException expected) {
        }
        try {
            environment.get(nonString);
            fail("Attempting to query the presence of a non-String key should throw");
        } catch (ClassCastException expected) {
        }
        try {
            environment.containsValue(nonString);
            fail("Attempting to query the presence of a non-String value should throw");
        } catch (ClassCastException expected) {
        }
    }

    /**
     * Checks that INHERIT and PIPE tend to have different hashCodes
     * in any particular instance of the runtime.
     * We test this by asserting that they use the identity hashCode,
     * which is a sufficient but not necessary condition for this.
     * If the implementation changes to a different sufficient condition
     * in future, this test should be updated accordingly.
     */
    public void testRedirect_inheritAndPipeTendToHaveDifferentHashCode() {
        assertIdentityHashCode(INHERIT);
        assertIdentityHashCode(PIPE);
    }

    public void testRedirect_hashCodeDependsOnFile() {
        File file = new File("/tmp/file");
        File otherFile = new File("/tmp/some_other_file") {
            @Override public int hashCode() { return 1 + file.hashCode(); }
        };
        Redirect a = Redirect.from(file);
        Redirect b = Redirect.from(otherFile);
        assertFalse("Unexpectedly equal hashCode: " + a + " vs. " + b,
                a.hashCode() == b.hashCode());
    }

    /**
     * Tests that {@link Redirect}'s equals() and hashCode() is sane.
     */
    public void testRedirect_equals() {
        File fileA = new File("/tmp/fileA");
        File fileB = new File("/tmp/fileB");
        File fileB2 = new File("/tmp/fileB");
        // check that test is set up correctly
        assertFalse(fileA.equals(fileB));
        assertEquals(fileB, fileB2);

        assertSymmetricEquals(Redirect.appendTo(fileB), Redirect.appendTo(fileB2));
        assertSymmetricEquals(Redirect.from(fileB), Redirect.from(fileB2));
        assertSymmetricEquals(Redirect.to(fileB), Redirect.to(fileB2));

        Redirect[] redirects = new Redirect[] {
                INHERIT,
                PIPE,
                Redirect.appendTo(fileA),
                Redirect.from(fileA),
                Redirect.to(fileA),
                Redirect.appendTo(fileB),
                Redirect.from(fileB),
                Redirect.to(fileB),
        };
        for (Redirect a : redirects) {
            for (Redirect b : redirects) {
                if (a != b) {
                    assertFalse("Unexpectedly equal: " + a + " vs. " + b, a.equals(b));
                    assertFalse("Unexpected asymmetric equality: " + a + " vs. " + b, b.equals(a));
                }
            }
        }
    }

    /**
     * Tests the {@link Redirect#type() type} and {@link Redirect#file() file} of
     * various Redirects. These guarantees are made in the respective javadocs,
     * so we're testing them together here.
     */
    public void testRedirect_fileAndType() {
        File file = new File("/tmp/fake-file-for/java.lang.ProcessBuilderTest");
        assertRedirectFileAndType(null, Type.INHERIT, INHERIT);
        assertRedirectFileAndType(null, Type.PIPE, PIPE);
        assertRedirectFileAndType(file, Type.APPEND, Redirect.appendTo(file));
        assertRedirectFileAndType(file, Type.READ, Redirect.from(file));
        assertRedirectFileAndType(file, Type.WRITE, Redirect.to(file));
    }

    private static void assertRedirectFileAndType(File expectedFile, Type expectedType,
            Redirect redirect) {
        assertEquals(redirect.toString(), expectedFile, redirect.file());
        assertEquals(redirect.toString(), expectedType, redirect.type());
    }

    public void testRedirect_defaultsToPipe() {
        assertRedirects(PIPE, PIPE, PIPE, new ProcessBuilder());
    }

    public void testRedirect_setAndGet() {
        File file = new File("/tmp/fake-file-for/java.lang.ProcessBuilderTest");
        assertRedirects(Redirect.from(file), PIPE, PIPE, new ProcessBuilder().redirectInput(file));
        assertRedirects(PIPE, Redirect.to(file), PIPE, new ProcessBuilder().redirectOutput(file));
        assertRedirects(PIPE, PIPE, Redirect.to(file), new ProcessBuilder().redirectError(file));
        assertRedirects(Redirect.from(file), INHERIT, Redirect.to(file),
                new ProcessBuilder()
                        .redirectInput(PIPE)
                        .redirectOutput(INHERIT)
                        .redirectError(file)
                        .redirectInput(file));

        assertRedirects(Redirect.INHERIT, Redirect.INHERIT, Redirect.INHERIT,
                new ProcessBuilder().inheritIO());
    }

    public void testCommand_setAndGet() {
        List<String> expected = Collections.unmodifiableList(
                Arrays.asList("echo", "fake", "command", "for", TAG));
        assertEquals(expected, new ProcessBuilder().command(expected).command());
        assertEquals(expected, new ProcessBuilder().command("echo", "fake", "command", "for", TAG)
                .command());
    }

    public void testDirectory_setAndGet() {
        File directory = new File("/tmp/fake/directory/for/" + TAG);
        assertEquals(directory, new ProcessBuilder().directory(directory).directory());
        assertNull(new ProcessBuilder().directory());
        assertNull(new ProcessBuilder()
                .directory(directory)
                .directory(null)
                .directory());
    }

    /**
     * One or more result codes returned by {@link Process#waitFor()}.
     */
    enum ResultCodes {
        ZERO { @Override void assertMatches(int actualResultCode) {
            assertEquals(0, actualResultCode);
        } },
        NONZERO { @Override void assertMatches(int actualResultCode) {
            assertTrue("Expected resultCode != 0, got 0", actualResultCode != 0);
        } };

        /** asserts that the given code falls within this ResultCodes */
        abstract void assertMatches(int actualResultCode);
    }

    /**
     * Starts the specified process, writes the specified input to it and waits for the process
     * to finish; then, then checks that the result code and output / error are expected.
     *
     * <p>This method assumes that the process consumes and produces character data encoded with
     * the platform default charset.
     */
    private static void checkProcessExecution(ProcessBuilder pb,
            ResultCodes expectedResultCode, String processInput,
            String expectedOutput, String expectedError) throws Exception {
        Process process = pb.start();
        Future<String> processOutput = asyncRead(process.getInputStream());
        Future<String> processError = asyncRead(process.getErrorStream());
        try (OutputStream outputStream = process.getOutputStream()) {
            outputStream.write(processInput.getBytes(Charset.defaultCharset()));
        }
        int actualResultCode = process.waitFor();
        expectedResultCode.assertMatches(actualResultCode);
        assertEquals(expectedOutput, processOutput.get());
        assertEquals(expectedError, processError.get());
    }

    /**
     * Asserts that inputStream is a <a href="ProcessBuilder#redirect-input">null input stream</a>.
     */
    private static void assertNullInputStream(InputStream inputStream) throws IOException {
        assertEquals(-1, inputStream.read());
        assertEquals(0, inputStream.available());
        inputStream.close(); // should do nothing
    }

    /**
     * Asserts that outputStream is a <a href="ProcessBuilder#redirect-output">null output
     * stream</a>.
     */
    private static void assertNullOutputStream(OutputStream outputStream) throws IOException {
        try {
            outputStream.write(42);
            fail("NullOutputStream.write(int) must throw IOException: " + outputStream);
        } catch (IOException expected) {
            // expected
        }
        outputStream.close(); // should do nothing
    }

    private static void assertRedirects(Redirect in, Redirect out, Redirect err, ProcessBuilder pb) {
        List<Redirect> expected = Arrays.asList(in, out, err);
        List<Redirect> actual = Arrays.asList(
                pb.redirectInput(), pb.redirectOutput(), pb.redirectError());
        assertEquals(expected, actual);
    }

    private static void assertIdentityHashCode(Redirect redirect) {
        assertEquals(System.identityHashCode(redirect), redirect.hashCode());
    }

    private static void assertSymmetricEquals(Redirect a, Redirect b) {
        assertEquals(a, b);
        assertEquals(b, a);
        assertEquals(a.hashCode(), b.hashCode());
    }

    private static int getChildProcessPid(Process process) {
        // Hack: UNIXProcess.pid is private; parse toString() instead of reflection
        Matcher matcher = Pattern.compile("pid=(\\d+)").matcher(process.toString());
        assertTrue("Can't find PID in: " + process, matcher.find());
        int result = Integer.parseInt(matcher.group(1));
        return result;
    }

    static String readAsString(InputStream inputStream) throws IOException {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        byte[] data = new byte[1024];
        int numRead;
        while ((numRead = inputStream.read(data)) >= 0) {
            outputStream.write(data, 0, numRead);
        }
        return new String(outputStream.toByteArray(), Charset.defaultCharset());
    }

    /**
     * Reads the entire specified {@code inputStream} asynchronously.
     */
    static FutureTask<String> asyncRead(final InputStream inputStream) {
        final FutureTask<String> result = new FutureTask<>(() -> readAsString(inputStream));
        new Thread("read asynchronously from " + inputStream) {
            @Override
            public void run() {
                result.run();
            }
        }.start();
        return result;
    }

}
