#!/usr/bin/python3

import os
import pathlib
import posix1e
import pwd
import grp
import stat
import yaml
import json
import csv
import tabulate
import itertools
import sys
try:
    from samba import param
    lp = param.LoadParm()
    lp.load('/etc/samba/smb.conf')
    SAMBA_SHARES = [lp.get('path', s) for s in lp.services()
                    if s not in ['homes', 'profiles'] and pathlib.Path(lp.get('path', s)).exists()]
except Exception:
    SAMBA_SHARES = []

LOCK = pathlib.Path('/tmp/acl_analyse.lock')
TEMP_UMASK = 0o022
UMASK = os.umask(TEMP_UMASK)
os.umask(UMASK)

SUBJECTS = ['owner', 'group owner', 'other', 'mask', 'users', 'groups']

ACL_TAG_TYPE_DICT = {1: 'owner:{nid}:{perm}',
        2: 'u:{nid}:{perm}',
        4: 'group:{nid}:{perm}',
        8: 'g:{nid}:{perm}',
        16: 'mask:{perm}',
        32: 'other:{perm}'}

HEADERS = ['dossier',
           'ACL observée',
           'ACL du parent',
           'diff']

users_mapping = {}
groups_mapping = {}

def permset_to_rwx(permset):
    """Return permission set as well known r, w, x representation
    permset: Permission set
    permset type: posix1e.Permset

    return: permissions represented by r, w, x, - characters
    return type: str
    """
    permset_string = ''
    if permset.read:
        permset_string += 'r'
    else:
        permset_string += '-'
    if permset.write:
        permset_string += 'w'
    else:
        permset_string += '-'
    if permset.execute:
        permset_string += 'x'
    else:
        permset_string += '-'
    return permset_string

def format_comparison(comparison):
    """Return list of strings 
    """
    if not comparison:
        return comparison
    return [f"{c[1]}" for c in comparison]

def cached_uid(entity_id):
    """Return either name or id depending on availabilty
    entity_id: uid
    entity_id type: int

    return: id or name
    """
    if entity_id in users_mapping:
        return users_mapping[entity_id]
    try:
        path_owner = pwd.getpwuid(entity_id).pw_name
    except BaseException:
        path_owner = entity_id
    users_mapping[entity_id] = path_owner
    return path_owner

def cached_gid(entity_id):
    """Return either name or id depending on availabilty
    entity_id: gid
    entity_id type: int

    return: id or name
    """
    if entity_id in users_mapping:
        return users_mapping[entity_id]
    try:
        path_owner = grp.getgrgid(entity_id).gr_name
    except BaseException:
        path_owner = entity_id
    users_mapping[entity_id] = path_owner
    return path_owner

def format_acl(path):
    """Return formatted acl entry
    path: path of file
    path type: pathlib.Path

    return: formatted string listing permissons
    return type: str
    """
    acl = posix1e.ACL(file=path.as_posix())
    human_readable_entries = {}
    path_stat = path.stat()
    path_owner = cached_uid(path_stat.st_uid)
    path_group = cached_gid(path_stat.st_gid)
    for entry in acl:
        if entry.tag_type == posix1e.ACL_USER_OBJ:
            human_readable_entries.setdefault('owner', [])
            human_readable_entries['owner'].append(ACL_TAG_TYPE_DICT[entry.tag_type].format(nid=path_owner,
                                                                                            perm=permset_to_rwx(entry.permset)))
        elif entry.tag_type == posix1e.ACL_GROUP_OBJ:
            human_readable_entries.setdefault('group owner', [])
            human_readable_entries['group owner'].append(ACL_TAG_TYPE_DICT[entry.tag_type].format(nid=path_group,
                                                                                                  perm=permset_to_rwx(entry.permset)))
        elif entry.tag_type == posix1e.ACL_OTHER:
            human_readable_entries.setdefault('other', [])
            human_readable_entries['other'].append(ACL_TAG_TYPE_DICT[entry.tag_type].format(perm=permset_to_rwx(entry.permset)))
        elif entry.tag_type == posix1e.ACL_MASK:
            human_readable_entries.setdefault('mask', [])
            human_readable_entries['mask'].append(ACL_TAG_TYPE_DICT[entry.tag_type].format(perm=permset_to_rwx(entry.permset)))
        elif entry.tag_type == posix1e.ACL_USER:
            entry_name = cached_uid(entry.qualifier)
            human_readable_entries.setdefault('users', [])
            human_readable_entries['users'].append(ACL_TAG_TYPE_DICT[entry.tag_type].format(nid=entry_name,
                                                                                            perm=permset_to_rwx(entry.permset)))
        elif entry.tag_type == posix1e.ACL_GROUP:
            entry_name = cached_gid(entry.qualifier)
            human_readable_entries.setdefault('groups', [])
            human_readable_entries['groups'].append(ACL_TAG_TYPE_DICT[entry.tag_type].format(nid=entry_name,
                                                                                             perm=permset_to_rwx(entry.permset)))
    human_readable_list = sorted(human_readable_entries.items(), key=lambda x: (SUBJECTS.index(x[0]), x[1]))
    human_readable_digest = '\n'.join(itertools.chain(*[hre[1] for hre in human_readable_list]))
    return human_readable_digest

def diff_perm_entries(ref, subject):
    """
    Return string encoding differences between ref and subject permessions.
    0 stands for no differences, - for missing permission, + for added permission.
    Differences are evaluated for r, w, x in that order and concatenated in one string.
    ref: permissions
    ref type: int
    subject: permissions set
    subject type: posix1e.Entry
    """
    diff = ''
    for perm_code in [posix1e.ACL_READ, posix1e.ACL_WRITE, posix1e.ACL_EXECUTE]:
        if subject.permset.test(perm_code):
            if ref & perm_code == perm_code:
                diff += '0'
            else:
                diff += '+'
        else:
            if ref & perm_code == perm_code:
                diff += '-'
            else:
                diff += '0'
    return diff

def compare_acl(default, parent, effective, subject, exhaustive=False, report_root=True, umask=0o0000):
    """
    default: parent folder acl
    default type: posix1e.ACL
    effective: current folder acl
    effective type: posix1e.ACL
    exhaustive: if False, inspection stops at first difference, else lists all differences
    exhaustive type: boolean
    umask: mask used globally for filtering permissions of new folders and files
    umask type: int
    """
    comparisons = []
    other_mask = umask%0o10
    group_mask = (umask>>3)%0o10
    user_mask = (umask>>6)%0o10
    acl_mask_mode = 7
    subject_stat = subject.stat()
    parent_stat = parent.stat()
    subject_owner = subject_stat.st_uid
    subject_group = subject_stat.st_gid
    parent_owner = parent_stat.st_uid
    parent_group = parent_stat.st_gid

    user_entries = [e for e in effective if e.tag_type == posix1e.ACL_USER]
    group_entries = [e for e in effective if e.tag_type == posix1e.ACL_GROUP]
    for ref in default:
        ref_mode = 0
        if ref.permset.read:
            ref_mode = ref_mode | 4
        if ref.permset.write:
            ref_mode = ref_mode | 2
        if ref.permset.execute:
            ref_mode = ref_mode | 1
        ref_mode = ref_mode & acl_mask_mode

        if ref.tag_type == posix1e.ACL_USER_OBJ:
            filtered_mode = ref_mode & ~user_mask
            for e in effective:
                entry = e
                if e.tag_type == ref.tag_type:
                    break
            diff = diff_perm_entries(filtered_mode, entry)
            if report_root or not (parent_owner != subject_owner and parent_owner == 0):
                parent_name = cached_uid(parent_owner)
                key = ACL_TAG_TYPE_DICT[ref.tag_type].format(nid=parent_name, perm=diff)
                comparisons.append((2, key, diff, '!' if diff != '000' else '='))
            if parent_owner != subject_owner:
                subject_name = cached_uid(subject_owner)
                key = ACL_TAG_TYPE_DICT[ref.tag_type].format(nid=subject_name, perm='+++')
                comparisons.append((1, key, '+++', '!'))

        elif ref.tag_type == posix1e.ACL_USER:
            entry = None
            for e in user_entries:
                if e.qualifier == ref.qualifier:
                    entry = e
                    break
            if entry:
                filtered_mode = ref_mode & ~user_mask
                diff = diff_perm_entries(filtered_mode, entry)
                user_entries.pop(user_entries.index(entry))
            else:
                diff = '---'
            ref_name = cached_uid(ref.qualifier)
            key = ACL_TAG_TYPE_DICT[ref.tag_type].format(nid=ref_name, perm=diff)
            comparisons.append((6, key, diff))

        elif ref.tag_type == posix1e.ACL_GROUP_OBJ:
            filtered_mode = ref_mode & ~group_mask
            for e in effective:
                entry = e
                if e.tag_type == ref.tag_type:
                    break
            diff = diff_perm_entries(filtered_mode, entry)
            if report_root or not (parent_group != subject_group and parent_group == 0):
                parent_name = cached_gid(parent_group)
                key = ACL_TAG_TYPE_DICT[ref.tag_type].format(nid=parent_name, perm=diff)
                comparisons.append((4, key, diff))
            if parent_group != subject_group:
                subject_name = cached_gid(subject_group)
                key = ACL_TAG_TYPE_DICT[ref.tag_type].format(nid=subject_name, perm='+++')
                comparisons.append((3, key, '+++'))

        elif ref.tag_type == posix1e.ACL_GROUP:
            entry = None
            for e in group_entries:
                if e.qualifier == ref.qualifier:
                    entry = e
                    break
            if entry:
                filtered_mode = ref_mode & ~group_mask
                diff = diff_perm_entries(filtered_mode, entry)
                group_entries.pop(group_entries.index(entry))
            else:
                diff = '---'
            ref_name = cached_gid(ref.qualifier)
            key = ACL_TAG_TYPE_DICT[ref.tag_type].format(nid=ref_name, perm=diff)
            comparisons.append((7, key, diff))

        elif ref.tag_type == posix1e.ACL_OTHER:
            filtered_mode = ref_mode & ~other_mask
            for e in effective:
                entry = e
                if e.tag_type == ref.tag_type:
                    break
            diff = diff_perm_entries(filtered_mode, entry)
            key = ACL_TAG_TYPE_DICT[ref.tag_type].format(perm=diff)
            comparisons.append((5, key, diff))

        if diff != '000' and not exhaustive:
            break
    for user in user_entries:
        user_name = cached_uid(user.qualifier)
        key = ACL_TAG_TYPE_DICT[2].format(nid=user_name, perm='+++')
        comparisons.append((6, key, '+++'))
    for group in group_entries:
        group_name = cached_gid(group.qualifier)
        key = ACL_TAG_TYPE_DICT[8].format(nid=group_name, perm='+++')
        comparisons.append((7, key, '+++'))

    return sorted(comparisons)

def compare_entities_acl(parent, subject, detail=False, inherited_only=True, report_root=True):
    """
    Return list of tuples with permissions diverging folders and their differences as dictionnary.
    For each entity (user, group, etc.) differences are expressed as a three characters chain,
    one character for r, w, x each, with
    0 standing for no difference,
    + for permission not present in parent folder and
    - for permission not present in current folder.
    For additional entities (users and groups from extended acl),
    --- denotes an entity absent from current folder and
    +++ an entity absent from parent folder.
    
    parent: parent folder from which getting default acl and permissions
    parent type: pathlib.Path
    subject: folder to inspect
    subject type: pathlib.Path
    detail: if False, inspection stops at first difference, else lists all differences
    detail type: boolean
    inherited_only: if True, compare only default acl with applied permissions,
                    else also compare with current parent folder permissions
    inherited_only type: boolean
    """
    if subject.is_file():
        umask = UMASK
    else:
        umask = 0o0000
    parent_default_acl = posix1e.ACL(filedef=parent.as_posix())
    if list(parent_default_acl):
        comparison = compare_acl(parent_default_acl,
                                 parent,
                                 posix1e.ACL(file=subject.as_posix()),
                                 subject,
                                 exhaustive=detail,
                                 report_root=report_root,
                                 umask=umask)
    elif not inherited_only:
        parent_acl = posix1e.ACL(file=parent.as_posix())
        comparison = compare_acl(parent_acl,
                                 parent,
                                 posix1e.ACL(file=subject.as_posix()),
                                 subject,
                                 exhaustive=detail,
                                 report_root=report_root,
                                 umask=umask)
    else:
        comparison = []
    return comparison

def permissions_modification_detected(entity, detail=False):
    """
    Return True if entity's permissions do not follow parent folder restrictions
    entity: root folder where recursive inspection begins
    entity type: pathlib.Path
    detail: if False, inspection stops at first difference, else lists all differences
    detail type: boolean
    """
    perms = {'setuid': False, 'setgid': False, 'owner': False, 'group': False, 'acl': False}
    parent = entity.parent
    entity_stat = entity.stat()
    parent_stat = parent.stat()
    entity_owner = entity_stat.st_uid
    entity_group = entity_stat.st_gid
    parent_owner = parent_stat.st_uid
    parent_group = parent_stat.st_gid
    parent_setuid = parent_stat.st_mode | stat.S_ISUID == parent_stat.st_mode
    parent_setgid = parent_stat.st_mode | stat.S_ISGID == parent_stat.st_mode
    perms['owner'] = parent_owner != entity_owner
    perms['group'] = parent_group != entity_group
    if parent_setuid and parent_owner != entity_owner:
        perms['setuid'] = True
    if parent_setgid and parent_group != entity_group:
        perms['setgid'] = True
    acl_comparison = compare_entities_acl(parent, entity, detail=detail, inherited_only=False)
    if acl_comparison:
        if any(c[2] != '000' for c in acl_comparison):
            perms['acl'] = True
    return any(perms.values())

def human_readable(report):
    """Format report into ascii table for displaying in terminal
    """
    tabulated_report = []
    for report_element in report:
        acl = report[report_element]['acl']
        parent_acl = report[report_element]['parent_acl']
        diff = report[report_element]['diff']
        tabulated_report.extend(itertools.zip_longest([report_element],
                                                      acl.split('\n'),
                                                      parent_acl.split('\n'),
                                                      diff,
                                                      fillvalue=''))
        tabulated_report.append(['', '', ''])
    return tabulate.tabulate(tabulated_report, headers=HEADERS,
                             tablefmt="orgtbl")

def csv_friendly(report):
    """Format report into lists of fields for export to csv format
    """
    csv_report = [HEADERS]
    csv_report.extend([(entry, report[entry]['acl'],
                        report[entry]['parent_acl'],
                        '\n'.join(report[entry]['diff']))
                       for entry in report])
    return csv_report

def parse_tree(root, exclusion=None):
    modified_paths = []
    exclusions = [pathlib.Path(ex) for ex in exclusion] if exclusion is not None else []
    modified_paths.append(root)
    for step in os.walk(root):
        folders = [pathlib.Path(step[0]).joinpath(p) for p in step[1]]
        modified_paths.extend([folder for folder in folders
                               if not any([folder.is_relative_to(ex) for ex in exclusions]) and permissions_modification_detected(folder)])
    return modified_paths

if __name__ == '__main__':
    import argparse

    class SingleExecution:

        def __init__(self, pid):
            self.pid = pid
            self.lock_file = pathlib.Path('/tmp/acl_analyse.lock')
        
        def __enter__(self):
            if self.lock_file.exists():
                with open(self.lock_file, 'r') as lock_file:
                    pid = lock_file.read()
                try:
                    pid = int(pid)
                except:
                    pid = None
                msg = f'with pid {pid}' if pid else ''
                raise Exception(f'Aborting! Lock {LOCK.as_posix()} already in place, acl_analyse running {msg}?')
            with open(self.lock_file, 'w') as lock_file:
                lock_file.write(f"{self.pid}")
        
        def __exit__(self, exc_type, exc_val, exc_tb):
            if self.lock_file.exists():
                with open(self.lock_file, 'r') as lock_file:
                    pid = lock_file.read()
                try:
                    pid = int(pid)
                except:
                    pid = None
                if pid == self.pid:
                    os.unlink(self.lock_file)


    def existing_path(path):
        path = pathlib.Path(path)
        if path.exists():
            return path
        else:
            raise argparse.ArgumentTypeError(f'{path} does not exist')

    def exec_cmd(args):
        with SingleExecution(os.getpid()) as context:
            modified_paths = []
            if args.root:
                for root_path in args.root:
                    modified_paths.extend(parse_tree(pathlib.Path(root_path), args.exclude))
            elif SAMBA_SHARES:
                for share in SAMBA_SHARES:
                    modified_paths.extend(parse_tree(pathlib.Path(share), args.exclude))
            else:
                raise Exception('Aucun dossier à analyser : \
                                 déclarez des dossiers partagés dans la configuration du serveur \
                                 ou passez des dossiers en paramètre de l’action')

            perm_digest = {mp.as_posix(): {'acl': format_acl(mp),
                                           'parent_acl': format_acl(mp.parent),
                                           'diff': format_comparison(compare_entities_acl(mp.parent,
                                                                                          mp,
                                                                                          inherited_only=False,
                                                                                          report_root=False,
                                                                                          detail=True))}
                           for mp in sorted(modified_paths)}
            if 'json' in args.format:
                json_digest = json.dumps(perm_digest)
                with open(args.output.joinpath('modified_acl.json'), 'w') as perm_digest_fh:
                    perm_digest_fh.write(json_digest)
                del json_digest
            if 'yaml' in args.format:
                yaml_digest = yaml.dump(perm_digest,
                                        Dumper=yaml.Dumper,
                                        default_flow_style=False,
                                        indent=4)
                with open(args.output.joinpath('modified_acl.yaml'), 'w') as perm_digest_fh:
                    perm_digest_fh.write(yaml_digest)
                del yaml_digest
            if 'tabular' in args.format:
                tabular_digest = human_readable(perm_digest)
                with open(args.output.joinpath('modified_acl.tabular'), 'w') as perm_digest_fh:
                    perm_digest_fh.write(tabular_digest)
                del tabular_digest
            if 'csv' in args.format:
                csv_digest = csv_friendly(perm_digest)
                with open(args.output.joinpath('modified_acl.csv'), 'w', newline='') as perm_digest_fh:
                    csv_writer = csv.writer(perm_digest_fh)
                    csv_writer.writerows(csv_digest)
                del csv_digest

    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(help='Aide des sous-commandes')
    parser_exec = subparsers.add_parser('exec', help='Execute command')
    parser_exec.add_argument('-r', '--root',
                             help='Racine des dossiers à analyser (peut-être utilisé plusieurs fois)',
                             action='append',
                             type=existing_path,
                             required=len(SAMBA_SHARES) == 0)
    parser_exec.add_argument('-x', '--exclude', action='append',
                             help='Dossier à exclure de l’analyse (chemin absolu, peut-être utilisé plusieurs fois)')
    parser_exec.add_argument('-f', '--format',
                             help='Format de sortie de la commande',
                             action='append', required=True,
                             choices=['json', 'yaml', 'tabular', 'csv'])
    parser_exec.add_argument('-o', '--output', help='Dossier de destination',
                             type=existing_path, required=True)
    parser_exec.set_defaults(func=exec_cmd)
    args = parser.parse_args()
    if hasattr(args, 'func'):
        args.func(args)
    else:
        parser.print_usage()
