"""
This module contains all of the routines needed to set up a master server, this
involves preparing the three listeners and the workers needed by the master.
"""

import fnmatch
import logging
import os
import re
import stat
import time

import salt.acl
import salt.auth
import salt.cache
import salt.client
import salt.crypt
import salt.exceptions
import salt.fileserver
import salt.key
import salt.minion
import salt.payload
import salt.pillar
import salt.runner
import salt.state
import salt.utils.args
import salt.utils.atomicfile
import salt.utils.dictupdate
import salt.utils.event
import salt.utils.files
import salt.utils.gitfs
import salt.utils.gzip_util
import salt.utils.jid
import salt.utils.job
import salt.utils.mine
import salt.utils.minions
import salt.utils.path
import salt.utils.platform
import salt.utils.stringutils
import salt.utils.user
import salt.utils.verify
import salt.wheel
from salt.defaults import DEFAULT_TARGET_DELIM
from salt.pillar import git_pillar

try:
    import pwd

    HAS_PWD = True
except ImportError:
    # pwd is not available on windows
    HAS_PWD = False

log = logging.getLogger(__name__)

# Things to do in lower layers:
# only accept valid minion ids

MINION_EVENT_BLACKLIST = (
    "salt/job/*/publish",
    "salt/job/*/new",
    "salt/job/*/return",
    "salt/key",
    "salt/cloud/*",
    "salt/run/*",
    "salt/cluster/*",
    "salt/wheel/*/new",
    "salt/wheel/*/return",
    "salt/run/*",
    "salt/cloud/*",
)


def valid_minion_tag(tag, blacklist=MINION_EVENT_BLACKLIST):
    for black in blacklist:
        if fnmatch.fnmatch(tag, black):
            return False
    return True


def init_git_pillar(opts):
    """
    Clear out the ext pillar caches, used when the master starts
    """
    ret = []
    for opts_dict in [x for x in opts.get("ext_pillar", [])]:
        if "git" in opts_dict:
            try:
                pillar = salt.utils.gitfs.GitPillar(
                    opts,
                    opts_dict["git"],
                    per_remote_overrides=git_pillar.PER_REMOTE_OVERRIDES,
                    per_remote_only=git_pillar.PER_REMOTE_ONLY,
                    global_only=git_pillar.GLOBAL_ONLY,
                )
                ret.append(pillar)
            except salt.exceptions.FileserverConfigError:
                if opts.get("git_pillar_verify_config", True):
                    raise
                else:
                    log.critical("Could not initialize git_pillar")
    return ret


def clean_fsbackend(opts):
    """
    Clean out the old fileserver backends
    """
    # Clear remote fileserver backend caches so they get recreated
    for backend in ("git", "hg", "svn"):
        if backend in opts["fileserver_backend"]:
            env_cache = os.path.join(opts["cachedir"], f"{backend}fs", "envs.p")
            if os.path.isfile(env_cache):
                log.debug("Clearing %sfs env cache", backend)
                try:
                    os.remove(env_cache)
                except OSError as exc:
                    log.critical(
                        "Unable to clear env cache file %s: %s", env_cache, exc
                    )

            file_lists_dir = os.path.join(
                opts["cachedir"], "file_lists", f"{backend}fs"
            )
            try:
                file_lists_caches = os.listdir(file_lists_dir)
            except OSError:
                continue
            for file_lists_cache in fnmatch.filter(file_lists_caches, "*.p"):
                cache_file = os.path.join(file_lists_dir, file_lists_cache)
                try:
                    os.remove(cache_file)
                except OSError as exc:
                    log.critical(
                        "Unable to file_lists cache file %s: %s", cache_file, exc
                    )


def clean_expired_tokens(opts, loadauth=None):
    """
    Clean expired tokens from the master.

    If ``loadauth`` is provided, reuse the caller's LoadAuth instance
    rather than constructing a fresh one. Useful in long-running loops
    (e.g. Maintenance) to avoid recreating the auth/eauth_tokens
    LazyLoaders on every iteration.
    """
    if loadauth is not None:
        _loadauth = loadauth
        _owned = False
    else:
        _loadauth = salt.auth.LoadAuth(opts)
        _owned = True
    try:
        for tok in _loadauth.list_tokens():
            token_data = _loadauth.get_tok(tok)
            if (
                not token_data
                or "expire" not in token_data
                or token_data.get("expire", 0) < time.time()
            ):
                _loadauth.rm_token(tok)
    finally:
        if _owned:
            _loadauth.destroy()


def clean_pub_auth(opts):
    try:
        auth_cache = os.path.join(opts["cachedir"], "publish_auth")
        if not os.path.exists(auth_cache):
            return
        else:
            for dirpath, dirnames, filenames in salt.utils.path.os_walk(auth_cache):
                for auth_file in filenames:
                    auth_file_path = os.path.join(dirpath, auth_file)
                    if not os.path.isfile(auth_file_path):
                        continue
                    if time.time() - os.path.getmtime(auth_file_path) > (
                        salt.utils.job.get_keep_jobs_seconds(opts)
                    ):
                        os.remove(auth_file_path)
    except OSError:
        log.error("Unable to delete pub auth file")


def clean_old_jobs(opts, mminion=None):
    """
    Clean out the old jobs from the job cache.

    If ``mminion`` is provided, reuse the caller's MasterMinion rather
    than constructing a fresh one. See ``clean_expired_tokens`` for the
    same rationale.
    """
    # If the master job cache has a clean_old_jobs, call it
    fstr = "{}.clean_old_jobs".format(opts["master_job_cache"])
    if mminion is not None:
        _mminion = mminion
        _owned = False
    else:
        _mminion = salt.minion.MasterMinion(opts, states=False, rend=False)
        _owned = True
    try:
        if fstr in _mminion.returners:
            _mminion.returners[fstr]()
    finally:
        if _owned:
            if hasattr(_mminion, "destroy"):
                _mminion.destroy()


def mk_key(opts, user):
    uid = None
    if HAS_PWD:
        try:
            uid = pwd.getpwnam(user).pw_uid
        except KeyError:
            # User doesn't exist in the system
            if opts["client_acl_verify"]:
                return None
    if salt.utils.platform.is_windows():
        # The username may contain '\' if it is in Windows
        # 'DOMAIN\username' format. Fix this for the keyfile path.
        keyfile = os.path.join(
            opts["cachedir"], ".{}_key".format(user.replace("\\", "_"))
        )
    else:
        keyfile = os.path.join(opts["cachedir"], f".{user}_key")

    if os.path.exists(keyfile):
        log.debug("Removing stale keyfile: %s", keyfile)
        if salt.utils.platform.is_windows() and not os.access(keyfile, os.W_OK):
            # Cannot delete read-only files on Windows.
            os.chmod(keyfile, stat.S_IRUSR | stat.S_IWUSR)
        os.unlink(keyfile)

    key = salt.crypt.Crypticle.generate_key_string()
    with salt.utils.files.set_umask(0o277):
        with salt.utils.files.fopen(keyfile, "w+") as fp_:
            fp_.write(salt.utils.stringutils.to_str(key))
    # 600 octal: Read and write access to the owner only.
    # Write access is necessary since on subsequent runs, if the file
    # exists, it needs to be written to again. Windows enforces this.
    os.chmod(keyfile, 0o600)
    if HAS_PWD and uid is not None:
        try:
            os.chown(keyfile, uid, -1)
        except OSError:
            # The master is not being run as root and can therefore not
            # chown the key file
            pass
    return key


def access_keys(opts):
    """
    A key needs to be placed in the filesystem with permissions 0400 so
    clients are required to run as root.
    """
    # TODO: Need a way to get all available users for systems not supported by pwd module.
    #       For now users pattern matching will not work for publisher_acl.
    keys = {}
    publisher_acl = opts["publisher_acl"]
    acl_users = set(publisher_acl.keys())
    if opts.get("user"):
        acl_users.add(opts["user"])
    acl_users.add(salt.utils.user.get_user())
    # When publisher_acl or external_auth is configured, non-root users
    # invoking the ``salt`` CLI need to traverse ``cachedir`` to read their
    # per-user ``.<user>_key`` file (chowned to them with mode 0o600).
    # Since 3006.3 the master defaults to running as the ``salt`` user,
    # leaving ``cachedir`` owned by ``salt:salt`` with mode 0o750 which
    # blocks non-root traversal (#65317).  Add the world-execute bit so
    # other users can open files inside, without exposing directory
    # listings.
    if not salt.utils.platform.is_windows() and (
        publisher_acl or opts.get("external_auth")
    ):
        cachedir = opts.get("cachedir")
        if cachedir and os.path.isdir(cachedir):
            try:
                current = stat.S_IMODE(os.stat(cachedir).st_mode)
                if not current & stat.S_IXOTH:
                    os.chmod(cachedir, current | stat.S_IXOTH)  # nosec
            except OSError as exc:
                log.warning(
                    "Unable to set traversal permissions on cachedir "
                    "%s for publisher_acl users: %s",
                    cachedir,
                    exc,
                )
    for user in acl_users:
        log.info("Preparing the %s key for local communication", user)

        keyfile = os.path.join(opts["cachedir"], f".{user}_key")
        if os.path.exists(keyfile):
            with salt.utils.files.fopen(keyfile, "r") as fp:
                key = salt.utils.stringutils.to_unicode(fp.read())
        else:
            key = mk_key(opts, user)
        if key is not None:
            keys[user] = key

    # Check other users matching ACL patterns
    if opts["client_acl_verify"] and HAS_PWD:
        log.profile("Beginning pwd.getpwall() call in masterapi access_keys function")
        for user in pwd.getpwall():
            user = user.pw_name
            if user not in keys and salt.utils.stringutils.check_whitelist_blacklist(
                user, whitelist=acl_users
            ):
                if os.path.exists(keyfile):
                    with salt.utils.files.fopen(keyfile, "r") as fp:
                        keys[user] = salt.utils.stringutils.to_unicode(fp.read())
                else:
                    keys[user] = mk_key(opts, user)
        log.profile("End pwd.getpwall() call in masterapi access_keys function")

    return keys


def fileserver_update(fileserver):
    """
    Update the fileserver backends, requires that a salt.fileserver.Fileserver
    object be passed in
    """
    try:
        if not fileserver.servers:
            log.error(
                "No fileservers loaded, the master will not be able to "
                "serve files to minions"
            )
            raise salt.exceptions.SaltMasterError("No fileserver backends available")
        fileserver.update()
    except Exception as exc:  # pylint: disable=broad-except
        log.error(
            "Exception %s occurred in file server update",
            exc,
            exc_info_on_loglevel=logging.DEBUG,
        )


class AutoKey:
    """
    Implement the methods to run auto key acceptance and rejection
    """

    def __init__(self, opts):
        self.signing_files = {}
        self.opts = opts

    def check_permissions(self, filename):
        """
        Check if the specified filename has correct permissions
        """
        if salt.utils.platform.is_windows():
            return True

        # After we've ascertained we're not on windows
        groups = salt.utils.user.get_gid_list(self.opts["user"], include_default=False)
        fmode = os.stat(filename)

        if stat.S_IWOTH & fmode.st_mode:
            # don't allow others to write to the file
            return False

        if stat.S_IWGRP & fmode.st_mode:
            # if the group has write access only allow with permissive_pki_access
            if not self.opts.get("permissive_pki_access", False):
                return False
            elif os.getuid() == 0 and fmode.st_gid not in groups:
                # if salt is root it has to be in the group that has write access
                # this gives the group 'permission' to have write access
                return False

        return True

    def check_signing_file(self, keyid, signing_file):
        """
        Check a keyid for membership in a signing file
        """
        if not signing_file or not os.path.exists(signing_file):
            return False

        if not self.check_permissions(signing_file):
            log.warning("Wrong permissions for %s, ignoring content", signing_file)
            return False

        mtime = os.path.getmtime(signing_file)
        if self.signing_files.get(signing_file, {}).get("mtime") != mtime:
            self.signing_files.setdefault(signing_file, {})["mtime"] = mtime
            with salt.utils.files.fopen(signing_file, "r") as fp_:
                self.signing_files[signing_file]["data"] = [
                    entry
                    for entry in [line.strip() for line in fp_]
                    if not entry.strip().startswith("#")
                ]
        return any(
            salt.utils.stringutils.expr_match(keyid, line)
            for line in self.signing_files[signing_file].get("data", [])
        )

    def check_autosign_dir(self, keyid):
        """
        Check a keyid for membership in a autosign directory.
        """
        if self.opts["cluster_id"]:
            pki_dir = self.opts["cluster_pki_dir"]
        else:
            pki_dir = self.opts["pki_dir"]
        autosign_dir = os.path.join(pki_dir, "minions_autosign")

        # cleanup expired files
        expire_minutes = self.opts.get("autosign_timeout", 120)
        if expire_minutes > 0:
            min_time = time.time() - (60 * int(expire_minutes))
            for root, dirs, filenames in salt.utils.path.os_walk(autosign_dir):
                for f in filenames:
                    stub_file = os.path.join(autosign_dir, f)
                    mtime = os.path.getmtime(stub_file)
                    if mtime < min_time:
                        log.warning("Autosign keyid expired %s", stub_file)
                        os.remove(stub_file)

        stub_file = os.path.join(autosign_dir, keyid)
        if not os.path.exists(stub_file):
            return False
        os.remove(stub_file)
        return True

    def check_autosign_grains(self, autosign_grains):
        """
        Check for matching grains in the autosign_grains_dir.
        """
        if not autosign_grains or "autosign_grains_dir" not in self.opts:
            return False

        autosign_grains_dir = self.opts["autosign_grains_dir"]
        for root, dirs, filenames in os.walk(autosign_grains_dir):
            for grain in filenames:
                if grain in autosign_grains:
                    grain_file = os.path.join(autosign_grains_dir, grain)

                    if not self.check_permissions(grain_file):
                        log.warning(
                            "Wrong permissions for %s, ignoring content", grain_file
                        )
                        continue

                    with salt.utils.files.fopen(grain_file, "r") as f:
                        for line in f:
                            line = salt.utils.stringutils.to_unicode(line).strip()
                            if line.startswith("#"):
                                continue
                            if str(autosign_grains[grain]) == line:
                                return True
        return False

    def check_autoreject(self, keyid):
        """
        Checks if the specified keyid should automatically be rejected.
        """
        return self.check_signing_file(keyid, self.opts.get("autoreject_file", None))

    def check_autosign(self, keyid, autosign_grains=None):
        """
        Checks if the specified keyid should automatically be signed.
        """
        if self.opts["auto_accept"]:
            return True
        if self.check_signing_file(keyid, self.opts.get("autosign_file", None)):
            return True
        if self.check_autosign_dir(keyid):
            return True
        if self.check_autosign_grains(autosign_grains):
            return True
        return False


class RemoteFuncs:
    """
    Funcitons made available to minions, this class includes the raw routines
    post validation that make up the minion access to the master
    """

    def __init__(self, opts):
        self.opts = opts
        self.event = None
        self.ckminions = None
        self.tops = None
        self.local = None
        self.mminion = None
        self.cache = None
        self.wheel_ = None
        self.event = salt.utils.event.get_event(
            "master",
            self.opts["sock_dir"],
            opts=self.opts,
            listen=False,
        )
        self.ckminions = salt.utils.minions.CkMinions(opts)
        # Create the tops dict for loading external top data
        self.tops = salt.loader.tops(self.opts)
        # Make a client
        self.local = salt.client.get_local_client(mopts=self.opts)
        # Create the master minion to access the external job cache
        self.mminion = salt.minion.MasterMinion(self.opts, states=False, rend=False)
        self.__setup_fileserver()
        self.cache = salt.cache.factory(opts)

    def __setup_fileserver(self):
        """
        Set the local file objects from the file server interface
        """
        self.fs_ = salt.fileserver.Fileserver(self.opts)
        self._serve_file = self.fs_.serve_file
        self._file_find = self.fs_._find_file
        self._file_hash = self.fs_.file_hash
        self._file_list = self.fs_.file_list
        self._file_list_emptydirs = self.fs_.file_list_emptydirs
        self._dir_list = self.fs_.dir_list
        self._symlink_list = self.fs_.symlink_list
        self._file_envs = self.fs_.envs

    def __verify_minion_publish(self, load):
        """
        Verify that the passed information authorized a minion to execute
        """
        # Verify that the load is valid
        if "peer" not in self.opts:
            return False
        if not isinstance(self.opts["peer"], dict):
            return False
        if any(key not in load for key in ("fun", "arg", "tgt", "ret", "id")):
            return False
        # If the command will make a recursive publish don't run
        if re.match("publish.*", load["fun"]):
            return False
        # Check the permissions for this minion
        perms = []
        for match in self.opts["peer"]:
            if re.match(match, load["id"]):
                # This is the list of funcs/modules!
                if isinstance(self.opts["peer"][match], list):
                    perms.extend(self.opts["peer"][match])
        if "," in load["fun"]:
            # 'arg': [['cat', '/proc/cpuinfo'], [], ['foo']]
            load["fun"] = load["fun"].split(",")
            arg_ = []
            for arg in load["arg"]:
                arg_.append(arg.split())
            load["arg"] = arg_
        return self.ckminions.auth_check(
            perms,
            load["fun"],
            load["arg"],
            load["tgt"],
            load.get("tgt_type", "glob"),
            publish_validate=True,
        )

    def _master_opts(self, load):
        """
        Return the master options to the minion
        """
        mopts = {}
        file_roots = {}
        envs = self._file_envs()
        for saltenv in envs:
            if saltenv not in file_roots:
                file_roots[saltenv] = []
        mopts["file_roots"] = file_roots
        mopts["top_file_merging_strategy"] = self.opts["top_file_merging_strategy"]
        mopts["env_order"] = self.opts["env_order"]
        mopts["default_top"] = self.opts["default_top"]
        if load.get("env_only"):
            return mopts
        mopts["renderer"] = self.opts["renderer"]
        mopts["failhard"] = self.opts["failhard"]
        mopts["state_top"] = self.opts["state_top"]
        mopts["state_top_saltenv"] = self.opts["state_top_saltenv"]
        mopts["nodegroups"] = self.opts["nodegroups"]
        mopts["state_auto_order"] = self.opts["state_auto_order"]
        mopts["state_events"] = self.opts["state_events"]
        mopts["state_aggregate"] = self.opts["state_aggregate"]
        mopts["jinja_env"] = self.opts["jinja_env"]
        mopts["jinja_sls_env"] = self.opts["jinja_sls_env"]
        mopts["jinja_lstrip_blocks"] = self.opts["jinja_lstrip_blocks"]
        mopts["jinja_trim_blocks"] = self.opts["jinja_trim_blocks"]
        return mopts

    def _master_tops(self, load, skip_verify=False):
        """
        Return the results from master_tops if configured
        """
        if not skip_verify:
            if "id" not in load:
                log.error("Received call for external nodes without an id")
                return {}
            if not salt.utils.verify.valid_id(self.opts, load["id"]):
                return {}
        # Evaluate all configured master_tops interfaces

        opts = {}
        grains = {}
        ret = {}

        if "opts" in load:
            opts = load["opts"]
            if "grains" in load["opts"]:
                grains = load["opts"]["grains"]
        for fun in self.tops:
            if fun not in self.opts.get("master_tops", {}):
                continue
            try:
                ret = salt.utils.dictupdate.merge(
                    ret, self.tops[fun](opts=opts, grains=grains), merge_lists=True
                )
            except Exception as exc:  # pylint: disable=broad-except
                # If anything happens in the top generation, log it and move on
                log.error(
                    "Top function %s failed with error %s for minion %s",
                    fun,
                    exc,
                    load["id"],
                )
        return ret

    def _mine_get(self, load, skip_verify=False):
        """
        Gathers the data from the specified minions' mine
        """
        if not skip_verify:
            if any(key not in load for key in ("id", "tgt", "fun")):
                return {}

        if isinstance(load["fun"], str):
            functions = list(set(load["fun"].split(",")))
            _ret_dict = len(functions) > 1
        elif isinstance(load["fun"], list):
            functions = load["fun"]
            _ret_dict = True
        else:
            return {}

        functions_allowed = []

        if "mine_get" in self.opts:
            # If master side acl defined.
            if not isinstance(self.opts["mine_get"], dict):
                return {}
            perms = set()
            for match in self.opts["mine_get"]:
                if re.match(match, load["id"]):
                    if isinstance(self.opts["mine_get"][match], list):
                        perms.update(self.opts["mine_get"][match])
            for fun in functions:
                if any(re.match(perm, fun) for perm in perms):
                    functions_allowed.append(fun)
            if not functions_allowed:
                return {}
        else:
            functions_allowed = functions

        ret = {}
        if not salt.utils.verify.valid_id(self.opts, load["id"]):
            return ret

        expr_form = load.get("expr_form")
        # keep both expr_form and tgt_type to ensure
        # comptability between old versions of salt
        if expr_form is not None and "tgt_type" not in load:
            match_type = expr_form
        else:
            match_type = load.get("tgt_type", "glob")
        if match_type.lower() == "pillar":
            match_type = "pillar_exact"
        if match_type.lower() == "compound":
            match_type = "compound_pillar_exact"
        checker = salt.utils.minions.CkMinions(self.opts)
        _res = checker.check_minions(load["tgt"], match_type, greedy=False)
        minions = _res["minions"]
        minion_side_acl = {}  # Cache minion-side ACL
        for minion in minions:
            mine_data = self.cache.fetch("mine", minion)
            if not isinstance(mine_data, dict):
                continue
            for function in functions_allowed:
                if function not in mine_data:
                    continue
                mine_entry = mine_data[function]
                mine_result = mine_data[function]
                if (
                    isinstance(mine_entry, dict)
                    and salt.utils.mine.MINE_ITEM_ACL_ID in mine_entry
                ):
                    mine_result = mine_entry[salt.utils.mine.MINE_ITEM_ACL_DATA]
                    # Check and fill minion-side ACL cache
                    if function not in minion_side_acl.get(minion, {}):
                        if "allow_tgt" in mine_entry:
                            # Only determine allowed targets if any have been specified.
                            # This prevents having to add a list of all minions as allowed targets.
                            get_minion = checker.check_minions(
                                mine_entry["allow_tgt"],
                                mine_entry.get("allow_tgt_type", "glob"),
                            )["minions"]
                            # the minion in allow_tgt does not exist
                            if not get_minion:
                                continue
                            salt.utils.dictupdate.set_dict_key_value(
                                minion_side_acl,
                                f"{minion}:{function}",
                                get_minion,
                            )
                if salt.utils.mine.minion_side_acl_denied(
                    minion_side_acl, minion, function, load["id"]
                ):
                    continue
                if _ret_dict:
                    ret.setdefault(function, {})[minion] = mine_result
                else:
                    # There is only one function in functions_allowed.
                    ret[minion] = mine_result
        return ret

    def _mine(self, load, skip_verify=False):
        """
        Store/update the mine data in cache.
        """
        if not skip_verify:
            if "id" not in load or "data" not in load:
                return False
        if self.opts.get("minion_data_cache", False) or self.opts.get(
            "enforce_mine_cache", False
        ):
            ckey = load["id"]
            cbank = "mine"
            new_data = load["data"]
            if not load.get("clear", False):
                data = self.cache.fetch(cbank, ckey)
                if isinstance(data, dict):
                    data.update(new_data)
            self.cache.store(cbank, ckey, data)
        return True

    def _mine_delete(self, load):
        """
        Allow the minion to delete a specific function from its own mine
        """
        if "id" not in load or "fun" not in load:
            return False
        if self.opts.get("minion_data_cache", False) or self.opts.get(
            "enforce_mine_cache", False
        ):
            cbank = "mine"
            ckey = load["id"]
            try:
                data = self.cache.fetch(cbank, ckey)
                if not isinstance(data, dict):
                    return False
                if load["fun"] in data:
                    del data[load["fun"]]
                    self.cache.store(cbank, ckey, data)
            except OSError:
                return False
        return True

    def _mine_flush(self, load, skip_verify=False):
        """
        Allow the minion to delete all of its own mine contents
        """
        if not skip_verify and "id" not in load:
            return False
        if self.opts.get("minion_data_cache", False) or self.opts.get(
            "enforce_mine_cache", False
        ):
            return self.cache.flush("mine", load["id"])
        return True

    def _file_recv(self, load):
        """
        Allows minions to send files to the master, files are sent to the
        master file cache
        """
        if any(key not in load for key in ("id", "path", "loc")):
            return False
        if not self.opts["file_recv"] or os.path.isabs(load["path"]):
            return False
        if os.path.isabs(load["path"]) or "../" in load["path"]:
            # Can overwrite master files!!
            return False
        if not salt.utils.verify.valid_id(self.opts, load["id"]):
            return False
        file_recv_max_size = 1024 * 1024 * self.opts["file_recv_max_size"]

        if "loc" in load and load["loc"] < 0:
            log.error("Invalid file pointer: load[loc] < 0")
            return False

        if load.get("size", 0) > file_recv_max_size:
            log.error("Exceeding file_recv_max_size limit: %s", file_recv_max_size)
            return False

        if len(load["data"]) + load.get("loc", 0) > file_recv_max_size:
            log.error("Exceeding file_recv_max_size limit: %s", file_recv_max_size)
            return False
        # Normalize Windows paths
        normpath = load["path"]
        if ":" in normpath:
            # make sure double backslashes are normalized
            normpath = normpath.replace("\\", "/")
            normpath = os.path.normpath(normpath)
        cpath = os.path.join(
            self.opts["cachedir"], "minions", load["id"], "files", normpath
        )
        cdir = os.path.dirname(cpath)
        if not os.path.isdir(cdir):
            try:
                os.makedirs(cdir)
            except OSError:
                pass
        if os.path.isfile(cpath) and load["loc"] != 0:
            mode = "ab"
        else:
            mode = "wb"
        with salt.utils.files.fopen(cpath, mode) as fp_:
            if load["loc"]:
                fp_.seek(load["loc"])
            fp_.write(salt.utils.stringutils.to_str(load["data"]))
        return True

    def _pillar(self, load):
        """
        Return the pillar data for the minion
        """
        if any(key not in load for key in ("id", "grains")):
            return False
        log.debug("Master _pillar using ext: %s", load.get("ext"))
        pillar = salt.pillar.get_pillar(
            self.opts,
            load["grains"],
            load["id"],
            load.get("saltenv", load.get("env")),
            load.get("ext"),
            self.mminion.functions,
            pillar_override=load.get("pillar_override", {}),
        )
        data = pillar.compile_pillar()
        if self.opts.get("minion_data_cache", False):
            self.cache.store("grains", load["id"], load["grains"])
            if self.opts.get("minion_data_cache_events") is True:
                self.event.fire_event(
                    {"comment": "Minion data cache refresh"},
                    salt.utils.event.tagify(load["id"], "refresh", "minion"),
                )
        return data

    def _minion_event(self, load):
        """
        Receive an event from the minion and fire it on the master event
        interface
        """
        if "id" not in load:
            return False
        if "events" not in load and ("tag" not in load or "data" not in load):
            return False
        if "events" in load:
            for event in load["events"]:
                if "data" in event:
                    event_data = event["data"]
                else:
                    event_data = event
                # Fire pretagged event first (for syndics) before blacklist check
                # This allows syndics to forward events like salt/job/*/new with
                # the syndic/ prefix, bypassing the minion event blacklist
                if load.get("pretag") is not None:
                    self.event.fire_event(
                        event_data,
                        salt.utils.event.tagify(event["tag"], base=load["pretag"]),
                    )
                # Check blacklist for original tag
                if not valid_minion_tag(event["tag"]):
                    log.warning("Filtering blacklisted event tag %s", event["tag"])
                    continue
                self.event.fire_event(event_data, event["tag"])  # old dup event
        else:
            tag = load["tag"]
            self.event.fire_event(load, tag)
        return True

    def _return(self, load):
        """
        Handle the return data sent from the minions
        """
        # Generate EndTime
        endtime = salt.utils.jid.jid_to_time(salt.utils.jid.gen_jid(self.opts))
        # If the return data is invalid, just ignore it
        if any(key not in load for key in ("return", "jid", "id")):
            return False

        if load["jid"] == "req":
            # The minion is returning a standalone job, request a jobid
            prep_fstr = "{}.prep_jid".format(self.opts["master_job_cache"])
            load["jid"] = self.mminion.returners[prep_fstr](
                nocache=load.get("nocache", False)
            )

            # save the load, since we don't have it
            saveload_fstr = "{}.save_load".format(self.opts["master_job_cache"])
            self.mminion.returners[saveload_fstr](load["jid"], load)
        log.info("Got return from %s for job %s", load["id"], load["jid"])
        self.event.fire_event(load, load["jid"])  # old dup event
        self.event.fire_event(
            load, salt.utils.event.tagify([load["jid"], "ret", load["id"]], "job")
        )
        self.event.fire_ret_load(load)
        if not self.opts["job_cache"] or self.opts.get("ext_job_cache"):
            return

        fstr = "{}.update_endtime".format(self.opts["master_job_cache"])
        if self.opts.get("job_cache_store_endtime") and fstr in self.mminion.returners:
            self.mminion.returners[fstr](load["jid"], endtime)

        fstr = "{}.returner".format(self.opts["master_job_cache"])
        self.mminion.returners[fstr](load)

    def _syndic_return(self, load):
        """
        Receive a syndic minion return and format it to look like returns from
        individual minions.
        """
        # Verify the load
        if any(key not in load for key in ("return", "jid", "id")):
            return None
        # if we have a load, save it
        if "load" in load:
            fstr = "{}.save_load".format(self.opts["master_job_cache"])
            self.mminion.returners[fstr](load["jid"], load["load"])

        # Format individual return loads
        for key, item in load["return"].items():
            ret = {"jid": load["jid"], "id": key, "return": item}
            if "out" in load:
                ret["out"] = load["out"]
            self._return(ret)

    def minion_runner(self, load):
        """
        Execute a runner from a minion, return the runner's function data
        """
        if "peer_run" not in self.opts:
            return {}
        if not isinstance(self.opts["peer_run"], dict):
            return {}
        if any(key not in load for key in ("fun", "arg", "id")):
            return {}
        perms = set()
        for match in self.opts["peer_run"]:
            if re.match(match, load["id"]):
                # This is the list of funcs/modules!
                if isinstance(self.opts["peer_run"][match], list):
                    perms.update(self.opts["peer_run"][match])
        good = False
        for perm in perms:
            if re.match(perm, load["fun"]):
                good = True
        if not good:
            # The minion is not who it says it is!
            # We don't want to listen to it!
            log.warning("Minion id %s is not who it says it is!", load["id"])
            return {}
        # Prepare the runner object
        opts = {}
        opts.update(self.opts)
        opts.update(
            {
                "fun": load["fun"],
                "arg": salt.utils.args.parse_input(
                    load["arg"], no_parse=load.get("no_parse", [])
                ),
                "id": load["id"],
                "doc": False,
                "conf_file": self.opts["conf_file"],
            }
        )
        runner = salt.runner.Runner(opts)
        return runner.run()

    def pub_ret(self, load, skip_verify=False):
        """
        Request the return data from a specific jid, only allowed
        if the requesting minion also initialted the execution.
        """
        if not skip_verify and any(key not in load for key in ("jid", "id")):
            return {}
        else:
            auth_cache = os.path.join(self.opts["cachedir"], "publish_auth")
            if not os.path.isdir(auth_cache):
                os.makedirs(auth_cache)
            jid_fn = os.path.join(auth_cache, load["jid"])
            with salt.utils.files.fopen(jid_fn, "r") as fp_:
                if not load["id"] == salt.utils.stringutils.to_unicode(fp_.read()):
                    return {}

            return self.local.get_cache_returns(load["jid"])

    def minion_pub(self, load):
        """
        Publish a command initiated from a minion, this method executes minion
        restrictions so that the minion publication will only work if it is
        enabled in the config.
        The configuration on the master allows minions to be matched to
        salt functions, so the minions can only publish allowed salt functions
        The config will look like this:
        peer:
            .*:
                - .*
        This configuration will enable all minions to execute all commands.
        peer:
            foo.example.com:
                - test.*
        This configuration will only allow the minion foo.example.com to
        execute commands from the test module
        """
        if not self.__verify_minion_publish(load):
            return {}
        # Set up the publication payload
        pub_load = {
            "fun": load["fun"],
            "arg": salt.utils.args.parse_input(
                load["arg"], no_parse=load.get("no_parse", [])
            ),
            "tgt_type": load.get("tgt_type", "glob"),
            "tgt": load["tgt"],
            "ret": load["ret"],
            "id": load["id"],
        }
        if "tgt_type" in load:
            if load["tgt_type"].startswith("node"):
                if load["tgt"] in self.opts["nodegroups"]:
                    pub_load["tgt"] = self.opts["nodegroups"][load["tgt"]]
                    pub_load["tgt_type"] = "compound"
                else:
                    return {}
            else:
                pub_load["tgt_type"] = load["tgt_type"]
        ret = {}
        ret["jid"] = self.local.cmd_async(**pub_load)
        _res = self.ckminions.check_minions(load["tgt"], pub_load["tgt_type"])
        ret["minions"] = _res["minions"]
        auth_cache = os.path.join(self.opts["cachedir"], "publish_auth")
        if not os.path.isdir(auth_cache):
            os.makedirs(auth_cache)
        jid_fn = os.path.join(auth_cache, str(ret["jid"]))
        with salt.utils.files.fopen(jid_fn, "w+") as fp_:
            fp_.write(salt.utils.stringutils.to_str(load["id"]))
        return ret

    def minion_publish(self, load):
        """
        Publish a command initiated from a minion, this method executes minion
        restrictions so that the minion publication will only work if it is
        enabled in the config.
        The configuration on the master allows minions to be matched to
        salt functions, so the minions can only publish allowed salt functions
        The config will look like this:
        peer:
            .*:
                - .*
        This configuration will enable all minions to execute all commands.
        peer:
            foo.example.com:
                - test.*
        This configuration will only allow the minion foo.example.com to
        execute commands from the test module
        """
        if not self.__verify_minion_publish(load):
            return {}
        # Set up the publication payload
        pub_load = {
            "fun": load["fun"],
            "arg": salt.utils.args.parse_input(
                load["arg"], no_parse=load.get("no_parse", [])
            ),
            "tgt_type": load.get("tgt_type", "glob"),
            "tgt": load["tgt"],
            "ret": load["ret"],
            "id": load["id"],
        }
        if "tmo" in load:
            try:
                pub_load["timeout"] = int(load["tmo"])
            except ValueError:
                msg = "Failed to parse timeout value: {}".format(load["tmo"])
                log.warning(msg)
                return {}
        if "timeout" in load:
            try:
                pub_load["timeout"] = int(load["timeout"])
            except ValueError:
                msg = "Failed to parse timeout value: {}".format(load["timeout"])
                log.warning(msg)
                return {}
        if "tgt_type" in load:
            if load["tgt_type"].startswith("node"):
                if load["tgt"] in self.opts["nodegroups"]:
                    pub_load["tgt"] = self.opts["nodegroups"][load["tgt"]]
                    pub_load["tgt_type"] = "compound"
                else:
                    return {}
            else:
                pub_load["tgt_type"] = load["tgt_type"]
        pub_load["raw"] = True
        ret = {}
        for minion in self.local.cmd_iter(**pub_load):
            if load.get("form", "") == "full":
                data = minion
                if "jid" in minion:
                    ret["__jid__"] = minion["jid"]
                data["ret"] = data.pop("return")
                ret[minion["id"]] = data
            else:
                ret[minion["id"]] = minion["return"]
                if "jid" in minion:
                    ret["__jid__"] = minion["jid"]
        for key, val in self.local.get_cache_returns(ret["__jid__"]).items():
            if key not in ret:
                ret[key] = val
        if load.get("form", "") != "full":
            ret.pop("__jid__")
        return ret

    def revoke_auth(self, load):
        """
        Allow a minion to request revocation of its own key
        """
        if "id" not in load:
            return False
        with salt.key.Key(self.opts) as keyapi:
            keyapi.delete_key(
                load["id"], preserve_minions=load.get("preserve_minion_cache", False)
            )
        return True

    def destroy(self):
        if self.event is not None:
            self.event.destroy()
            self.event = None
        if self.local is not None:
            self.local.destroy()
            self.local = None
        if self.mminion is not None:
            self.mminion.destroy()
            self.mminion = None
        if self.tops is not None:
            if hasattr(self.tops, "destroy"):
                self.tops.destroy()
            self.tops = None
        if self.cache is not None:
            if hasattr(self.cache, "destroy"):
                self.cache.destroy()
            self.cache = None
        if self.ckminions is not None:
            if hasattr(self.ckminions, "cache") and self.ckminions.cache is not None:
                if hasattr(self.ckminions.cache, "destroy"):
                    self.ckminions.cache.destroy()
                self.ckminions.cache = None
            self.ckminions = None
        self.wheel_ = None
        # Clear bound methods from fileserver to allow GC
        if hasattr(self, "fs_") and self.fs_ is not None:
            if hasattr(self.fs_, "destroy"):
                self.fs_.destroy()
            self.fs_ = None
        self._serve_file = None
        self._file_find = None
        self._file_hash = None
        self._file_list = None
        self._file_list_emptydirs = None
        self._dir_list = None
        self._symlink_list = None
        self._file_envs = None


class LocalFuncs:
    """
    Set up methods for use only from the local system
    """

    # The ClearFuncs object encapsulates the functions that can be executed in
    # the clear:
    # publish (The publish from the LocalClient)
    # _auth
    def __init__(self, opts, key):
        self.opts = opts
        self.key = key
        self.event = None
        self.local = None
        self.ckminions = None
        self.loadauth = None
        self.mminion = None
        # Create the event manager
        self.event = salt.utils.event.get_event(
            "master",
            self.opts["sock_dir"],
            opts=self.opts,
            listen=False,
        )
        # Make a client
        self.local = salt.client.get_local_client(mopts=self.opts)
        # Make an minion checker object
        self.ckminions = salt.utils.minions.CkMinions(opts)
        # Make an Auth object
        self.loadauth = salt.auth.LoadAuth(opts)
        # Stand up the master Minion to access returner data
        self.mminion = salt.minion.MasterMinion(self.opts, states=False, rend=False)

    def runner(self, load):
        """
        Send a master control function back to the runner system
        """
        # All runner opts pass through eauth
        auth_type, err_name, key = self._prep_auth_info(load)

        # Authenticate
        auth_check = self.loadauth.check_authentication(load, auth_type)
        error = auth_check.get("error")

        if error:
            # Authentication error occurred: do not continue.
            return {"error": error}

        # Authorize
        runner_check = self.ckminions.runner_check(
            auth_check.get("auth_list", []), load["fun"], load["kwarg"]
        )
        username = auth_check.get("username")
        if not runner_check:
            return {
                "error": {
                    "name": err_name,
                    "message": (
                        'Authentication failure of type "{}" occurred '
                        "for user {}.".format(auth_type, username)
                    ),
                }
            }
        elif isinstance(runner_check, dict) and "error" in runner_check:
            # A dictionary with an error name/message was handled by ckminions.runner_check
            return runner_check

        # Authorized. Do the job!
        try:
            fun = load.pop("fun")
            with salt.runner.RunnerClient(self.opts) as runner_client:
                return runner_client.asynchronous(fun, load.get("kwarg", {}), username)
        except Exception as exc:  # pylint: disable=broad-except
            log.exception("Exception occurred while introspecting %s", fun)
            return {
                "error": {
                    "name": exc.__class__.__name__,
                    "args": exc.args,
                    "message": str(exc),
                }
            }

    def wheel(self, load):
        """
        Send a master control function back to the wheel system
        """
        # All wheel ops pass through eauth
        auth_type, err_name, key = self._prep_auth_info(load)

        # Authenticate
        auth_check = self.loadauth.check_authentication(
            load, auth_type, key=key, show_username=True
        )
        error = auth_check.get("error")

        if error:
            # Authentication error occurred: do not continue.
            return {"error": error}

        # Authorize
        username = auth_check.get("username")
        if auth_type != "user":
            wheel_check = self.ckminions.wheel_check(
                auth_check.get("auth_list", []), load["fun"], load["kwarg"]
            )
            if not wheel_check:
                return {
                    "error": {
                        "name": err_name,
                        "message": (
                            'Authentication failure of type "{}" occurred for '
                            "user {}.".format(auth_type, username)
                        ),
                    }
                }
            elif isinstance(wheel_check, dict) and "error" in wheel_check:
                # A dictionary with an error name/message was handled by ckminions.wheel_check
                return wheel_check

        # Authenticated. Do the job.
        jid = salt.utils.jid.gen_jid(self.opts)
        fun = load.pop("fun")
        tag = salt.utils.event.tagify(jid, prefix="wheel")
        data = {
            "fun": f"wheel.{fun}",
            "jid": jid,
            "tag": tag,
            "user": username,
        }
        try:
            self.event.fire_event(data, salt.utils.event.tagify([jid, "new"], "wheel"))
            with salt.wheel.WheelClient(self.opts) as wheel_client:
                ret = wheel_client.call_func(fun, **load)
            data["return"] = ret
            data["success"] = True
            self.event.fire_event(data, salt.utils.event.tagify([jid, "ret"], "wheel"))
            return {"tag": tag, "data": data}
        except Exception as exc:  # pylint: disable=broad-except
            log.exception("Exception occurred while introspecting %s", fun)
            data["return"] = "Exception occurred in wheel {}: {}: {}".format(
                fun,
                exc.__class__.__name__,
                exc,
            )
            data["success"] = False
            self.event.fire_event(data, salt.utils.event.tagify([jid, "ret"], "wheel"))
            return {"tag": tag, "data": data}

    def mk_token(self, load):
        """
        Create and return an authentication token, the clear load needs to
        contain the eauth key and the needed authentication creds.
        """
        token = self.loadauth.mk_token(load)
        if not token:
            log.warning('Authentication failure of type "eauth" occurred.')
            return ""
        return token

    def get_token(self, load):
        """
        Return the name associated with a token or False if the token is invalid
        """
        if "token" not in load:
            return False
        return self.loadauth.get_tok(load["token"])

    def publish(self, load):
        """
        This method sends out publications to the minions, it can only be used
        by the LocalClient.
        """
        extra = load.get("kwargs", {})

        publisher_acl = salt.acl.PublisherACL(self.opts["publisher_acl_blacklist"])

        if publisher_acl.user_is_blacklisted(
            load["user"]
        ) or publisher_acl.cmd_is_blacklisted(load["fun"]):
            log.error(
                "%s does not have permissions to run %s. Please contact "
                "your local administrator if you believe this is in error.",
                load["user"],
                load["fun"],
            )
            return {
                "error": {
                    "name": "AuthorizationError",
                    "message": "Authorization error occurred.",
                }
            }

        # Retrieve the minions list
        delimiter = load.get("kwargs", {}).get("delimiter", DEFAULT_TARGET_DELIM)
        _res = self.ckminions.check_minions(
            load["tgt"], load.get("tgt_type", "glob"), delimiter
        )
        minions = _res["minions"]

        # Check for external auth calls and authenticate
        auth_type, err_name, key = self._prep_auth_info(extra)
        if auth_type == "user":
            auth_check = self.loadauth.check_authentication(load, auth_type, key=key)
        else:
            auth_check = self.loadauth.check_authentication(extra, auth_type)

        # Setup authorization list variable and error information
        auth_list = auth_check.get("auth_list", [])
        error = auth_check.get("error")
        err_msg = f'Authentication failure of type "{auth_type}" occurred.'

        if error:
            # Authentication error occurred: do not continue.
            log.warning(err_msg)
            return {
                "error": {
                    "name": "AuthenticationError",
                    "message": "Authentication error occurred.",
                }
            }

        # All Token, Eauth, and non-root users must pass the authorization check
        if auth_type != "user" or (auth_type == "user" and auth_list):
            # Authorize the request
            authorized = self.ckminions.auth_check(
                auth_list,
                load["fun"],
                load["arg"],
                load["tgt"],
                load.get("tgt_type", "glob"),
                minions=minions,
                # always accept find_job
                whitelist=["saltutil.find_job"],
            )

            if not authorized:
                # Authorization error occurred. Log warning and do not continue.
                log.warning(err_msg)
                return {
                    "error": {
                        "name": "AuthorizationError",
                        "message": "Authorization error occurred.",
                    }
                }

            # Perform some specific auth_type tasks after the authorization check
            if auth_type == "token":
                username = auth_check.get("username")
                load["user"] = username
                log.debug('Minion tokenized user = "%s"', username)
            elif auth_type == "eauth":
                # The username we are attempting to auth with
                load["user"] = self.loadauth.load_name(extra)

        # If we order masters (via a syndic), don't short circuit if no minions
        # are found
        if not self.opts.get("order_masters"):
            # Check for no minions
            if not minions:
                return {"enc": "clear", "load": {"jid": None, "minions": minions}}
        # Retrieve the jid
        if not load["jid"]:
            fstr = "{}.prep_jid".format(self.opts["master_job_cache"])
            load["jid"] = self.mminion.returners[fstr](
                nocache=extra.get("nocache", False)
            )
        self.event.fire_event({"minions": minions}, load["jid"])

        new_job_load = {
            "jid": load["jid"],
            "tgt_type": load["tgt_type"],
            "tgt": load["tgt"],
            "user": load["user"],
            "fun": load["fun"],
            "arg": salt.utils.args.parse_input(
                load["arg"], no_parse=load.get("no_parse", [])
            ),
            "minions": minions,
        }

        # Announce the job on the event bus
        self.event.fire_event(new_job_load, "new_job")  # old dup event
        self.event.fire_event(
            new_job_load, salt.utils.event.tagify([load["jid"], "new"], "job")
        )

        # Save the invocation information
        if self.opts["ext_job_cache"]:
            try:
                fstr = "{}.save_load".format(self.opts["ext_job_cache"])
                self.mminion.returners[fstr](load["jid"], load)
            except KeyError:
                log.critical(
                    "The specified returner used for the external job cache "
                    '"%s" does not have a save_load function!',
                    self.opts["ext_job_cache"],
                )
            except Exception:  # pylint: disable=broad-except
                log.critical(
                    "The specified returner threw a stack trace:", exc_info=True
                )

        # always write out to the master job cache
        try:
            fstr = "{}.save_load".format(self.opts["master_job_cache"])
            self.mminion.returners[fstr](load["jid"], load)
        except KeyError:
            log.critical(
                "The specified returner used for the master job cache "
                '"%s" does not have a save_load function!',
                self.opts["master_job_cache"],
            )
        except Exception:  # pylint: disable=broad-except
            log.critical("The specified returner threw a stack trace:", exc_info=True)
        # Altering the contents of the publish load is serious!! Changes here
        # break compatibility with minion/master versions and even tiny
        # additions can have serious implications on the performance of the
        # publish commands.
        #
        # In short, check with Thomas Hatch before you even think about
        # touching this stuff, we can probably do what you want to do another
        # way that won't have a negative impact.
        pub_load = {
            "fun": load["fun"],
            "arg": salt.utils.args.parse_input(
                load["arg"], no_parse=load.get("no_parse", [])
            ),
            "tgt": load["tgt"],
            "jid": load["jid"],
            "ret": load["ret"],
        }

        if "id" in extra:
            pub_load["id"] = extra["id"]
        if "tgt_type" in load:
            pub_load["tgt_type"] = load["tgt_type"]
        if "to" in load:
            pub_load["to"] = load["to"]

        if "kwargs" in load:
            if "ret_config" in load["kwargs"]:
                pub_load["ret_config"] = load["kwargs"].get("ret_config")

            if "metadata" in load["kwargs"]:
                pub_load["metadata"] = load["kwargs"].get("metadata")

            if "ret_kwargs" in load["kwargs"]:
                pub_load["ret_kwargs"] = load["kwargs"].get("ret_kwargs")

        if "user" in load:
            log.info(
                "User %s Published command %s with jid %s",
                load["user"],
                load["fun"],
                load["jid"],
            )
            pub_load["user"] = load["user"]
        else:
            log.info("Published command %s with jid %s", load["fun"], load["jid"])
        log.debug("Published command details %s", pub_load)

        return {"ret": {"jid": load["jid"], "minions": minions}, "pub": pub_load}

    def _prep_auth_info(self, load):
        key = None
        if "token" in load:
            auth_type = "token"
            err_name = "TokenAuthenticationError"
        elif "eauth" in load:
            auth_type = "eauth"
            err_name = "EauthAuthenticationError"
        else:
            auth_type = "user"
            err_name = "UserAuthenticationError"
            key = self.key

        return auth_type, err_name, key

    def destroy(self):
        if self.event is not None:
            self.event.destroy()
            self.event = None
        if self.local is not None:
            self.local.destroy()
            self.local = None
        if self.mminion is not None:
            self.mminion.destroy()
            self.mminion = None
        if self.loadauth is not None:
            self.loadauth.destroy()
            self.loadauth = None
        if self.ckminions is not None:
            if hasattr(self.ckminions, "cache") and self.ckminions.cache is not None:
                if hasattr(self.ckminions.cache, "destroy"):
                    self.ckminions.cache.destroy()
                self.ckminions.cache = None
            self.ckminions = None
