package org.jenkinsci.plugins.gitclient;

import hudson.EnvVars;
import hudson.model.TaskListener;
import hudson.plugins.git.GitException;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Random;

import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.URIish;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

import org.jvnet.hudson.test.Issue;

import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.*;

/**
 * Git client security tests,
 *
 * @author Mark Waite
 */
@RunWith(Parameterized.class)
public class GitClientSecurityTest {

    /* Test parameter - remote URL that is expected to fail with known exception */
    private final String badRemoteUrl;
    /* Test parameter - should remote URL check be enabled */
    private final boolean enableRemoteCheckUrl;

    /* Git client plugin repository directory. */
    private static final File SRC_REPO_DIR = new File(".git");

    /* Instance of object under test */
    private GitClient gitClient = null;

    /* Marker file used to check for SECURITY-1534 */
    private static String markerFileName = "/tmp/iwantmore-%d.pizza";

    @Rule
    public TemporaryFolder tempFolder = new TemporaryFolder();
    private File repoRoot = null;

    public GitClientSecurityTest(final String badRemoteUrl, final boolean enableRemoteCheckUrl) throws IOException, InterruptedException {
        this.badRemoteUrl = badRemoteUrl;
        this.enableRemoteCheckUrl = enableRemoteCheckUrl;
    }

    /* Capabilities of command line git in current environment */
    private static final boolean CLI_GIT_SUPPORTS_OPERAND_SEPARATOR;
    private static final boolean CLI_GIT_SUPPORTS_SYMREF;

    static {
        CliGitAPIImpl tempGitClient;
        try {
            tempGitClient = (CliGitAPIImpl) Git.with(TaskListener.NULL, new EnvVars()).in(SRC_REPO_DIR).using("git").getClient();
        } catch (Exception e) {
            tempGitClient = null;
        }
        if (tempGitClient != null) {
            CLI_GIT_SUPPORTS_OPERAND_SEPARATOR = tempGitClient.isAtLeastVersion(2, 8, 0, 0);
            CLI_GIT_SUPPORTS_SYMREF = tempGitClient.isAtLeastVersion(2, 8, 0, 0);
        } else {
            CLI_GIT_SUPPORTS_OPERAND_SEPARATOR = false;
            CLI_GIT_SUPPORTS_SYMREF = false;
        }
    }

    private static final Random CONFIG_RANDOM = new Random();

    /*
     * SECURITY-1534 notes that repository URL's provided by the user
     * were not sanity checked before being passed to git ls-remote
     * and git fetch. A sanity check is now enabled by default.
     *
     * As a backwards compatibility 'escape hatch', a Jenkins command
     * line argument can disable the sanity checks. Disabling the
     * checks then relies on command line git to perform the sanity
     * checks.
     *
     * This function returns a randomly selected value to enable or
     * disable the repository URL check based on the contents of the
     * attack string. If remote check is selected to be disabled and
     * the command line git implementation does not have full support
     * for the '--' separator between options and operands and the
     * attack is one of a known list of strings, then this function
     * will always return 'true' so that the remote checks will be
     * enabled.
     *
     * Returning 'false' in those cases on certain older command line
     * git implementations (git 1.8.3 on CentOS 7, git 2.7.4 on Ubuntu
     * 16) would cause the tested code to not throw an exception
     * because those command line git versions do not fully support
     * '--' to separate options and operands.
     */
    private static boolean enableRemoteCheck(String attack) {
        boolean enabled = CONFIG_RANDOM.nextBoolean();
        if (!enabled &&
            !CLI_GIT_SUPPORTS_OPERAND_SEPARATOR &&
            (attack.equals("-q")
             || attack.equals("--quiet")
             || attack.equals("-t")
             || attack.equals("--tags")
             || attack.startsWith("--upload-pack=")
            )) {
            enabled = true;
        }
        return enabled;
    }

    @Parameterized.Parameters(name = "{1},{0}")
    public static Collection testConfiguration() throws Exception {
        markerFileName = String.format(markerFileName, CONFIG_RANDOM.nextInt()); // Unique enough file name
        List<Object[]> arguments = new ArrayList<>();
        for (String prefix : BAD_REMOTE_URL_PREFIXES) {
            /* insert markerFileName into test data */
            String formattedPrefix = String.format(prefix, markerFileName);

            /* Random remote URL with prefix */
            String firstChar = CONFIG_RANDOM.nextBoolean() ? " " : "";
            String middleChar = CONFIG_RANDOM.nextBoolean() ? " " : "";
            String lastChar = CONFIG_RANDOM.nextBoolean() ? " " : "";
            int remoteIndex = CONFIG_RANDOM.nextInt(VALID_REMOTES.length);
            String remoteUrl = firstChar + formattedPrefix + middleChar + VALID_REMOTES[remoteIndex] + lastChar;
            Object[] remoteUrlItem = {remoteUrl, enableRemoteCheck(formattedPrefix)};
            arguments.add(remoteUrlItem);

            /* Random remote URL with prefix separated by a space */
            remoteIndex = CONFIG_RANDOM.nextInt(VALID_REMOTES.length);
            remoteUrl = formattedPrefix + " " + VALID_REMOTES[remoteIndex];
            Object[] remoteUrlItemOneSpace = {remoteUrl, enableRemoteCheck(formattedPrefix)};
            arguments.add(remoteUrlItemOneSpace);

            /* Random remote URL with prefix and no separator */
            remoteIndex = CONFIG_RANDOM.nextInt(VALID_REMOTES.length);
            remoteUrl = formattedPrefix + VALID_REMOTES[remoteIndex];
            Object[] noSpaceItem = {remoteUrl, enableRemoteCheck(formattedPrefix)};
            arguments.add(noSpaceItem);

            /* Remote URL with only the prefix */
            Object[] prefixItem = {formattedPrefix, enableRemoteCheck(formattedPrefix)};
            arguments.add(prefixItem);
        }
        Collections.shuffle(arguments);
        return arguments.subList(0, 25);
    }

    @BeforeClass
    public static void setCliGitDefaults() throws Exception {
        /* Command line git commands fail unless certain default values are set */
        CliGitCommand gitCmd = new CliGitCommand(null);
        gitCmd.setDefaults();
    }

    @AfterClass
    public static void resetRemoteCheckUrl() {
        org.jenkinsci.plugins.gitclient.CliGitAPIImpl.CHECK_REMOTE_URL = true;
    }

    @Before
    public void setRemoteCheckUrl() {
        org.jenkinsci.plugins.gitclient.CliGitAPIImpl.CHECK_REMOTE_URL = enableRemoteCheckUrl;
    }

    @Before
    public void setGitClient() throws IOException, InterruptedException {
        repoRoot = tempFolder.newFolder();
        gitClient = Git.with(TaskListener.NULL, new EnvVars()).in(repoRoot).using("git").getClient();
        File gitDir = gitClient.getRepository().getDirectory();
        assertFalse("Already found " + gitDir, gitDir.isDirectory());
        gitClient.init_().workspace(repoRoot.getAbsolutePath()).execute();
        assertTrue("Missing " + gitDir, gitDir.isDirectory());
        gitClient.setRemoteUrl("origin", SRC_REPO_DIR.getAbsolutePath());
    }

    private final Random random = new Random();

    private static final String[] BAD_REMOTE_URL_PREFIXES = {
        "--sort version:refname",
        "--upload-pack=/usr/bin/id",
        "--upload-pack=`touch %s`",
        "-o",
        "-q",
        "-t",
        "-v",
        "`echo %s`",
        "--all",
        "-a",
        "--append",
        "--depth=1",
        "--shallow-since=2019-09-01",
        "--shallow-exclude=HEAD",
        "--unshallow",
        "--update-shallow",
        "--negotiation-tip=master",
        "--dry-run",
        "--force",
        "-f",
        "--keep",
        "-k",
        "--multiple",
        "--no-auto-gc",
        "-p",
        "--prune",
        "-P",
        "--prune-tags",
        "-n",
        "--no-tags",
        "--ref-map=+refs/heads/abc/*:refs/remotes/origin/abc/*",
        "--tags",
        "--recurse-submodules",
        "-j",
        "--jobs",
        "--no-recurse-submodules",
        "--recurse-submodules-default=on-demand",
        "--update-head-ok",
        "--upload-pack /usr/bin/id",
        "--progress",
        "--quiet",
        "-o /usr/bin/id",
        "--server-option=/usr/bin/id",
        "--show-forced-updates",
        "--no-show-forced-updates",
        "-4",
        "-6"
    };

    private static final String[] VALID_REMOTES = {
        "https://github.com/jenkinsci/platformlabeler-plugin.git",
        "git://github.com/jenkinsci/platformlabeler-plugin.git",
        "https://github.com/jenkinsci/archetypes.git",
        "git://github.com/jenkinsci/archetypes.git",
        "https://github.com/jenkinsci/archetypes.git",
        "https://git.assembla.com/git-plugin.3.git",
        "https://[email protected]/markewaite/jenkins-pipeline-utils.git",
        "https://jenkins-git-plugin.git.beanstalkapp.com/git-client-plugin.git",
        "https://gitlab.com/MarkEWaite/git-client-plugin.git",
        "origin"
    };

    @Before
    public void removeMarkerFile() throws Exception {
        File markerFile = new File(markerFileName);
        Files.deleteIfExists(markerFile.toPath());
    }

    @After
    public void checkMarkerFile() {
        if (enableRemoteCheckUrl) { /* If remote checking is disabled, marker file is expected in several cases */
            File markerFile = new File(markerFileName);
            assertFalse("Marker file '" + markerFileName + "' detected after test", markerFile.exists());
        }
    }

    @Test
    @Issue("SECURITY-1534")
    public void testGetHeadRev_String_SECURITY_1534() throws Exception {
        String expectedMessage = enableRemoteCheckUrl ? "Invalid remote URL: " + badRemoteUrl : badRemoteUrl.trim();
        GitException e = assertThrows(GitException.class,
                                      () -> {
                                          gitClient.getHeadRev(badRemoteUrl);
                                      });
        assertThat(e.getMessage(), containsString(expectedMessage));
    }

    @Test
    @Issue("SECURITY-1534")
    public void testGetHeadRev_String_String_SECURITY_1534() throws Exception {
        String expectedMessage = enableRemoteCheckUrl ? "Invalid remote URL: " + badRemoteUrl : badRemoteUrl.trim();
        GitException e = assertThrows(GitException.class,
                                      () -> {
                                          gitClient.getHeadRev(badRemoteUrl, "master");
                                      });
        assertThat(e.getMessage(), containsString(expectedMessage));
    }

    @Test
    @Issue("SECURITY-1534")
    public void testGetRemoteReferences_SECURITY_1534() throws Exception {
        boolean headsOnly = random.nextBoolean();
        boolean tagsOnly = random.nextBoolean();
        String expectedMessage = enableRemoteCheckUrl ? "Invalid remote URL: " + badRemoteUrl : badRemoteUrl.trim();
        GitException e = assertThrows(GitException.class,
                                      () -> {
                                          gitClient.getRemoteReferences(badRemoteUrl, "*master", headsOnly, tagsOnly);
                                      });
        assertThat(e.getMessage(), containsString(expectedMessage));
    }

    @Test
    @Issue("SECURITY-1534")
    public void testGetRemoteSymbolicReferences_SECURITY_1534() throws Exception {
        assumeTrue(CLI_GIT_SUPPORTS_SYMREF);
        String expectedMessage = enableRemoteCheckUrl ? "Invalid remote URL: " + badRemoteUrl : badRemoteUrl.trim();
        GitException e = assertThrows(GitException.class,
                                      () -> {
                                          gitClient.getRemoteSymbolicReferences(badRemoteUrl, "master");
                                      });
        assertThat(e.getMessage(), containsString(expectedMessage));
    }

    @Test
    @Issue("SECURITY-1534")
    public void testFetch_URIish_SECURITY_1534() throws Exception {
        String refSpecString = "+refs/heads/*:refs/remotes/origin/*";
        List<RefSpec> refSpecs = new ArrayList<>();
        RefSpec refSpec = new RefSpec(refSpecString);
        refSpecs.add(refSpec);
        URIish badRepoUrl = new URIish(badRemoteUrl.trim());
        GitException e = assertThrows(GitException.class,
                                      () -> {
                                          gitClient.fetch_().from(badRepoUrl, refSpecs).execute();	
                                      });
        if (enableRemoteCheckUrl) {
            assertThat(e.getMessage(), containsString("Invalid remote URL: " + badRepoUrl.toPrivateASCIIString()));
        }
    }

    @Test
    @Issue("SECURITY-1534")
    public void testFetch_String_SECURITY_1534() throws Exception {
        RefSpec refSpec = new RefSpec("+refs/heads/*:refs/remotes/origin/*");
        gitClient.setRemoteUrl("origin", badRemoteUrl);
        GitException e = assertThrows(GitException.class,
                                      () -> {
                                          gitClient.fetch("origin", refSpec);
                                      });
        if (enableRemoteCheckUrl) {
            assertThat(e.getMessage(), containsString("Invalid remote URL: " + badRemoteUrl.trim()));
        }
    }

    @Test
    @Issue("SECURITY-1534")
    public void testFetch_String_RefSpec_SECURITY_1534() throws Exception {
        RefSpec refSpec = new RefSpec("+refs/heads/*:refs/remotes/origin/*");
        gitClient.setRemoteUrl("origin", badRemoteUrl);
        GitException e = assertThrows(GitException.class,
                                      () -> {
                                          gitClient.fetch("origin", refSpec, refSpec, refSpec);
                                      });
        if (enableRemoteCheckUrl) {
            assertThat(e.getMessage(), containsString("Invalid remote URL: " + badRemoteUrl.trim()));
        }
    }
}