# Copyright 1999-2017. Parallels IP Holdings GmbH. All Rights Reserved.

import os
import sys
import pwd
import grp
import glob
import re
import logging as log
from optparse import OptionParser
import subprocess
import plesk_subprocess
from fsmng_permissions import FILES_AC
from fsmng_stat import FileStat

RPM_BIN = "/bin/rpm"
DPKG_BIN = "/usr/bin/dpkg"
APT_CACHE_DIR = "/var/cache/apt/archives"
APT_GET_BIN = "/usr/bin/apt-get"


class FsError(Exception):
    pass


class FileNotInPackageError(Exception):
    pass


def get_owners(names):
    """ Turn comma-separated sysuser names into list of uids. """
    owners = []
    for name in names.split(','):
        try:
            owners.append(pwd.getpwnam(name).pw_uid)
        except KeyError:
            pass
    return owners


def get_groups(names):
    """ Turn comma-separated sysgroup names into list of gids. """
    groups = []
    for name in names.split(','):
        try:
            groups.append(grp.getgrnam(name).gr_gid)
        except KeyError:
            pass
    return groups


class RpmFilePerms:
    """ Helper to acquire proper file permissions by inspecting corresponding rpm-package meta-info """
    @classmethod
    def is_installed(cls):
        """ Check if rpm is installed. """
        return os.path.exists(RPM_BIN)

    def get_file_perms(self, path, content_data=None):
        """ Get FileStat object for path as described in corresponding package
            or raise FileNotInPackageError.
        """
        npath = os.path.normpath(path)
        cmd = [RPM_BIN,  "-qf", path, "--dump"]
        if content_data is None:
            content_data = plesk_subprocess.check_output(cmd)
        for line in content_data.split("\n"):
            fields = re.split(r'\s+', line)
            if fields[0] == npath:
                fs = FileStat(perms=oct(int(fields[4], 8) & 07777), user=fields[5], group=fields[6])
                fs.set_mode(int(fields[4], 8))
                if fs.typ == FileStat.LNK:
                    fs.link_to = fields[10]
                return fs
        raise FileNotInPackageError("File permissions are not found for %s" % path)


class DebFilePerms:
    """ Helper to acquire proper file's permissions by inspecting corresponding deb-package """
    PACKAGE_PATTERN = "Package: "
    VERSION_PATTERN = "Version: "

    def __init__(self):
        pass

    @classmethod
    def is_installed(cls):
        """ Check if Apt/dpkg stuff is installed. """
        return os.path.exists(DPKG_BIN)

    def get_file_perms(self, path):
        """ Get tuple (perms, owner, group) for path
            as described in correspongind package or raise FileNotInPackageError.
        """
        try:
            cmd = [DPKG_BIN, "-S", path]
            output = plesk_subprocess.check_output(cmd)
            pkg_name = output[:output.index(":")].split(", ")[0]
            pkg_version = self._installed_dpkg_package_version(pkg_name)
            log.debug("File %s is from %s (%s)" % (path, pkg_name, pkg_version))
            pkg_path = self._get_package_path(pkg_name, pkg_version)
            return self._get_file_perms_from_package(path, pkg_path)
        except subprocess.CalledProcessError as e:
            raise FileNotInPackageError("%s" % e)

    def _installed_dpkg_package_version(self, pkg_name):
        cmd = [DPKG_BIN, "-s", pkg_name]
        output = plesk_subprocess.check_output(cmd)
        for line in output.split("\n"):
            if line.startswith(self.VERSION_PATTERN):
                return line[len(self.VERSION_PATTERN):]
        raise FileNotInPackageError("failed to detect version of package %s" % pkg_name)

    @classmethod
    def _get_file_perms_from_package(cls, file_path, pkg_path, content_data=None):
        pat = "."+os.path.normpath(file_path)
        if not content_data:
            content_data = plesk_subprocess.check_output([DPKG_BIN, "-c", pkg_path])
        for line in content_data.split("\n"):
            filepath = re.split(r'\s+', line)[5]
            if filepath in (pat, pat+"/"):
                return cls._parse_pkg_file_perms(line)
        raise FileNotInPackageError("failed to get file permissions from package")

    @classmethod
    def _parse_pkg_file_perms(cls, line):
        fs = FileStat()
        res = 0
        i = 1
        for usertype in ["USR", "GRP", "OTH"]:
            for permtype in ["r", "w", "x"]:
                res *= 2
                if permtype == line[i]:
                    res += 1
                i += 1
        fs.perms = oct(res)
        log.debug("parsed %s into %s (%d)" % (line[1:10], fs.perms, res))
        m = re.search(r'^\S+\s(\w+)/(\w+)\s', line)
        if not m:
            raise FileNotInPackageError("failed to parse user/group: '%s'" % line)
        (fs.user, fs.group) = m.groups()
        fields = re.split(r'\s+', line)
        if line[0] == 'l':
            (fs.typ, fs.link_to) = (FileStat.LNK, fields[7])
        elif line[0] == 'd':
            fs.typ = FileStat.DIR
        elif line[0] == '-':
            fs.typ = FileStat.REG
        else:
            log.warning("unexpected file type '%s' in '%s'", line[0], line)

        return fs

    def _get_package_path(self, pkg_name, pkg_version):
        path = self._find_pkg_in_cache(pkg_name, pkg_version)
        if path:
            return path
        path = self._download_package(pkg_name, pkg_version)
        if path:
            return path
        raise FileNotInPackageError("Cannot find deb-archive for package %s (%s)" % (pkg_name, pkg_version))

    def _find_pkg_in_cache(self, pkg_name, pkg_version):
        for path in glob.glob(os.path.join(APT_CACHE_DIR, pkg_name+"*.deb")):
            if self._check_package_name_version(path, pkg_name, pkg_version):
                return path
        return None

    def _check_package_name_version(self, path, name, version):
        cmd = [DPKG_BIN, "-I", path]
        output = plesk_subprocess.check_output(cmd)
        (name_ok, ver_ok) = (False, False)
        for line in output.split("\n"):
            if line == " " + self.PACKAGE_PATTERN + name:
                name_ok = True
            if line == " " + self.VERSION_PATTERN + version:
                ver_ok = True
        log.debug("name, version OK = %s, %s: %s (%s)" % (name_ok, ver_ok, name, version))
        return name_ok and ver_ok

    def _download_package(self, name, version):
        log.debug("_download_package '%s'" % name)
        subprocess.check_call([APT_GET_BIN, "update"])
        cmd = [APT_GET_BIN, "install", "--reinstall", "--download-only", name+"="+version]
        subprocess.check_call(cmd)
        return self._find_pkg_in_cache(name, version)


def get_package_file_permissions(path):
    """ Returns tuple (perms, user, group) of expected file permissions for path
        or raises an Exception.
    """
    if RpmFilePerms.is_installed():
        mng = RpmFilePerms()
    elif DebFilePerms.is_installed():
        mng = DebFilePerms()
    else:
        raise Exception("Cannot detect package manager: no rpm or dpkg utilities found")

    return mng.get_file_perms(path)


def get_file_ac(path, options):
    """ Inspect options and returns FileStat object that should be checked/set for path.
        If some field of the object is None then corresponding parameter should be skipped.
    """
    (with_perms, with_owner, with_group, with_type) = (options.with_perms, options.with_owner, options.with_group, options.with_type)
    if not any([with_perms, with_owner, with_group, with_type]):
        (with_perms, with_owner, with_group, with_type) = (True, True, True, True)

    (perms, owner, group, typ, link) = (options.perms, options.owner, options.group, options.filetype, options.symlink_to)

    if all([perms or not with_perms, owner or not with_owner, group or not with_group, typ or not with_type]):
        if typ == FileStat.LNK and not link:
            raise ValueError("--symlink-to should be specified for --filetype=symlink")
        return FileStat(perms=perms, user=owner, group=group, typ=typ, link_to=link)

    if path in FILES_AC:
        if FILES_AC[path] is None:
            log.info("File %s does not exist on current OS.", path)
            return FileStat()
        if with_perms and not perms:
            perms = FILES_AC[path][0]
        if with_owner and not owner:
            owner = FILES_AC[path][1]
        if with_group and not group:
            group = FILES_AC[path][2]
        if with_type and not typ:
            typ = FILES_AC[path][3]
            if typ == FileStat.LNK:
                link = FILES_AC[path][4]
        return FileStat(perms=perms, user=owner, group=group, typ=typ, link_to=link)

    log.debug("try get_package_file_permissions")
    exp = get_package_file_permissions(path)

    log.debug("package permissions = %s" % exp)

    if with_perms:
        if not perms:
            perms = exp.perms
    else:
        perms = None

    if with_owner:
        if not owner:
            owner = exp.user
    else:
        owner = None

    if with_group:
        if not group:
            group = exp.group
    else:
        group = None

    if with_type:
        if not typ:
            (typ, link) = (exp.typ, exp.link_to)
    else:
        typ = None

    return FileStat(perms=perms, user=owner, group=group, typ=typ, link_to=link)


def validate_perms(path, st, perms=None):
    """ Validate permissions on path.
        If permissions are incorrent then return string description of error.
        Otherwise return None.
    """
    if not perms:
        return None
    actual = oct(st.st_mode & 07777)
    expected = perms.split(',')
    if actual not in expected:
        return "Incorrect permissions on %s." \
               " Expected: one of %s. Actual: %s." \
               % (path, perms, actual)
    return None


def validate_owner(path, st, owner=None):
    """ Validate owner of path.
        If owner is incorrent then return string description of error.
        Otherwise return None.
    """
    if not owner:
        return None
    actual = st.st_uid
    expected = get_owners(owner)
    if actual not in expected:
        return "Incorrect owner of %s." \
               " Expected: %s (%s). Actual: %s (%s)." \
               % (path, owner, ','.join(str(x) for x in expected),
                  pwd.getpwuid(actual).pw_name, str(actual))
    return None


def validate_group(path, st, group=None):
    """ Validate group of path.
        If group is incorrent then return string description of error.
        Otherwise return None.
    """
    if not group:
        return None
    actual = st.st_gid
    expected = get_groups(group)
    if actual not in expected:
        return "Incorrect group of %s." \
               " Expected: %s (%s). Actual: %s (%s)." \
               % (path, group, ','.join(str(x) for x in expected),
                  grp.getgrgid(actual).gr_name, str(actual))
    return None


def validate_type(path, st, filetype=None, link_to=None):
    if filetype is None:
        return None
    act = FileStat()
    act.set_mode(st.st_mode)
    if act.typ != filetype:
        return "Incorrect type of %s. Expected: %s. Actual: %s" % (path, filetype, act.typ)
    if filetype == FileStat.LNK:
        act_link_to = os.readlink(path)
        if act_link_to != link_to:
            return "Symlink %s points to incorrect place. Expected: %s. Actual: %s" \
                   % (path, link_to, act_link_to)
    return None


def check_ac(path, options):
    """ Check permissions, owner and group of path.
        If any of parameters are incorrect then raise FsError
    """
    exp = get_file_ac(path, options)
    if exp == FileStat():
        return

    if not os.path.exists(path) and not os.path.islink(path):
        raise FsError("The file %s was not found." % path)

    log.debug("get_file_ac returned %s" % exp)

    st = os.lstat(path)
    errors = [
        validate_perms(path, st, exp.perms),
        validate_owner(path, st, exp.user),
        validate_group(path, st, exp.group),
        validate_type(path, st, exp.typ, exp.link_to),
    ]
    if any(errors):
        raise FsError("\n".join(filter(None, errors)))


def fix_symlink(src, dst):
    if os.path.exists(dst) and not os.path.islink(dst):
        raise Exception("Cannot fix %s: file exists and it is not a symlink")
    if os.path.islink(dst):
        os.unlink(dst)
    os.symlink(src, dst)


def set_ac(path, options):
    """ Set permissions, owner and group on path. """
    exp = get_file_ac(path, options)
    if exp == FileStat():
        return

    # Fixing type should go first:
    if exp.typ:
        if exp.typ == FileStat.LNK:
            fix_symlink(exp.link_to, path)
        elif exp.typ == FileStat.DIR and not os.path.exists(path):
            os.mkdir(path, 0700)
        else:
            log.info("Fixing type for '%s' is not supported: %s" % (exp.typ, path))
        act = FileStat()
        act.set_mode(os.lstat(path).st_mode)
        if exp.typ != act.typ:
            raise Exception("Expected and actual types of file '%s' do not match: %s != %s ."
                            % (path, exp.typ, act.typ))

    if not os.path.exists(path) and not os.path.islink(path):
        raise FsError("The file %s was not found." % path)

    path_stat = os.lstat(path)

    if exp.user:
        owners = get_owners(exp.user)
        if path_stat.st_uid not in owners:
            os.lchown(path, owners[0], -1)
    if exp.group:
        groups = get_groups(exp.group)
        if path_stat.st_gid not in groups:
            os.lchown(path, -1, groups[0])
    if exp.perms:
        if not os.path.islink(path):
            perms = exp.perms.split(',')
            log.debug("exp perms %s = %s, act mode = %s" % (path, perms, oct(path_stat.st_mode & 07777)))
            if oct(path_stat.st_mode & 07777) not in perms:
                os.chmod(path, int(perms[0], 8))
        else:
            log.info("Changing permissions on symlinks is not possible: %s" % path)


def do_main(argv):
    parser = OptionParser(usage="Usage: %prog --check-ac|--set-ac <path>"
                                " [--perms <permission>,<permission>]"
                                " [--owner <owner>,<owner>]"
                                " [--group <group>,<group>]")

    parser.add_option("-v", "--verbose", action="count", help="Increase verbosity (can be specified multiple times).")

    parser.add_option("-c", "--check-ac", default=None,
                      help="checks file permissions and ownership")
    parser.add_option("-s", "--set-ac", default=None,
                      help="sets file permissions and ownership")

    parser.add_option("-P", "--with-perms", default=False, action="store_true",
                      help="check/set file permissions")
    parser.add_option("-O", "--with-owner", default=False, action="store_true",
                      help="check/set file owner")
    parser.add_option("-G", "--with-group", default=False, action="store_true",
                      help="check/set file group")
    parser.add_option("-T", "--with-type", default=False, action="store_true",
                      help="check/set file type")

    parser.add_option("-p", "--perms", default=None, help="file permissions")
    parser.add_option("-o", "--owner", default=None, help="file owner")
    parser.add_option("-g", "--group", default=None, help="file group")
    parser.add_option("-t", "--filetype", default=None, help="file type")
    parser.add_option("-l", "--symlink-to", default=None, help="symlink destination")

    (options, args) = parser.parse_args()

    log_level = log.WARNING
    if options.verbose == 1:
        log_level = log.INFO
    elif options.verbose >= 2:
        log_level = log.DEBUG
    log.basicConfig(format='%(levelname)s:%(message)s', level=log_level, stream=sys.stderr)

    if options.check_ac:
        check_ac(options.check_ac, options)
    elif options.set_ac:
        set_ac(options.set_ac, options)
    else:
        raise ValueError("Neither check-ac nor set-ac options are specified.")


def main():
    try:
        do_main(sys.argv)
    except FsError as ex:
        sys.stderr.write("%s\n" % ex)
        sys.exit(0)
    except Exception as ex:
        log.error('%s', ex)
        log.debug(ex, exc_info=True)
        sys.exit(1)

if __name__ == '__main__':
    main()

# vim: ts=4 sts=4 sw=4 et :
