#!/usr/bin/python3

# Copyright (C) 2014 - 2021 Red Hat, Inc.
#
# This file is part of csmock.
#
# csmock is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# any later version.
#
# csmock is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with csmock.  If not, see <http://www.gnu.org/licenses/>.

# standard imports
import argparse
import copy
import importlib
import os
import pathlib
import pkgutil
import re
import shlex
import shutil
import subprocess
import sys
import time

# local imports
import csmock.common.util
from csmock.common.util         import require_file
from csmock.common.util         import shell_quote
from csmock.common.util         import strlist_to_shell_cmd
from csmock.common.results      import FatalError
from csmock.common.results      import ScanResults
from csmock.common.results      import apply_result_filters
from csmock.common.results      import finalize_results
from csmock.common.results      import handle_kfp_git_url
from csmock.common.results      import handle_known_fp_list
from csmock.common.results      import transform_results


CSMOCK_DATADIR = "/usr/share/csmock"

CWE_MAP_FILE = CSMOCK_DATADIR + "/cwe-map.csv"

# path to the csgrep-static executable
CSGREP_STATIC = "/usr/libexec/csgrep-static"

CSMOCK_SCRIPTS = CSMOCK_DATADIR + "/scripts"

CHROOT_FIXUPS = CSMOCK_SCRIPTS + "/chroot-fixups"

ENABLE_KEEP_GOING_SCRIPT = CSMOCK_SCRIPTS + "/enable-keep-going.sh"

PATCH_RAWBUILD = CSMOCK_SCRIPTS + "/patch-rawbuild.sh"

DEFAULT_KNOWN_FALSE_POSITIVES = CSMOCK_DATADIR + "/known-false-positives.js"

# how long should we wait before checking mock profile availability again
MOCK_WAITING_TICK = 60

DEFAULT_CSWRAP_TIMEOUT = 30

DEFAULT_RPM_OPTS = [
    "--define", "_unpackaged_files_terminate_build 0",
    "--define", "apidocs 0",
    "--define", "with_publican 0",
    "--without", "docs",
    "--without", "langpacks"                # to speedup build of libreoffice
    ]

NOCHECK_RPM_OPTS = [
    "--define", "libguestfs_runtests 0",
    "--define", "runselftest 0",
    "--without", "test",
    "--without", "testsuite"]

RAWBUILD_RPM_OPTS = [
    "--define", "__patch " + PATCH_RAWBUILD,
    "--define", "_rawbuild -b _RAWBUILD",
    "--with", "vanilla"]

DEFAULT_CSWRAP_FILTERS = [
    "csgrep --mode=json --quiet --path '^/builddir/build/BUILD/' --remove-duplicates"]

# remember to use --mode=json for csgrep (TODO: improve csgrep's interface)
DEFAULT_RESULT_FILTERS = [
    "sed -r 's|(/builddir/build/BUILD/)[^/]+-build/|\\1|'",
    "csgrep --mode=json --path '^/builddir/build/BUILD/' \
--strip-path-prefix /builddir/build/BUILD/",
    "csgrep --mode=json --invert-match --path '^ksh-.*[0-9]+\\.c$'",
    "csgrep --mode=json --invert-match --path 'CMakeFiles/CMakeTmp|conftest.c'"]

# path filter needed with `rpmbuild -bi`
# TODO: introduce a csgrep option for this
# `/builddir/build/BUILDROOT/${NVR}/...` was used by old versions of `rpm`
# `/builddir/build/BUILD/${NVR}/BUILDROOT/...` was later introduced by a backward-incompatible change in `rpm`
# https://github.com/rpm-software-management/rpm/commit/9d35c8df497534e1fbd806a4dc78802bcf35d7cb
RPM_BI_FILTER = "sed -r 's;/builddir/build/BUILD(ROOT/[^/]+|/[^/]+/BUILDROOT)/;/builddir/build/BUILD//;'"

# path to csexec-loader is hard-coded for now
CSEXEC_ENABLE_FLAG = "-Wl,--dynamic-linker,/usr/bin/csexec-loader"

# static arguments passed to bld2repo
BLD2REPO_ARGS = [
        "--koji-host", "https://brewhub.engineering.redhat.com/brewhub",
        "--koji-storage-host", "http://download.devel.redhat.com/brewroot",
        "--mbs-host", "https://mbs.engineering.redhat.com"]


def find_missing_pkgs(pkgs, results, mock):
    # dump list of RPMs installed in the chroot (for debugging purposes)
    cmd = strlist_to_shell_cmd(mock.get_mock_cmd(["--shell", "rpm -qa"]))
    cmd += " | sort -V > %s/rpm-list-mock.txt" % results.dbgdir
    if results.exec_cmd(cmd, shell=True) != 0:
        results.error("failed to get list of packages installed in chroot")

    # get full list of provides
    provides = "%s/rpm-list-mock-provides.txt" % results.tmpdir
    cmd = strlist_to_shell_cmd(mock.get_mock_cmd(["--shell", "rpm -qa --provides"]))
    cmd += " | sort -V > %s" % provides
    if results.exec_cmd(cmd, shell=True) != 0:
        results.error("failed to get list of RPM provides in chroot")

    missing = []
    installed = set()
    with open(provides) as f:
        lines = f.readlines()
        for l in lines:
            pkg = re.sub(" .*$", "", l.strip())
            installed.add(pkg)

    for dep in pkgs:
        pkg = re.sub(" .*$", "", dep)
        if pkg in installed:
            continue
        missing += [dep]

    return missing


def query_build_id(results, nvr):
    # run `brew buildinfo` on the given NVR
    results.print_with_ts("obtaining build info from brew: " + nvr)
    cmd = ['brew', 'buildinfo', nvr]
    (ec, info) = results.get_cmd_output(cmd, shell=False)
    if ec != 0:
        return None

    # parse the first line of the output
    head = info.splitlines()[0]
    m = re.match("^.* \\[([0-9]+)\\]$", head)
    if m:
        return m.group(1)

    return None


def prepare_module_build_repo(results, nvr):
    repo_dir = os.path.join(results.tmpdir, "local-build-repo")
    if not os.path.exists(repo_dir):
        # query a module build in brew
        build_id = query_build_id(results, nvr)
        if build_id is None:
            return None

        # use bld2repo to build a local build repo
        cmd = ["bld2repo",
                "--build-id", build_id,
                "--result-dir", repo_dir]
        cmd += BLD2REPO_ARGS
        if (0 != results.exec_cmd(cmd)):
            return None

    # return local URL to the build repo
    url = "file://" + repo_dir
    return url


def extra_build_repos(results, srpm):
    urls = []

    # check for a module build
    m = re.match(r"^.*/(.*\.module\+el.*)\.src\.rpm$", srpm)
    if m:
        nvr = m.group(1)
        repo_url = prepare_module_build_repo(results, nvr)
        if repo_url is not None:
            # use a local build repo created by bld2repo
            urls += [repo_url]

    return urls


class MockWrapper:
    def __init__(self, results, props):
        self.results = results
        self.mock_profile = props.mock_profile
        self.mock_root_override = props.mock_root_override
        self.pid = os.getpid()
        self.scrub_done = props.skip_mock_init
        self.init_done = props.skip_mock_init
        self.scrub_on_exit = props.scrub_on_exit
        self.skip_clean = props.skip_mock_clean
        self.use_login_shell = props.use_login_shell
        self.add_repos = props.add_repos
        # just to silence pylint, will be initialized in __enter__()
        self.def_cmd = None

        # get buildroot directory
        lock_name = self.mock_root = self.mock_root_override
        if not self.mock_root:
            cmd = ['mock', '-r', self.mock_profile, '--print-root-path']
            ec, self.mock_root = results.get_cmd_output(cmd, shell=False)
            if ec != 0:
                results.error(f'mock could not determine root path for {self.mock_profile}', ec=ec)

            # strip trailing newline
            self.mock_root = self.mock_root.strip()

            # use only the basename of the mock root
            lock_name = pathlib.Path(self.mock_root).parent.name

        lock = f"/tmp/.csmock-{lock_name.replace('/', '_')}"
        self.lock_file = f"{lock}.lock"
        self.meta_lock_file = f"{lock}.metalock"

    def __enter__(self):
        cmd = "flock -w%u '%s' -c '\
lock_file=\"%s\" \n\
self_pid=\"%d\" \n\
if test -e \"$lock_file\"; then \n\
    test -e /proc/\"$self_pid\"     || exit $? \n\
    read pid < \"$lock_file\"       || exit $? \n\
    test ! -e /proc/\"$pid\"        || exit $? \n\
    echo \"warning: purging stray lock file $lock_file (PID $pid)\" >&2 \n\
fi \n\
echo \"$self_pid\" > \"$lock_file\"'" \
            % (MOCK_WAITING_TICK, self.meta_lock_file, self.lock_file, self.pid)
        advice = False
        while os.system(cmd) != 0:
            if not advice:
                self.results.print_with_ts("tip: you can use --root-override=<directory> "
                      "to run this csmock instance in parallel")
                advice = True
            f = open(self.lock_file)
            other_pid = ""
            if f is not None:
                other_pid = f.readline().rstrip()
                f.close()
            msg = "waiting till %s (PID %s) disappears..."
            self.results.print_with_ts(msg % (self.lock_file, other_pid))
            time.sleep(MOCK_WAITING_TICK)

        # prepare the mock command template with default arguments
        if os.path.exists("/usr/bin/mock-unbuffered"):
            # mock wrapper writing debug output without buffering
            mock = "/usr/bin/mock-unbuffered"
        elif os.path.exists("/usr/bin/mock"):
            # mock wrapper for non-privileged users (members of group mock)
            mock = "/usr/bin/mock"
        else:
            # fallback to any mock in $PATH (e.g. /usr/local/bin/mock)
            mock = "mock"
        self.def_cmd = [mock, "-r", self.mock_profile]

        # make csmock work in case the 'tmpfs' plug-in is enabled
        # (see <https://bugzilla.redhat.com/1190100> for details)
        self.def_cmd += ["--plugin-option=tmpfs:keep_mounted=True"]

        # re-enable verbose output per https://bugzilla.redhat.com/1166609
        self.def_cmd += ["--config-opts=print_main_output=True"]

        if self.mock_root_override:
            self.def_cmd += ["--disable-plugin=root_cache",
                             "--disable-plugin=yum_cache"]
            self.def_cmd += [f"--config-opts=root={self.mock_root_override}"]

        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if not self.skip_clean:
            # clean up mock chroot
            if self.exec_mock_cmd(["--clean"]) != 0:
                self.results.error("failed to clean mock chroot: %s" % self.mock_profile, ec=0)

        if self.scrub_on_exit:
            # scrub mock chroot
            if self.exec_mock_cmd(["--scrub=all"]) != 0:
                self.results.error("failed to scrub mock chroot: %s" % self.mock_profile, ec=0)

        # release the lock file
        cmd = "test -r '%s' && test %d = \"$(<%s)\" && rm -f '%s'" % (
            self.lock_file, self.pid, self.lock_file, self.lock_file)
        os.system(cmd)

    def get_mock_cmd(self, args, quiet=True):
        cmd = self.def_cmd[:]
        if quiet:
            cmd += ["--quiet"]
        return cmd + args

    def exec_mock_cmd(self, args, quiet=True):
        cmd = self.get_mock_cmd(args, quiet=quiet)
        return self.results.exec_cmd(cmd)

    def exec_chroot_cmd(self, cmd, quiet=True):
        return self.exec_mock_cmd(["--chroot", cmd], quiet=quiet)

    def exec_mockbuild_cmd(self, cmd, quiet=True):
        args = ""
        if self.use_login_shell:
            args = " -l"
        full_cmd = f"/bin/bash{args} -c {shell_quote(cmd)}"
        return self.exec_mock_cmd(["--unpriv", "--chroot", full_cmd], quiet=quiet)

    def exec_rpmbuild_bi(self, props, extra_rpm_opts=[], extra_env={}):
        if props.spec_in is None:
            self.results.fatal_error("SRPM is required by a plug-in")

        # construct basic `rpmbuild -bi ...` command
        rpm_opts = props.rpm_opts + extra_rpm_opts
        cmd = "rpmbuild -bi --nodeps --short-circuit %s %s" \
            % (props.spec_in, strlist_to_shell_cmd(rpm_opts))

        # wrap %install cmd by all %build cmd wrappers (workaround for buggy pkgs)
        cmd = props.wrap_build_cmd(cmd)

        # initialize environment variables according to ScanProps
        cmd = props.wrap_shell_cmd_by_env(cmd, extra_env)

        return self.exec_mockbuild_cmd(cmd, quiet=False)

    def copy_out(self, args, quiet=True):
        cmd = ["--disable-plugin=selinux", "--copyout"] + args
        return self.exec_mock_cmd(cmd, quiet=quiet)

    def try_install(self, pkgs, quiet=True):
        cmd = []
        for repo in self.add_repos:
            cmd += ["--addrepo", repo]
        cmd += ["--install"] + pkgs
        return (self.exec_mock_cmd(cmd, quiet=quiet) == 0)

    def install_deps(self, srpm, quiet=True):
        cmd_add = []
        for url in extra_build_repos(self.results, srpm):
            cmd_add += ["--addrepo", url]

        if re.match("^.*\\.module\\+el.*\\.src\\.rpm$", srpm):
            # we need to reinstall "module-build-macros" first for a modular build
            self.exec_mock_cmd(["--remove", "module-build-macros"], quiet=quiet)
            cmd = ["--install", "module-build-macros"] + cmd_add
            self.exec_mock_cmd(cmd, quiet=quiet)

        if re.search(r"rhel-[67]", self.mock_profile):
            # use --installdeps for legacy chroots
            base_cmd = "--installdeps"
        else:
            # install both static and dynamic build dependencies (replacement for --installdeps)
            base_cmd = "--calculate-build-dependencies"

        # finally install the dependencies
        cmd = ["--no-clean", base_cmd, srpm] + cmd_add
        return (self.exec_mock_cmd(cmd, quiet=quiet) == 0)

    def emergency_install_pkgs(self, pkgs):
        """try to install pkgs one by one"""
        for dep in pkgs:
            if (dep):
                self.try_install([dep])

    def emergency_install_deps(self, srpm):
        (_, raw_deps) = self.results.get_cmd_output("rpm -qp '%s' --requires" % srpm)
        pkgs = raw_deps.split("\n")
        self.emergency_install_pkgs(pkgs)

    def remove(self, pkgs):
        return (self.exec_mock_cmd(["--remove"] + pkgs) == 0)

    def init_and_install(self, srpm, pkgs, keep_going=False, try_only=False):
        for do_scrub in [False, True]:
            if do_scrub and not self.scrub_done:
                self.results.print_with_ts("trying to scrub everything...")
                self.exec_mock_cmd(["--scrub=all"], quiet=False)
                self.scrub_done = True
                self.init_done = False

            # unless a scrub was done print warnings only
            ec_by_scrub = int(self.scrub_done)

            # run `mock --init` if not disabled
            if not self.init_done and (self.exec_mock_cmd(["--init"], quiet=False) != 0):
                self.results.error(f"failed to init mock profile ({self.mock_profile})",
                                   ec=ec_by_scrub, fatal=ec_by_scrub)
                continue
            self.init_done = True

            # run `mock --calculate-build-dependencies`
            srpm_deps_ok = srpm is None or self.install_deps(srpm)
            if not srpm_deps_ok and not try_only:
                srpm_base = os.path.basename(srpm)
                self.results.error(f"failed to install build dependencies of {srpm_base}", ec=ec_by_scrub)
                if not self.scrub_done:
                    continue

                if keep_going:
                    self.emergency_install_deps(srpm)

            if not pkgs:
                return srpm_deps_ok

            # run `mock --install`
            self.try_install(pkgs)
            missing_deps = find_missing_pkgs(pkgs, self.results, self)
            if not missing_deps:
                # no misssing dependencies
                return srpm_deps_ok
            if try_only:
                return False

            if keep_going:
                # try to install the missing packages one by one
                self.emergency_install_pkgs(missing_deps)
                missing_deps = find_missing_pkgs(pkgs, self.results, self)

            self.results.error(f"failed to install required packages ({strlist_to_shell_cmd(missing_deps)})",
                               ec=ec_by_scrub)

        return False


class ScanProps:
    def __init__(self):
        self.plugins = None
        self.spec_in = None
        self.install_pkgs = ["tar"]                     # needed for self.copy_in_files to work
        self.install_pkgs_blacklist = []
        self.install_opt_pkgs = []
        self.add_repos = []
        self.copy_in_files = [CSMOCK_SCRIPTS]
        self.pre_mock_hooks = []
        self.post_depinst_hooks = []
        self.post_install_hooks = []
        self.rpm_opts = DEFAULT_RPM_OPTS
        self.path = []
        self.env = {}
        self.copy_out_files = []
        self.use_ldpwrap = False
        self.csexec_enabled = False
        self.cswrap_enabled = False
        self.cswrap_filters = DEFAULT_CSWRAP_FILTERS
        self.result_filters = DEFAULT_RESULT_FILTERS
        self.build_cmd_wrappers = []
        self.post_build_chroot_cmds = []
        self.post_process_hooks = []
        self.keep_going = False
        self.cswrap_timeout = DEFAULT_CSWRAP_TIMEOUT
        self.embed_context = 0
        self.results_limits_opts = []
        self.results_limits_applied = False
        self.no_scan = False
        self.print_defects = False
        self.need_rpm_bi = False
        self.run_check = False
        self.use_login_shell = True
        self.skip_mock_init = False
        self.skip_mock_clean = False
        self.scrub_on_exit = False
        self.shell_cmd_to_build = None
        self.srpm = None
        self.base_srpm = None
        self.mock_profile = None
        self.base_mock_profile = None
        self.mock_root_override = None
        self.any_tool = False
        self.nvr = None
        self.pkg = None
        self.imp_checker_set = set()
        self.imp_csgrep_filters = []
        self.cswrap_path = None
        self.kfp_git_url = None

    def enable_cswrap(self):
        if self.cswrap_enabled:
            # already enabled
            return
        self.cswrap_enabled = True

        # resolve cswrap_path by querying cswrap binary
        cmd = ["cswrap", "--print-path-to-wrap"]
        subproc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        (out, _) = subproc.communicate()
        self.cswrap_path = out.decode("utf8").strip()

        self.copy_in_files += ["/usr/bin/cswrap", self.cswrap_path]
        self.path = [self.cswrap_path] + self.path
        self.env["CSWRAP_CAP_FILE"] = "/builddir/cswrap-capture.err"
        self.env["CSWRAP_TIMEOUT"] = "%d" % self.cswrap_timeout
        self.env["CSWRAP_TIMEOUT_FOR"] = ":"
        self.copy_out_files += ["/builddir/cswrap-capture.err"]

    def enable_ldpwrap(self):
        assert "ldpwrap" not in self.install_pkgs
        self.install_pkgs += ["ldpwrap"]
        self.add_repos += ["https://download.copr.fedorainfracloud.org/results/@aufover/ldpwrap/fedora-$releasever-$basearch/"]
        self.rpm_opts += ["--define", "__spec_check_pre export LD_PRELOAD=/usr/lib64/ldpwrap.so %{___build_pre}"]

    def enable_csexec(self):
        if self.use_ldpwrap:
            # we are asked to use ldpwrap rather than csexec-loader
            self.enable_ldpwrap()
            return

        if self.csexec_enabled:
            # already enabled
            return
        self.csexec_enabled = True

        # install csexec into chroot
        self.install_pkgs += ["csexec"]

        # use the "gcc" plug-in to inject linker flags
        gcc = self.plugins.plug_by_name["gcc"]
        gcc.enable()
        # FIXME: This only works if Plugin::handle_args() of gcc.py has not yet been called
        gcc.flags.append_flags([CSEXEC_ENABLE_FLAG])

    def pick_cswrap_results(self, results):
        if not self.cswrap_enabled:
            # not enabled --> succeeded trivially
            return 0

        # apply all filters using a shell pipe
        fin = "%s/builddir/cswrap-capture.err" % results.dbgdir_raw
        out = "%s/cswrap-capture.js" % results.dbgdir_uni
        cmd = "cat '%s'" % fin
        for filt in self.cswrap_filters:
            cmd += " | %s" % filt
        cmd += " > '%s'" % out
        return results.exec_cmd(cmd, shell=True)


    def wrap_build_cmd(self, cmd_in):
        cmd_out = cmd_in
        for w in self.build_cmd_wrappers:
            cmd_out = "sh -c %s" % shell_quote(cmd_out)
            cmd_out = w % cmd_out
        return cmd_out

    def wrap_shell_cmd_by_env(self, cmd_in, extra_env={}):
        # merge self.env with extra_env
        env = self.env.copy()
        env.update(extra_env)

        # serialize self.path
        path_str = ""
        for p in self.path:
            path_str += p + ":"
        cmd_out = "PATH=%s$PATH " % path_str

        # serialize env
        assert "PATH" not in env
        for var in env:
            cmd_out += "%s=$'%s' " % (var, env[var])

        # run a new instance of shell for the specified command
        cmd_out += "sh -c %s" % shell_quote(cmd_in)
        return cmd_out

    def run_hooks(self, results, hook_name, *args):
        """run all hooks from the list specified by hook_name"""
        item = hook_name.replace("-", "_") + "_hooks"
        hook_list = getattr(self, item)
        for hook in hook_list:
            rv = hook(*args)
            if rv != 0:
                results.error(f"{hook_name} hook {hook.__module__}::{hook.__name__}() returned {rv}", ec=rv)


class PluginManager:
    def __init__(self):
        self.plugins_sorted = []
        self.plug_by_name = {}
        self.pass_before = {}
        self.pass_after = {}

    def try_load(self, mod_name):
        full_name = "csmock.plugins." + mod_name
        mod = importlib.import_module(full_name)
        plugin = mod.Plugin()

        # record real (module) name of the plugin
        assert not hasattr(plugin, "mod_name")
        plugin.mod_name = mod_name

        props = plugin.get_props()
        # TODO: check API version
        if hasattr(props, "pass_priority"):
            sys.stderr.write("%s: %s: ignoring pass_priority = %s defined by %s\n"
                    % (sys.argv[0], self.__class__.__name__, str(props.pass_priority), mod))
        for attr in ["pass_before", "pass_after"]:
            if hasattr(props, attr):
                getattr(self, attr)[mod_name] = getattr(props, attr)
        self.plug_by_name[mod_name] = plugin

    def sort_topologically(self):
        # build dependency graph
        graph = {}
        for plug in self.plug_by_name.keys():
            graph[plug] = set()
        for plug in self.pass_before.keys():
            for before in self.pass_before[plug]:
                if before in self.plug_by_name.keys():
                    graph[before].add(plug)
        for plug in self.pass_after.keys():
            for after in self.pass_after[plug]:
                if after in self.plug_by_name.keys():
                    graph[plug].add(after)

        try:
            import graphlib
            # use graphlib.TopologicalSorter to implement real topological sort (python 3.9+)
            ts = graphlib.TopologicalSorter(graph)
            order = list(ts.static_order())
        except:
            # fallback to a lame implementation of the ordering algorithm
            order = []
            for plug in sorted(self.plug_by_name.keys()):
                for after in sorted(graph[plug]):
                    if after not in order:
                        order += [after]
            for plug in sorted(self.plug_by_name.keys()):
                if plug not in order:
                    order += [plug]

        self.plugins_sorted = []
        for plug in order:
            self.plugins_sorted += [self.plug_by_name[plug]]

    def load_default_plugins(self):
        pkg = importlib.import_module("csmock.plugins")
        for (_, mod_name, _) in pkgutil.iter_modules(pkg.__path__):
            self.try_load(mod_name)
        self.sort_topologically()

    # Print description of each available plugin in format TOOL [:indent:] DESCRIPTION
    def print_plugin_descriptions(self):
        max_key_len = max(map(len, self.plug_by_name.keys()))
        min_indent_len = 8
        description_indent = max_key_len + min_indent_len

        def list_plugins(stable):
            for key, plugin in sorted(self.plug_by_name.items()):
                props = plugin.get_props()
                desc = getattr(props, "description", "")
                if stable != getattr(props, "stable", False):
                    continue
                if not stable:
                    # highlight the fact that the plug-in is experimental
                    desc = "[EXPERIMENTAL] " + desc
                sys.stdout.write("{}{}{}\n".format(
                    key, " " * (description_indent - len(key)),
                    desc.replace('\n', '\n%s' % (" " * description_indent))))

        list_plugins(stable=True)
        list_plugins(stable=False)

    def get_name_list(self):
        return sorted(self.plug_by_name.keys())

    def enable(self, plugin_name):
        plugin = self.plug_by_name[plugin_name]
        plugin.enable()

    def enable_all(self):
        for plugin in self.plugins_sorted:
            stable = getattr(plugin.get_props(), "stable", False)
            if not stable:
                continue
            plugin.enable()

    def init_parser(self, parser):
        for plugin in self.plugins_sorted:
            plugin.init_parser(parser)

    def handle_args(self, parser, args, props):
        for plugin in self.plugins_sorted:
            plugin.handle_args(parser, args, props)

    def enabled_plugins(self):
        lst = []
        for plugin in self.plugins_sorted:
            if getattr(plugin, "enabled", False):
                lst += [plugin.mod_name]
        return lst

    def num_enabled(self):
        return len(self.enabled_plugins())


# argparse._VersionAction would write to stderr, which breaks help2man
class VersionPrinter(argparse.Action):
    def __init__(self, option_strings, dest=None, default=None, help=None):
        super(VersionPrinter, self).__init__(
            option_strings=option_strings, dest=dest, default=default, nargs=0,
            help=help)

    def __call__(self, parser, namespace, values, option_string=None):
        print("csmock-3.8.5-1.fc45")
        sys.exit(0)


# provide a more user-friendly error message in case a plug-in is not installed
class FileNameParser(argparse.Action):
    def __call__(self, parser, namespace, val, os=None):
        if isinstance(val, str) and val.startswith("--"):
            parser.error("File name '%s' starts with '--', which looks like \
option.  Are you sure, you have necessary plug-ins installed?  If it really \
is a file name, please use the './' prefix." % val)
        else:
            setattr(namespace, self.dest, val)


def main():
    # load plug-ins
    plugins = PluginManager()
    plugins.load_default_plugins()
    plugin_list = plugins.get_name_list()

    # list available tools
    # FIXME: --list-available-tools takes precedence over --help and --version
    class ToolsPrinter(argparse.Action):
        def __init__(self, option_strings, dest=None, default=None, help=None):
            super(ToolsPrinter, self).__init__(
                option_strings=option_strings, dest=dest, default=default, nargs=0,
                help=help)

        def __call__(self, parser, namespace, values, option_string=None):
            plugins.print_plugin_descriptions()
            sys.exit(0)

    # initialize argument parser
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "SRPM", nargs="?", action=FileNameParser,
        help="source RPM package to be scanned by static analyzers")

    # define optional arguments
    parser.add_argument(
        "-r", "--root", dest="mock_profile", default="default",
        help="mock profile to use (defaults to mock's default)")

    parser.add_argument(
        "-t", "--tools", action="append", default=[],
        help="comma-separated list of tools to enable \
(use --list-available-tools to see the list of available tools)")

    parser.add_argument(
        "-a", "--all-tools", action="store_true",
        help="enable all stable csmock plug-ins \
(use --list-available-tools to see the list of available tools)")

    parser.add_argument(
        "-l", "--list-available-tools", action=ToolsPrinter,
        help="list available tools and exit")

    parser.add_argument(
        "--install", action="append", default=[],
        help="space-separated list of packages to install into the chroot")

    parser.add_argument(
        "-o", "--output",
        help="name of the tarball or directory to put the results to")

    parser.add_argument(
        "-f", "--force", action="store_true",
        help="overwrite the resulting file or directory if it exists already")

    parser.add_argument(
        "-j", "--jobs", type=int, default=0,
        help="maximal number of jobs running in parallel (passed to 'make')")

    parser.add_argument(
        "--rpm-build-opts", action="append", default=[],
        help="shell-quoted options passed to rpm-build")

    parser.add_argument(
        "--cswrap-timeout", type=int, default=DEFAULT_CSWRAP_TIMEOUT,
        help="maximal amount of time taken by analysis of a single module [s]")

    parser.add_argument(
        "-U", "--embed-context", type=int, default=3,
        help="embed a number of lines of context from the source file for the \
key event (defaults to 3).")

    parser.add_argument(
        "--warning-rate-limit", type=int, default=1024,
        help="stop processing a warning if the count of its occurrences \
exceeds the specified limit (defaults to 1024).")

    parser.add_argument(
        "--limit-msg-len", type=int, default=512,
        help="limit length of diagnostic messages by the specified number of chars \
(defaults to 512).")

    parser.add_argument(
        "-k", "--keep-going", action="store_true",
        help="continue as much as possible after an error")

    parser.add_argument(
        "--skip-init", action="store_true",
        help="do not run 'mock --init' before the scan \
(may lead to unpredictable scan results)")

    parser.add_argument(
        "--skip-build", action="store_true",
        help="do not run %%build and %%install sections [EXPERIMENTAL]")

    parser.add_argument(
        "--use-ldpwrap", action="store_true",
        help="use ldpwrap instead of csexec-loader [EXPERIMENTAL]")

    cleanup_group = parser.add_mutually_exclusive_group()

    cleanup_group.add_argument(
        "--no-clean", action="store_true",
        help="do not clean chroot when it becomes unused")

    cleanup_group.add_argument(
        "--scrub-on-exit", action="store_true",
        help="scrub all caches after the scan")

    parser.add_argument(
        "--no-scan", action="store_true",
        help="do not analyze any package, just check versions of the analyzers")

    csmock.common.util.add_paired_flag(
        parser, "run-check",
        help="run the %%check section of specfile (disabled by default)")

    csmock.common.util.add_paired_flag(
        parser, "print-defects",
        help="print the resulting list of defects (default if connected to a tty)")

    parser.add_argument(
        "--base-srpm",
        help="perform a differential scan against the specified base package")

    parser.add_argument(
        "--base-root", dest="base_mock_profile",
        help="mock profile to use for the base scan (use only with --base-srpm)")

    parser.add_argument(
        "--root-override", dest="mock_root_override",
        help='override the build root directory for mock (disables yum and root cache)'
    )

    # --skip-patches, --diff-patches, and --shell-cmd are mutually exclusive
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "--skip-patches", action="store_true",
        help="skip patches not annotated by %%{?_rawbuild} (vanilla build)")
    group.add_argument(
        "--diff-patches", action="store_true",
        help="scan with/without patches and diff the lists of defects")
    group.add_argument(
        "-c", "--shell-cmd",
        help="use shell command to build the given tarball (instead of SRPM)")

    # --known-false-positives
    default_kfp = DEFAULT_KNOWN_FALSE_POSITIVES
    default_kfp_text = f'defaults to "{default_kfp}"'
    if not os.path.exists(default_kfp):
        default_kfp = ""
        default_kfp_text += " if available"
    parser.add_argument(
        "--known-false-positives", default=default_kfp,
        help=f"suppress known false positives loaded from the given file ({default_kfp_text})")

    # currently --kfp-git-url works independently of --known-false-positives
    parser.add_argument(
        "--kfp-git-url",
        help="known false positives git URL (optionally taking a revision delimited by #)")

    csmock.common.util.add_paired_flag(
        parser, "use-login-shell",
        help="use login shell for build (default)")

    # needed for help2man
    parser.add_argument(
        "--version", action=VersionPrinter,
        help="print the version of csmock and exit")

    # add command-line options handled by plugins
    plugins.init_parser(parser)

    # parse command-line arguments
    args = parser.parse_args()

    if args.print_defects is None:
        args.print_defects = sys.stdout.isatty()

    # check that only available tools are requested (and enable them)
    for i in args.tools:
        for j in i.split(","):
            tool = j.strip()
            if not tool:
                continue
            if tool in plugin_list:
                # explicitly enable this tool
                plugins.enable(tool)
            else:
                parser.error("tool not available: %s" % tool)

    if args.all_tools:
        # enable all available tools
        plugins.enable_all()

    output = args.output
    if args.SRPM is None:
        if args.no_scan:
            if output is None:
                parser.error("unable to infer --output (because --no-scan was given)")
        else:
            parser.error("no SRPM (or tarball) specified on the command line")

    if args.no_scan and args.shell_cmd is not None:
        parser.error("--shell-cmd makes no sense with --no-scan")

    if args.base_srpm is None:
        if args.base_mock_profile is not None:
            parser.error("--base-root makes no sense without --base-srpm")
    else:
        if args.diff_patches:
            parser.error("options --diff-patches and --base-scan are mutually exclusive")

    props = ScanProps()
    props.plugins               = plugins
    props.cswrap_timeout        = args.cswrap_timeout
    props.embed_context         = args.embed_context
    props.keep_going            = args.keep_going
    props.no_scan               = args.no_scan
    props.print_defects         = args.print_defects
    props.use_login_shell       = args.use_login_shell in [True, None]
    props.shell_cmd_to_build    = args.shell_cmd
    props.srpm                  = args.SRPM
    props.base_srpm             = args.base_srpm
    props.skip_patches          = args.skip_patches
    props.skip_mock_init        = args.skip_init
    props.skip_build            = args.skip_build
    props.scrub_on_exit         = args.scrub_on_exit
    props.use_ldpwrap           = args.use_ldpwrap
    props.skip_mock_clean       = args.no_clean
    props.kfp_git_url           = args.kfp_git_url

    if props.embed_context > 0:
        # we need csgrep-static in the chroot for --embed-context
        props.copy_in_files += [CSGREP_STATIC]

    if args.warning_rate_limit > 0:
        props.results_limits_opts += [f"--warning-rate-limit={args.warning_rate_limit}"]

    if args.limit_msg_len > 0:
        props.results_limits_opts += [f"--limit-msg-len={args.limit_msg_len}"]

    if args.run_check:
        # run the %check section of specfile
        props.run_check = True

    # parse and append rpm-build opts
    for opt in args.rpm_build_opts:
        props.rpm_opts += shlex.split(opt)

    if 0 < args.jobs:
        # initialize the %{_smp_mflags} RPM macro
        props.rpm_opts += ["--define", "_smp_mflags -j%d" % args.jobs]

    # make sure that we have a configuration for the selected mock profile
    props.mock_profile = args.mock_profile
    if props.mock_profile.endswith(".cfg"):
        require_file(parser, props.mock_profile)
    else:
        require_file(parser, "/etc/mock/%s.cfg" % props.mock_profile)
    if args.base_mock_profile is None:
        props.base_mock_profile = props.mock_profile
    else:
        props.base_mock_profile = args.base_mock_profile
        require_file(parser, "/etc/mock/%s.cfg" % props.base_mock_profile)

    props.mock_root_override = args.mock_root_override

    # append the list of packages to install specified on command-line
    for pkg in args.install:
        props.install_pkgs += pkg.split()

    if not props.no_scan:
        # make sure that 'srpm' is a file (it can be a tar archive instead of SRPM)
        require_file(parser, props.srpm)

    if props.srpm is not None:
        # resolve NVR
        srpm_base = os.path.basename(props.srpm)
        if props.shell_cmd_to_build is None:
            props.nvr = re.sub("\\.src\\.rpm$", "", srpm_base)
        else:
            props.nvr = re.sub("\\.tar$", "", re.sub("\\.[^.]*$", "", srpm_base))

        # cut off the `-version-release` or `-version` suffix to obtain package name where `version` can be
        # a number optionally prefixed by `v` or a full-size SHA1 hash encoded in lowercase as, for example,
        # in `project-koku-koku-cbe5e5c3355c1e140aa1cca7377aebe09d8d8466`
        props.pkg = re.sub("-(([v]?[0-9][^-]*)|([0-9a-f]{40}))(-[0-9][^-]*)?$", "", props.nvr)

    # resolve name of the file/dir we are going to store the results to
    if args.output is None:
        output = props.nvr + ".tar.xz"
    output = os.path.realpath(output)

    # FIXME: TOCTOU race
    if os.path.exists(output) and not args.force:
        parser.error("'%s' already exists, use --force to proceed" % output)

    # check the path given to --known-false-positives
    props.known_false_positives = args.known_false_positives
    if props.known_false_positives:
        require_file(parser, props.known_false_positives)

    # poll plug-ins to reflect themselves in ScanProps
    plugins.handle_args(parser, args, props)
    props.any_tool = (plugins.num_enabled() > 0)

    if props.run_check:
        # we need to run %install to be able to run %check
        props.need_rpm_bi = True

    if args.diff_patches:
        ec = do_diff_scan(props, output, diff_patches=True)
    elif args.base_srpm is not None:
        ec = do_diff_scan(props, output, diff_patches=False)
    else:
        ec = do_scan(props, output)

    sys.exit(ec)


def do_scan(props, output):
    if props.skip_build:
        # TODO: fail sooner with some user-friendly error message
        assert not props.cswrap_enabled
        assert not props.need_rpm_bi

    if props.skip_patches:
        props.rpm_opts += RAWBUILD_RPM_OPTS

    try:
        with ScanResults(output, "csmock", "csmock-3.8.5-1.fc45", props.keep_going) as results:
            enabled_plugins = props.plugins.enabled_plugins()
            results.ini_writer.append("enabled-plugins", ", ".join(enabled_plugins))
            results.ini_writer.append("mock-config", props.mock_profile)
            results.ini_writer.append("project-name", props.nvr)
            handle_known_fp_list(props, results)
            handle_kfp_git_url(props)

            if not props.any_tool:
                # no tool enabled
                results.error("No tools are enabled, only trying to build \
the package.  Use --tools or --all-tools to enable them!\n", ec=0)

            # dump list of RPMs installed on the host (for debugging purposes)
            results.exec_cmd(
                "rpm -qa | sort -V > '%s/rpm-list-host.txt'" % results.dbgdir,
                shell=True)

            if props.no_scan:
                srpm_dup = None
            else:
                if props.shell_cmd_to_build is None:
                    # check the given SRPM
                    if results.get_cmd_output("rpm -pq '%s'" % props.srpm)[0] != 0:
                        results.fatal_error("failed to open SRPM: %s" % props.srpm)
                    (ec, spec) = results.get_cmd_output(
                        "rpm -lpq '%s' | grep '\\.spec$'" % props.srpm)
                    if ec != 0:
                        results.fatal_error("no specfile found in SRPM: %s" % props.srpm)
                    spec = spec.rstrip()
                    props.spec_in = "/builddir/build/SPECS/%s" % spec

                # copy the given SRPM into our tmp dir
                srpm_base = os.path.basename(props.srpm)
                srpm_dup = "%s/%s" % (results.tmpdir, srpm_base)
                shutil.copyfile(props.srpm, srpm_dup)
                props.copy_in_files += [srpm_dup]

                if props.shell_cmd_to_build is not None:
                    # do not query source tarball for build deps
                    src_tar_dup = srpm_dup
                    srpm_dup = None

            # run pre-mock hooks
            props.run_hooks(results, "pre-mock", results, props)

            with MockWrapper(results, props) as mock:
                if srpm_dup is not None:
                    # first rebuild the given SRPM (some deps might be required even for the rebuild)
                    mock.init_and_install(srpm_dup, props.install_pkgs, try_only=True)

                    # install the copied SRPM into the chroot
                    srpm_in = "/builddir/%s" % srpm_base
                    mock.exec_mock_cmd(["--copyin", srpm_dup, srpm_in])
                    mock.exec_chroot_cmd("chown mockbuild -R /builddir")
                    mock.exec_mockbuild_cmd("rpm -Uvh --nodeps '%s'" % srpm_in)

                    if props.keep_going:
                        # ignore ExclusiveArch tags with --keep-going
                        mock.exec_mockbuild_cmd("sed -e 's|^ExclusiveArch:.*$||' -i " + props.spec_in)

                    # rebuild the given SRPM (and rename to match the original one)
                    cmd_tpl = "rpmbuild -bs --nodeps %s %s && sh -c 'cd \
/builddir/build/SRPMS && eval mv -v *.src.rpm %s || :'"
                    cmd = cmd_tpl % (props.spec_in, strlist_to_shell_cmd(props.rpm_opts), srpm_in)
                    mock.exec_mockbuild_cmd(cmd)

                    # use the rebuilt SRPM to get the dependency list
                    mock.copy_out([srpm_in, srpm_dup])

                # run `mock --init`, `mock --installdeps`, and `mock --install`
                mock.init_and_install(srpm_dup, props.install_pkgs, keep_going=props.keep_going)

                # install optional packages (if any)
                if props.install_opt_pkgs:
                    for pkg in props.install_opt_pkgs:
                        mock.try_install([pkg])
                    # just to update rpm-list-mock.txt
                    find_missing_pkgs([], results, mock)

                # remove unwanted packages (if any)
                if props.install_pkgs_blacklist:
                    mock.remove(props.install_pkgs_blacklist)
                    # just to update rpm-list-mock.txt
                    find_missing_pkgs([], results, mock)

                # make /builddir writable without root access
                mock.exec_chroot_cmd("chown mockbuild -R /builddir")

                if props.shell_cmd_to_build is not None:
                    # prepare a build script in our tmp dir
                    build_script = "%s/build.sh" % results.tmpdir
                    cmd_tpl = "printf '#!/bin/sh\n\
cd /builddir/build/BUILD || exit $?\n\
cd %%s*/ || cd *\n\
%%s' '%s' '%s' | tee '%s' >&2\n"
                    results.exec_cmd(
                        cmd_tpl % (props.nvr, props.shell_cmd_to_build, build_script),
                        shell=True)
                    props.copy_in_files += [build_script]

                # copy required files into the chroot
                cmd = "tar -cP "
                cmd += strlist_to_shell_cmd(props.copy_in_files)
                cmd += " | "
                cmd += strlist_to_shell_cmd(
                    mock.get_mock_cmd(["--shell", "tar -xC/"]))
                results.exec_cmd(cmd, shell=True)

                # run post-depinst hooks
                props.run_hooks(results, "post-depinst", results, mock)

                if not props.no_scan:
                    if props.shell_cmd_to_build is None:
                        # install the copied SRPM into the chroot
                        mock.exec_mockbuild_cmd("rpm -Uvh --nodeps '%s'" % srpm_dup)
                        # make the installed SRPM accessible (if the maintainer did not)
                        mock.exec_chroot_cmd("chmod -R +r /builddir")

                    if props.keep_going:
                        # include ENABLE_KEEP_GOING_SCRIPT into CHROOT_FIXUPS
                        cmd = "ln -fv '%s' '%s'" % (ENABLE_KEEP_GOING_SCRIPT, CHROOT_FIXUPS)
                        mock.exec_mock_cmd(["--chroot", cmd])

                    # run fixups scripts
                    cmd_tpl = "for i in %s/*; do test -x $i && echo RUN: $i >&2 && $i; done"
                    mock.exec_mock_cmd(["--shell", cmd_tpl % CHROOT_FIXUPS])

                    if props.shell_cmd_to_build is None:
                        # run %prep phase without pluggin-in any static analyzers
                        cmd = "rpmbuild -bp --nodeps %s %s" % (props.spec_in, strlist_to_shell_cmd(props.rpm_opts))
                        ec = mock.exec_mockbuild_cmd(cmd, quiet=False)
                    else:
                        # extract the given archive (we got instead of SRPM)
                        if re.match("^.*\\.zip$", src_tar_dup):
                            # ZIP archive
                            prep_cmd_tpl = "unzip -d '%s' '%s'"
                        else:
                            # assume TAR
                            prep_cmd_tpl = "tar -C '%s' -xf '%s'"
                        prep_cmd = prep_cmd_tpl % ("/builddir/build/BUILD", src_tar_dup)
                        ec = mock.exec_mockbuild_cmd(prep_cmd)

                    if ec != 0:
                        results.error("%prep failed", ec=ec)

                    # make the unpacked contents accessible (if the maintainer did not)
                    mock.exec_chroot_cmd("chmod -R +r /builddir/build")

                    if not props.skip_build:
                        if props.shell_cmd_to_build is None:
                            # run %build phase with static analyzers plugged-in
                            rpm_opts = props.rpm_opts
                            if not props.run_check:
                                rpm_opts += NOCHECK_RPM_OPTS
                            build_cmd = "rpmbuild -bc --nodeps --short-circuit %s %s" \
                                    % (props.spec_in, strlist_to_shell_cmd(rpm_opts))
                        else:
                            # run the above prepared build script
                            build_cmd = "sh -x '%s'" % build_script

                        # wrap build_cmd by all the necessary wrappers
                        build_cmd = props.wrap_build_cmd(build_cmd)

                        # initialize environment variables according to ScanProps
                        build_cmd = props.wrap_shell_cmd_by_env(build_cmd)

                        ec = mock.exec_mockbuild_cmd(build_cmd, quiet=False)
                        if ec != 0:
                            results.error("%build failed", ec=ec)

                    if props.need_rpm_bi:
                        extra_rpm_opts = []
                        if not props.run_check:
                            # disable %check while running 'rpmbuild -bi'
                            if mock.exec_chroot_cmd("rpmbuild --nocheck") == 0:
                                extra_rpm_opts += ["--nocheck"]
                            else:
                                # fragile compatibility workaround for older versions of rpm-build,
                                # known to break if unescaped %check appears in a change log entry
                                extra_rpm_opts += ["--define", "check\\\n%%check\\\nexit 0"]

                            # static list of rpmbuild options to use with --nocheck
                            extra_rpm_opts += NOCHECK_RPM_OPTS

                        ec = mock.exec_rpmbuild_bi(props, extra_rpm_opts=extra_rpm_opts)
                        if ec != 0:
                            results.error("%install failed", ec=ec)
                        props.result_filters = [RPM_BI_FILTER] + props.result_filters

                    try:
                        # run post-install hooks
                        props.run_hooks(results, "post-install", results, mock, props)

                        # execute post-build commands in the chroot
                        for cmd in props.post_build_chroot_cmds:
                            rv = mock.exec_chroot_cmd(cmd)
                            if rv != 0:
                                results.error(f"post-build-chroot command failed with exit code: {rv}", ec=0)

                    finally:
                        # get the (intermediate) results out of the chroot
                        if props.copy_out_files:
                            cmd = strlist_to_shell_cmd(
                                mock.get_mock_cmd(
                                    ["--shell", "tar -c --remove-files " + strlist_to_shell_cmd(
                                        props.copy_out_files)]))

                            cmd += " | tar -xC '%s'" % results.dbgdir_raw
                            if results.exec_cmd(cmd, shell=True) != 0:
                                results.error("failed to get intermediate results from mock")

                if not props.no_scan:
                    if props.pick_cswrap_results(results) != 0:
                        results.error("failed to pick cswrap results")

                    # run post-process hooks
                    props.run_hooks(results, "post-process", results)

                # we are done with IniWriter
                results.ini_writer.close()

                # merge all results into a single file named scan-results-all.js
                ini_file = "%s/scan.ini" % results.resdir
                js_file = "%s/scan-results.js" % results.resdir
                all_file = "%s/scan-results-all.js" % results.dbgdir
                cmd = "cslinker --quiet --cwelist '%s' --inifile '%s' '%s'/* > '%s'" \
                        % (CWE_MAP_FILE, ini_file, results.dbgdir_uni, all_file)
                results.exec_cmd(cmd, shell=True)

                if props.embed_context > 0:
                    # embed context lines from source program files
                    tmp_file = f"{all_file}.tmp"
                    csgrep_cmd = f"{CSGREP_STATIC} --mode=json --embed-context {props.embed_context}"

                    if props.results_limits_opts:
                        # apply results limits already while embedding context to avoid creating excessively huge output
                        csgrep_cmd += " " + strlist_to_shell_cmd(props.results_limits_opts)

                    cmd = strlist_to_shell_cmd(mock.get_mock_cmd(["--shell", csgrep_cmd]))
                    cmd += f" <'{all_file}' >'{tmp_file}'"
                    if results.exec_cmd(cmd, shell=True) == 0:
                        shutil.move(tmp_file, all_file)
                        props.results_limits_applied = True

            # we are done with mock

            # make sure to apply results limits because `csgrep --embed-context` might not be available in chroot
            if props.results_limits_opts and not props.results_limits_applied:
                tmp_file = f"{all_file}.tmp"
                csgrep_cmd = "csgrep --mode=json " + strlist_to_shell_cmd(props.results_limits_opts)
                csgrep_cmd += f" '{all_file}' >'{tmp_file}'"
                ec = results.exec_cmd(csgrep_cmd, shell=True)
                if 0 == ec:
                    shutil.move(tmp_file, all_file)
                    props.results_limits_applied = True
                else:
                    results.error("failed to apply results limits", ec=ec)

            # apply filters, sort the list and record suppressed results
            supp_filters = [RPM_BI_FILTER, "csgrep --mode=json --strip-path-prefix /builddir/build/BUILD/"]
            apply_result_filters(props, results, supp_filters=supp_filters)

            return results.ec

    except FatalError as error:
        return error.ec


def do_diff_scan(props, output, diff_patches):
    try:
        with ScanResults(output, "csmock", "csmock-3.8.5-1.fc45", props.keep_going, create_dbgdir=False) as results:
            run0_props = copy.deepcopy(props)
            csdiff = "csdiff"
            if diff_patches:
                # we are looking for defects in patches
                assert not props.skip_patches
                run0_props.skip_patches = True
                title = "%s - Findings in Patches" % props.nvr
            else:
                # this is a version-diff-build
                run0_props.srpm         = run0_props.base_srpm
                run0_props.mock_profile = run0_props.base_mock_profile
                csdiff += " --ignore-path"
                title = "%s - Findings not detected in %s" % (props.nvr, props.base_srpm)

            run0 = "%s/run0" % results.resdir
            ec = do_scan(run0_props, run0)
            if ec != 0:
                results.error("scan of baseline package failed, cannot continue with scan of %s" %
                        props.nvr, ec=ec)

            run1 = "%s/run1" % results.resdir
            ec = do_scan(props, run1)
            if ec != 0:
                results.error("scan of %s failed" % props.nvr, ec=ec)

            # diff and process fixed defects
            run0_file = "%s/scan-results.js" % run0
            run1_file = "%s/scan-results.js" % run1
            js_file_fixed = "%s/scan-results-fixed.js" % results.resdir
            cmd = "%s --fixed %s %s > %s" % (csdiff, run0_file, run1_file, js_file_fixed)
            if results.exec_cmd(cmd, shell=True) != 0:
                results.error("csdiff --fixed failed")
            transform_results(js_file_fixed, results)

            # finalize scan.ini
            results.ini_writer.append("title", title)
            results.ini_writer.close()
            ini_file = "%s/scan.ini" % results.resdir

            # diff and process added defects
            js_file = "%s/scan-results.js" % results.resdir
            cmd_tpl = "%s %s %s | cslinker --inifile %s - > %s"
            cmd = cmd_tpl % (csdiff, run0_file, run1_file, ini_file, js_file)
            if results.exec_cmd(cmd, shell=True) != 0:
                results.error("csdiff failed")
            finalize_results(js_file, results, props)

            return results.ec

    except FatalError as error:
        return error.ec

if __name__ == '__main__':
    main()
