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

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipFile;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.storm.shade.org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.storm.shade.org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.storm.testing.TmpPath;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ServerUtilsTest {

    public static final Logger LOG = LoggerFactory.getLogger(ServerUtilsTest.class);

    @Test
    public void testExtractZipFileDisallowsPathTraversal() throws Exception {
        try (TmpPath path = new TmpPath()) {
            Path testRoot = Paths.get(path.getPath());
            Path extractionDest = testRoot.resolve("dest");
            Files.createDirectories(extractionDest);

            /*
             * Contains good.txt and ../evil.txt. Evil.txt will path outside the target dir, and should not be extracted.
             */
            try (ZipFile zip = new ZipFile(Paths.get("src/test/resources/evil-path-traversal.jar").toFile())) {
                ServerUtils.extractZipFile(zip, extractionDest.toFile(), null);
            }
            
            assertThat(Files.exists(extractionDest.resolve("good.txt")), is(true));
            assertThat(Files.exists(testRoot.resolve("evil.txt")), is(false));
        }
    }
    
    @Test
    public void testExtractZipFileDisallowsPathTraversalWhenUsingPrefix() throws Exception {
        try (TmpPath path = new TmpPath()) {
            Path testRoot = Paths.get(path.getPath());
            Path destParent = testRoot.resolve("outer");
            Path extractionDest = destParent.resolve("resources");
            Files.createDirectories(extractionDest);

            /*
             * Contains resources/good.txt and resources/../evil.txt. Evil.txt should not be extracted as it would end
             * up outside the extraction dest.
             */
            try (ZipFile zip = new ZipFile(Paths.get("src/test/resources/evil-path-traversal-resources.jar").toFile())) {
                ServerUtils.extractZipFile(zip, extractionDest.toFile(), "resources");
            }
            
            assertThat(Files.exists(extractionDest.resolve("good.txt")), is(true));
            assertThat(Files.exists(extractionDest.resolve("evil.txt")), is(false));
            assertThat(Files.exists(destParent.resolve("evil.txt")), is(false));
        }
    }

    /**
     * Get running processes for all or specified user.
     *
     * @param user Null for all users, otherwise specify the user. e.g. "root"
     * @return List of ProcessIds for the user
     * @throws IOException thrown when reading process I/O
     */
    private Collection<Long> getRunningProcessIds(String user) throws IOException {
        // get list of few running processes
        Collection<Long> pids = new ArrayList<>();
        int pidIndex = 0;
        String cmd;
        if (ServerUtils.IS_ON_WINDOWS) {
            cmd = "tasklist";
            pidIndex = 1;
        } else {
            cmd = (user == null) ? "ps -e" : "ps -U " + user;
        }
        Process p = Runtime.getRuntime().exec(cmd);
        try (BufferedReader input = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
            String line;
            while ((line = input.readLine()) != null) {
                line = line.trim();
                if (line.isEmpty()) {
                    continue;
                }
                try {
                    String pidStr = line.split("\\s+")[pidIndex];
                    if (pidStr.equalsIgnoreCase("pid")) {
                        continue; // header line
                    }
                    if (!StringUtils.isNumeric(pidStr)) {
                        LOG.debug("Ignoring line \"{}\" while looking for PIDs in output of \"{}\"", line, cmd);
                        continue;
                    }
                    pids.add(Long.parseLong(pidStr));
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            }
        }
        return pids;
    }

    @Test
    public void testIsProcessAlive() throws Exception {
        // specific selected process should not be alive for a randomly generated user
        String randomUser = RandomStringUtils.randomAlphanumeric(12);

        // get list of few running processes
        Collection<Long> pids = getRunningProcessIds(null);
        assertFalse(pids.isEmpty());
        for (long pid: pids) {
            boolean status = ServerUtils.isProcessAlive(pid, randomUser);
            assertFalse(status, "Random user " + randomUser + " is not expected to own any process");
        }

        boolean status = false;
        String currentUser = System.getProperty("user.name");
        for (long pid: pids) {
            // at least one pid will be owned by the current user (doing the testing)
            if (ServerUtils.isProcessAlive(pid, currentUser)) {
                status = true;
                break;
            }
        }
        assertTrue(status, "Expecting user " + currentUser + " to own at least one process");
    }

    @Test
    public void testIsAnyProcessAlive() throws Exception {
        // no process should be alive for a randomly generated user
        String randomUser = RandomStringUtils.randomAlphanumeric(12);
        Collection<Long> pids = getRunningProcessIds(null);

        assertFalse(pids.isEmpty());
        boolean status = ServerUtils.isAnyProcessAlive(pids, randomUser);
        assertFalse(status, "Random user " + randomUser + " is not expected to own any process");

        // at least one pid will be owned by the current user (doing the testing)
        String currentUser = System.getProperty("user.name");
        status = ServerUtils.isAnyProcessAlive(pids, currentUser);
        assertTrue(status, "Expecting user " + currentUser + " to own at least one process");

        if (!ServerUtils.IS_ON_WINDOWS) {
            // userid test is valid only on Posix platforms
            int inValidUserId = -1;
            status = ServerUtils.isAnyProcessAlive(pids, inValidUserId);
            assertFalse(status, "Invalid userId " + randomUser + " is not expected to own any process");

            int currentUid = ServerUtils.getUserId(null);
            status = ServerUtils.isAnyProcessAlive(pids, currentUid);
            assertTrue(status, "Expecting uid " + currentUid + " to own at least one process");
        }
    }

    @Test
    public void testGetUserId() throws Exception {
        if (ServerUtils.IS_ON_WINDOWS) {
            return; // trivially succeed on Windows, since this test is not for Windows platform
        }
        int uid1 = ServerUtils.getUserId(null);
        Path p = Files.createTempFile("testGetUser", ".txt");
        int uid2 = ServerUtils.getPathOwnerUid(p.toString());
        if (!p.toFile().delete()) {
            LOG.warn("Could not delete temporary file {}", p);
        }
        assertEquals(uid1, uid2,
            "User UID " + uid1 + " is not same as file " + p + " owner UID of " + uid2);
    }

    @Test
    public void testIsAnyProcessPosixProcessPidDirAlive() throws IOException {
        final String testName = "testIsAnyProcessPosixProcessPidDirAlive";
        List<String> errors = new ArrayList<>();
        int maxPidCnt = 5;
        if (ServerUtils.IS_ON_WINDOWS) {
            LOG.info("{}: test cannot be run on Windows. Marked as successful", testName);
            return;
        }
        final Path parentDir = Paths.get("/proc");
        if (!parentDir.toFile().exists()) {
            LOG.info("{}: test cannot be run on system without process directory {}, os.name={}",
                    testName, parentDir, System.getProperty("os.name"));
            // check if we can get process id on this Posix system - testing test code, useful on Mac
            String cmd = "/bin/sleep 10";
            if (getPidOfPosixProcess(Runtime.getRuntime().exec(cmd), errors) < 0) {
                fail(String.format("%s: Cannot obtain process id for executed command \"%s\"\n%s",
                        testName, cmd, String.join("\n\t", errors)));
            }
            return;
        }
        // Create processes and wait for their termination
        Set<Long> observables = new HashSet<>();

        for (int i = 0 ; i < maxPidCnt ; i++) {
            String cmd = "sleep 20000";
            Process process = Runtime.getRuntime().exec(cmd);
            long pid = getPidOfPosixProcess(process, errors);
            LOG.info("{}: ({}) ran process \"{}\" with pid={}", testName, i, cmd, pid);
            if (pid < 0) {
                String e = String.format("%s: (%d) Cannot obtain process id for executed command \"%s\"", testName, i, cmd);
                errors.add(e);
                LOG.error(e);
                continue;
            }
            observables.add(pid);
        }
        String userName = System.getProperty("user.name");
        // now kill processes one by one
        List<Long> pidList = new ArrayList<>(observables);
        final long processKillIntervalMs = 2000;
        for (int i = 0; i < pidList.size(); i++) {
            long pid = pidList.get(i);
            LOG.info("{}: ({}) Sleeping for {} milliseconds before kill", testName, i, processKillIntervalMs);
            if (sleepInterrupted(processKillIntervalMs)) {
                return;
            }
            Runtime.getRuntime().exec("kill -9 " + pid);
            LOG.info("{}: ({}) Sleeping for {} milliseconds after kill", testName, i, processKillIntervalMs);
            if (sleepInterrupted(processKillIntervalMs)) {
                return;
            }
            boolean pidDirsAvailable = ServerUtils.isAnyPosixProcessPidDirAlive(observables, userName);
            if (i < pidList.size() - 1) {
                if (pidDirsAvailable) {
                    LOG.info("{}: ({}) Found existing process directories before killing last process", testName, i);
                } else {
                    String e = String.format("%s: (%d) Found no existing process directories before killing last process", testName, i);
                    errors.add(e);
                    LOG.error(e);
                }
            } else {
                if (pidDirsAvailable) {
                    String e = String.format("%s: (%d) Found existing process directories after killing last process", testName, i);
                    errors.add(e);
                    LOG.error(e);
                } else {
                    LOG.info("{}: ({}) Found no existing process directories after killing last process", testName, i);
                }
            }
        }
        if (!errors.isEmpty()) {
            fail(String.format("There are %d failures in test:\n\t%s", errors.size(), String.join("\n\t", errors)));
        }
    }

    /**
     * Simulate the production scenario where the owner of the process directory is sometimes returned as the
     * UID instead of user. This scenario is simulated by calling
     * {@link ServerUtils#isAnyPosixProcessPidDirAlive(Collection, String, boolean)} with the last parameter
     * set to true as well as false.
     *
     * @throws Exception on I/O exception
     */
    @Test
    public void testIsAnyPosixProcessPidDirAliveMockingFileOwnerUid() throws Exception {
        File procDir = new File("/proc");
        if (!procDir.exists()) {
            LOG.info("Test testIsAnyPosixProcessPidDirAlive is designed to run on systems with /proc directory only, marking as success");
            return;
        }
        Collection<Long> allPids = getRunningProcessIds(null);
        Collection<Long> rootPids = getRunningProcessIds("root");
        assertFalse(allPids.isEmpty());
        assertFalse(rootPids.isEmpty());

        String currentUser = System.getProperty("user.name");

        for (boolean mockFileOwnerToUid: Arrays.asList(true, false)) {
            // at least one pid will be owned by the current user (doing the testing)
            boolean status = ServerUtils.isAnyPosixProcessPidDirAlive(allPids, currentUser, mockFileOwnerToUid);
            String err = String.format("(mockFileOwnerToUid=%s) Expecting user %s to own at least one process",
                    mockFileOwnerToUid, currentUser);
            assertTrue(status, err);
        }

        // simulate reassignment of all process id to a different user (root)
        for (boolean mockFileOwnerToUid: Arrays.asList(true, false)) {
            boolean status = ServerUtils.isAnyPosixProcessPidDirAlive(rootPids, currentUser, mockFileOwnerToUid);
            String err = String.format("(mockFileOwnerToUid=%s) Expecting user %s to own no process",
                    mockFileOwnerToUid, currentUser);
            assertFalse(status, err);
        }
    }

    /**
     * Make the best effort to obtain the Process ID from the Process object. Thus staying entirely with the JVM.
     *
     * @param p Process instance returned upon executing {@link Runtime#exec(String)}.
     * @param errors Populate errors when PID is a negative number.
     * @return positive PID upon success, otherwise negative.
     */
    private synchronized long getPidOfPosixProcess(Process p, List<String> errors) {
        Class<? extends Process> pClass = p.getClass();
        String pObjStr = ToStringBuilder.reflectionToString(p, ToStringStyle.SHORT_PREFIX_STYLE);
        String pclassName = pClass.getName();
        try {
            if (pclassName.equals("java.lang.UNIXProcess")) {
                Field f = pClass.getDeclaredField("pid");
                f.setAccessible(true);
                long pid = f.getLong(p);
                f.setAccessible(false);
                if (pid < 0) {
                    errors.add("\t \"pid\" attribute in Process class " + pclassName + " returned -1, process=" + pObjStr);
                }
                return pid;
            }
            for (Field f : pClass.getDeclaredFields()) {
                if (!f.getName().equalsIgnoreCase("pid")) {
                    continue;
                }
                LOG.info("ServerUtilsTest.getPidOfPosixProcess(): found attribute {}#{}", pclassName, f.getName());
                f.setAccessible(true);
                long pid = f.getLong(p);
                f.setAccessible(false);
                if (pid < 0) {
                    errors.add("\t \"pid\" attribute in Process class " + pclassName + " returned -1, process=" + pObjStr);
                }
                return pid;
            }
            // post JDK 9 there should be getPid() - future JDK-11 compatibility only for the sake of Travis test in community
            try {
                Method m = pClass.getDeclaredMethod("getPid");
                LOG.info("ServerUtilsTest.getPidOfPosixProcess(): found method {}#getPid()\n", pclassName);
                long pid = (Long)m.invoke(p);
                if (pid < 0) {
                    errors.add("\t \"getPid()\" method in Process class " + pclassName + " returned -1, process=" + pObjStr);
                }
                return pid;
            } catch (SecurityException e) {
                errors.add("\t getPid() method in Process class " + pclassName + " cannot be called: " + e.getMessage() + ", process=" + pObjStr);
                return -1;
            } catch (NoSuchMethodException e) {
                // ignore and try something else
            }
            errors.add("\t Process class " + pclassName + " missing field \"pid\" and missing method \"getPid()\", process=" + pObjStr);
            return -1;
        } catch (Exception e) {
            errors.add("\t Exception in Process class " + pclassName + ": " + e.getMessage() + ", process=" + pObjStr);
            e.printStackTrace();
            return -1;
        }
    }

    /**
     * Sleep for specified milliseconds and return true if sleep was interrupted.
     *
     * @param milliSeconds number of milliseconds to sleep
     * @return true if sleep was interrupted, false otherwise.
     */
    private boolean sleepInterrupted(long milliSeconds) {
        try {
            Thread.sleep(milliSeconds);
        } catch (InterruptedException ex) {
            ex.printStackTrace();
            Thread.currentThread().interrupt();
            return true;
        }
        return false;
    }
}
