#!/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
from typing import Optional, Tuple

# 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.hermetic_build = props.hermetic_build
        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]

        # 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 setup_chroot(self, srpm):
        """Set up the mock chroot, including hermetic build setup if requested."""
        if self.hermetic_build is not None:
            (lockfile, repo_dir) = self.hermetic_build
            ec = self.exec_mock_cmd(["--hermetic-build", lockfile, repo_dir,
                                     "--short-circuit=prep", "--rpmbuild-opts=--noprep",
                                     "-N", srpm], quiet=False)
            if ec != 0:
                self.results.error("failed to set up hermetic chroot", ec=ec)
            self.def_cmd += [f"--config-opts=offline_local_repository={repo_dir}"]

        # -r is added here (rather than in __enter__) because mock's
        # --hermetic-build is incompatible with -r and must run first
        self.def_cmd += ["-r", self.mock_profile]

    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`
            # skip for hermetic builds (deps should be calculated prior)
            srpm_deps_ok = srpm is None or self.hermetic_build is not 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 self.hermetic_build and missing_deps:
                self.results.print_with_ts(
                    f"WARN: Proceeding with hermetic build despite missing deps: {strlist_to_shell_cmd(missing_deps)}"
                )
                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: Optional[str] = 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
        self.hermetic_build: Optional[Tuple] = 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.6-1.el9")
        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)'
    )

    parser.add_argument(
        "--hermetic-build",
        nargs=2,
        metavar=("LOCKFILE", "REPO_DIRECTORY"),
        help="perform a hermetic (fully offline) build using a pre-generated "
        "lockfile and offline RPM repository (see mock --hermetic-build)",
    )

    # --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

    if args.hermetic_build is not None:
        (lockfile, repo_dir) = args.hermetic_build
        require_file(parser, lockfile)
        if not os.path.isdir(repo_dir):
            parser.error(f"not a directory: {repo_dir}")
        if not os.path.isdir(os.path.join(repo_dir, "repodata")):
            parser.error(f"repo directory missing repodata/: {repo_dir}")
        props.hermetic_build = (os.path.realpath(lockfile), os.path.realpath(repo_dir))
        props.mock_profile = "hermetic-build"
        props.skip_mock_init = True

    # 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.6-1.el9", 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:
                mock.setup_chroot(props.srpm)

                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.6-1.el9", 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()
