# Copyright (C) 2021 Intel Corporation
# SPDX-License-Identifier: GPL-3.0-or-later

# pylint: disable=too-many-public-methods, too-many-arguments, fixme
"""
CVE-bin-tool tests
"""
import contextlib
import importlib
import io
import os
import shutil
import sys
import tarfile
import tempfile
import unittest
import unittest.mock
from test.test_data import __all__ as all_test_name
from test.utils import LONG_TESTS, download_file

import pytest

from cve_bin_tool.cvedb import CVEDB
from cve_bin_tool.version_scanner import VersionScanner

BINARIES_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), "binaries")
# load test data
test_data = list(
    map(lambda x: importlib.import_module(f"test.test_data.{x}"), all_test_name)
)
mapping_test_data = map(lambda x: x.mapping_test_data, test_data)
package_test_data = map(lambda x: x.package_test_data, test_data)
DISABLED_TESTS_ACTIONS = ["gimp"]
DISABLED_TESTS_LOCAL = []


class TestScanner:
    """Runs a series of tests against our "faked" binaries.

    The faked binaries are very small c files containing the same string signatures we use
    in the cve-bin-tool.  They should trigger results as if they contained the library and
    version specified in the file name.

    At this time, the tests work only in python3.
    """

    @classmethod
    def setup_class(cls):
        cls.cvedb = CVEDB()
        if os.getenv("UPDATE_DB") == "1":
            cls.cvedb.get_cvelist_if_stale()
        else:
            print("Skip NVD database updates.")
        # Instantiate a scanner
        cls.scanner = VersionScanner(should_extract=True)
        # temp dir for mapping tests
        cls.mapping_test_dir = tempfile.mkdtemp(prefix="mapping-test-")
        # temp dir for tests that require downloads
        cls.package_test_dir = tempfile.mkdtemp(prefix="package_test-")

    @classmethod
    def teardown_class(cls):
        shutil.rmtree(cls.package_test_dir)
        shutil.rmtree(cls.mapping_test_dir)

    def test_false_positive(self):
        self.scanner.all_cves = []
        with tempfile.NamedTemporaryFile(
            "w+b",
            suffix="-test-false-positive.out",
            dir=self.mapping_test_dir,
            delete=False,
        ) as f:
            common_signatures = [
                # common strings generated by a compiler
                b"\x7f\x45\x4c\x46\x02\x01\x01\x03\n",
                b"GCC: (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 8.1.0\n",
                b"GNU C17 8.1.0 -mtune=core2 -march=nocona -g -g -g -O2 -O2 -O2 -fno-ident -fbuilding-libgcc -fno-stack-protector\n",
                b"../../../../../src/gcca-8.1.0/libgcc/libgcc2.c\n",
                rb"C:\mingw810\x86_64-810-posix-seh-rt_v6-rev0\build\gcca-8.1.0\x86_64-w64-mingw32\libgcc\n",
                b"GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0\n",
                # bare version strings.
                b"1_0",
                b"1_2_3",
                b"1.4",
                b"1.2.3",
                b"6.7a",
                b"8.9.10-11",
                b"1-2",
                b"1-2-4",
                b"1.2.3-rc.1",
            ]
            f.writelines(common_signatures)
            filename = f.name
        for params in self.scanner.scan_file(
            os.path.join(self.mapping_test_dir, filename)
        ):
            if params:
                pytest.fail(msg=f"Checker has detected false positive: {params}")

    @pytest.mark.parametrize(
        "product, version, version_strings",
        (
            (d["product"], d["version"], d["version_strings"])
            for list_data in mapping_test_data
            for d in list_data
        ),
    )
    def test_version_mapping(self, product, version, version_strings):
        """Helper function to scan a binary and check that it contains
        certain cves for a version and doesn't contain others."""

        # Run the scan
        version_strings = list(map(lambda s: f"{s}\n".encode("ascii"), version_strings))
        # first filename will test "is" and second will test "contains"
        filenames = [
            f"-{product}-{version}.out",
            f"{'.'.join(list(product))}-{version}.out",
        ]
        for filename in filenames:
            with tempfile.NamedTemporaryFile(
                "w+b", suffix=filename, dir=self.mapping_test_dir, delete=False
            ) as f:
                f.write(b"\x7f\x45\x4c\x46\x02\x01\x01\x03\n")
                f.writelines(version_strings)
                filename = f.name

            list_products = set()
            list_versions = set()
            expected_path = os.path.join(self.mapping_test_dir, filename)
            for scan_info in self.scanner.recursive_scan(expected_path):
                if scan_info:
                    product_info, file_path = scan_info
                    list_products.add(product_info.product)
                    list_versions.add(product_info.version)
                    assert file_path == expected_path
            assert product in list_products
            assert version in list_versions

    def make_condensed_from_download(self, download_path, condensed_path):
        """Extract a downloaded file recursivley, find all the strings for each
        file. Create a new archive with only the strings that were found."""
        dot_extracted = ".extracted"
        file_lines_pairs = []

        class MakeCondensedVersionScanner(VersionScanner):
            def run_checkers(self, filename, lines):
                # Remove until .extracted plus 1 for the '/' after it. This will
                # give us the path to the file relative to the root of the
                # archive it came from
                file_lines_pairs.append(
                    (
                        filename[
                            filename.index(dot_extracted) + len(dot_extracted) + 1 :
                        ],
                        lines,
                    )
                )
                yield None

        # Run the recursive extraction
        for _ in MakeCondensedVersionScanner(should_extract=True).recursive_scan(
            download_path
        ):
            pass

        with contextlib.ExitStack() as stack:
            # gzip will add a last modified time by calling time.time, to ensure
            # that the hash is always the same, we set the time to 0
            stack.enter_context(unittest.mock.patch("time.time", return_value=1))
            # Create the archive
            with tarfile.open(condensed_path, mode="w|gz") as archive:
                for filepath, lines in file_lines_pairs:
                    # Create a bytes object for each file
                    fileobj = stack.enter_context(io.BytesIO())
                    fileobj.write(b"\x7f\x45\x4c\x46\x02\x01\x01\x03\n")
                    fileobj.writelines(
                        list(map(lambda s: f"{s}\n".encode("ascii"), lines))
                    )
                    # If we call write at all we need to seek back to the beginning
                    fileobj.seek(0)
                    # Create the TarInfo objects
                    tarinfo = tarfile.TarInfo(name=filepath)
                    tarinfo.size = len(fileobj.getvalue())
                    # Add the file to the archive
                    archive.addfile(tarinfo, fileobj=fileobj)

    def condensed_filepath(self, url, package_name):
        # Make directories for downloads and to store condensed versions
        downloads_dir = os.path.join(os.path.dirname(__file__), "downloads")
        condensed_dir = os.path.join(os.path.dirname(__file__), "condensed-downloads")
        for dirpath in [downloads_dir, condensed_dir]:
            if not os.path.isdir(dirpath):
                os.mkdir(dirpath)
        # Check if we've already made a condensed version of the file, if we
        # have, we're done.
        condensed_path = os.path.join(condensed_dir, package_name + ".tar.gz")
        if os.path.isfile(condensed_path):
            return condensed_path
        # Download the file if we don't have a condensed version of it and we
        # don't have it downloaded already
        download_path = os.path.join(downloads_dir, package_name)
        if not os.path.isfile(download_path):
            download_file(url + package_name, download_path)
        # Make the condensed version of the file
        self.make_condensed_from_download(download_path, condensed_path)
        # Return the condensed version
        return condensed_path

    @pytest.mark.parametrize(
        "url, package_name, product, version",
        (
            pytest.param(
                d["url"],
                d["package_name"],
                d["product"],
                d["version"],
                marks=[
                    pytest.mark.skipif(
                        "ACTIONS" in os.environ
                        and os.environ["ACTIONS"] == "1"
                        and d["product"] in DISABLED_TESTS_ACTIONS,
                        reason=f"{d['product']} Long tests disabled due to issues working in github actions",
                    ),
                    pytest.mark.skipif(
                        "ACTIONS" not in os.environ
                        and d["product"] in DISABLED_TESTS_LOCAL,
                        reason=f"{d['product']} Long tests disabled locally",
                    ),
                ],
            )
            for list_data in package_test_data
            for d in list_data
        ),
    )
    @unittest.skipUnless(LONG_TESTS() > 0, "Skipping long tests")
    def test_version_in_package(self, url, package_name, product, version):
        """Helper function to get a file (presumed to be a real copy
        of a library, probably from a Linux distribution) and run a
        scan on it.  Any test using this should likely be listed as a
        long test."""
        # get file
        tempfile = self.condensed_filepath(url, package_name)
        # new scanner for the new test.
        # self.scanner = VersionScanner(self.cve_scanner, should_extract=True)
        # run the tests
        list_products = set()
        list_versions = set()
        for scan_info in self.scanner.recursive_scan(tempfile):
            if scan_info:
                product_info, file_path = scan_info
                list_products.add(product_info.product)
                list_versions.add(product_info.version)

        # Make sure the product and version are in the results
        assert (
            product in list_products
        ), f"""{product} not found in {package_name}.
        The checker signature or url may be incorrect."""
        assert version in list_versions

    def test_does_not_scan_symlinks(self):
        """Test that the scanner doesn't scan symlinks"""
        if sys.platform.startswith("linux"):
            # we can only do this in linux since symlink is privilege operation in windows
            os.symlink("non-existant-file", "non-existant-link")
            try:
                with pytest.raises(StopIteration):
                    next(
                        self.scanner.scan_file(
                            os.path.join(self.mapping_test_dir, "non-existant-link")
                        )
                    )
            finally:
                os.unlink("non-existant-link")

    def test_cannot_open_file(self, caplog):
        """Test behaviour when file cannot be opened"""
        with pytest.raises(StopIteration):
            next(
                self.scanner.scan_file(
                    os.path.join(self.mapping_test_dir, "non-existant-file")
                )
            )
        assert str.find("Invalid file", caplog.text)

    def test_clean_file_path(self):
        filepath = (
            "/tmp/cve-bin-tool/dhtei34fd/file_name.extracted/usr/bin/vulnerable_file"
        )
        expected_path = "/usr/bin/vulnerable_file"

        cleaned_path = self.scanner.clean_file_path(filepath)
        assert expected_path == cleaned_path
