#!/bin/python3


# Dict of file paths with their sha256 checksums
# make sure to run with `--auto-update` flag if you're running first time on this machine
# to automatically update all stored checksums to new values
files = {
    '/etc/pam.d/system-auth': '89d62406b2d623a76d53c33aca98ce8ee124ed4a450ff6c8a44cfccca78baa2f',
    '/etc/pam.d/su': '7d8962b4a2cd10cf4bc13da8949a4a6151b572d39e87b7125be55f882b16c4da',
    '/etc/pam.d/sudo': 'd1738818070684a5d2c9b26224906aad69a4fea77aabd960fc2675aee2df1fa2',
    '/etc/passwd': '28d6bec52ac5b4957a2c30dfcd15008dc1a39665c27abce97408489f3dbf02c9',
    '/etc/shadow': 'a24f72cba4cbc6b0a8433da2f4b011f31345068e3e5d6bebed6fb6a35769bd59',
    '/etc/ssh/sshd_config': '515db2484625122b4254472f7e673649e3d89b57577eaa29395017676735907b',
    '/bin/sudo': '0ffaf9e93a080ca1698837729641c283d24500d6cdd2cb4eb8e42427566a230e',
    '/bin/su': '3101438405d98e71e9eb68fbc5a33536f1ad0dad5a1c8aacd6da6c95ef082194',
    '/usr/bin/passwd': 'd4df1659159737bb4c08a430d493d257d75cdd93e18427946265ae5862a714c7',
    '/usr/bin/chsh': '6bc0ae69620dde18f7942e2573afb4a6200b10269612151f48f54ef8423a64fe',
    '/usr/bin/chfn': '63178af1347a62f58874640d38d605d3cb1bebe8092533787965ba317e8b553b',
    '/home/itsdrike/.ssh/authorized_keys': '674806197893dbf67d3c9ba3abf049d30e571de0c4b450fc9819d3e8b0f854cc',
}

# default state of VERBOSE variable, this is controlled by flag
# `--verbose`, and should probably be kept as false by default
VERBOSE = False
# default state of ENABLE_UPDATE variable, this should be kept to false
# to make sure this script can run as cronjob, not just manually by user
# this variable will get set to True if the script is ran with `--update`
ENABLE_UPDATE = False
# default state of AUTO_UPDATE variable, this has no effect if above is
# False, if not and this variable is True, user input is skipped entirely
# and the checksums are updated automatically, otherwise user confirmation
# is needed, controlled with `--no-confirm`, or `--auto-update`
AUTO_UPDATE = False
# default state of TO_ADD variable, this controls new files, checksums of
# which should be added to the `files` dictionary, this is controlled
# by flag `--add=/path/to/file`
TO_ADD = []

# -----------------------------------------------------------------------------------------------
# -                   CODE PART, DON'T EDIT UNLESS YOU KNOW WHAT YOU'RE DOING                   -
# -----------------------------------------------------------------------------------------------

import subprocess
import sys
import os
import re
import colorama

colorama.init(autoreset=True)


def _print_help(prepend_newline=False):
    if prepend_newline:
        print()
    print(
        'Accepted flags:\n'
        '  `-u`/`--update`: If invalid checksum is found, ask user if it should be updated (y/n)\n'
        '  `-a=path`/`--add=path`: Add a new file to the list of check entries\n'
        '  `--no-confirm`: Used in combination with `--update`, automatically assumes `y` for all questions\n'
        '  `--auto-update`: Combines `--update` and `--no-confirm`\n'
        '  `-e`/`--edit`: Edit this file using your $EDITOR (falls back to vi)\n'
        '  `-v`/`--verbose`: Verbose mode, show checksums on failures and some more info\n'
        '  `-h`/`--help`: Show this help'
    )


def _yes_no(text, add_yn=True):
    if add_yn:
        text += ' (y/n): '
    while True:
        user_inp = input(text).lower()
        if user_inp in ('y', 'yes'):
            return True
        elif user_inp in ('n', 'no'):
            return False


def _add_file(file_path, checksum):
    this = os.path.abspath(__file__)
    pattern = re.compile(r"files = \{\n(\s+)(['\"])(.+\n)+\}")

    if file_path in files:
        print(
            f"{colorama.Fore.RED}Path {colorama.Fore.RESET}"
            f"'{colorama.Fore.BLUE}{file_path}{colorama.Fore.RESET}' {colorama.Fore.RED}"
            "is already in the 'files' dictionary perhaps you wanted `--update`?"
        )
        return False

    with open(this, 'r') as f:
        contents = f.read()
    try:
        match = list(pattern.finditer(contents))[0]
    except IndexError:
        raise RuntimeError("Unable to detect files dict with regex, changed dict structure?")

    new_line = f"{match[1]}{match[2]}{file_path}{match[2]}: {match[2]}{checksum}{match[2]},\n"
    add_position = match.end() - 1  # before bracket symbol
    if not match[3].endswith(',\n'):
        add_position -= 1  # before newline character on non-comma line
        contents = contents[:add_position] + ',' + contents[add_position:]
        add_position += 2

    new_contents = contents[:add_position] + new_line + contents[add_position:]

    try:
        with open(this, 'w') as f:
            f.write(new_contents)
    except PermissionError:
        print(
            f'{colorama.Fore.RED}PermissionError: {colorama.Fore.RESET}'
            'To add a new rule, you must have write access to: '
            f"'{colorama.Fore.BLUE}{this}{colorama.Fore.RESET}' (forgot sudo?)"
        )
        exit(2)

    return True


def _update_file(file_path, new_checksum, stored_checksum):
    this = os.path.abspath(__file__)
    pattern = re.compile(rf"(\s+)(['\"]){file_path}['\"]:(\s+)['\"]{stored_checksum}['\"],?")
    with open(this, 'r') as f:
        contents = f.read()

    new_contents = re.sub(
        pattern,
        rf'\1\2{file_path}\2:\3\g<2>{new_checksum}\2,',
        contents
    )

    if contents == new_contents:  # Line wasn't find, perhaps it's a new file?
        return False

    try:
        with open(this, 'w') as f:
            f.write(new_contents)
    except PermissionError as e:
        print(
            f'{colorama.Fore.RED}PermissionError: {colorama.Fore.RESET}'
            'To update a rule, you must have write access to: '
            f"'{colorama.Fore.BLUE}{this}{colorama.Fore.RESET}' (forgot sudo?)"
        )
        exit(2)

    return True


def _get_checksum(file):
    proc = subprocess.run(['sha256sum', file], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    proc_stdout = proc.stdout.decode('utf-8')
    if "No such file or directory" in proc_stdout:
        print(
            f'{colorama.Fore.RED}File {colorama.Fore.RESET}'
            f"'{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}'{colorama.Fore.RED} not found, "
            "can't produce sha256 checksum, check the 'files' dictionary on the top of the "
            'program and remove this entry.'
        )
        exit(2)
    elif "Permission denied" in proc_stdout:
        raise PermissionError(f"PermissionError: Unable to read file '{file}'")
    return proc_stdout.replace(f'  {file}\n', '')


def _command_exists(command):
    proc = subprocess.run(f'which {command}', stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True)
    return proc.returncode == 0


def ask_update(file_path):
    new_checksum = _get_checksum(file_path)
    stored_checksum = files[file_path]

    file_line = f"'{colorama.Fore.BLUE}{file_path}{colorama.Fore.RESET}'"
    if AUTO_UPDATE or _yes_no('  - ' + file_line + ' update checksum?'):
        if AUTO_UPDATE:
            print('  - updating checksum for ' + file_line)  # path won't get printed by _yes_no(), so print here
        result = _update_file(file_path, new_checksum, stored_checksum)
        print(f'{colorama.Fore.GREEN}  -> Updated')
        return result

    print(f'{colorama.Fore.RED}  -> Staying mismatched')
    return False


def run_editor():
    try:
        editor = os.environ['EDITOR']
    except KeyError:
        for candidate in ('nvim', 'vim', 'vi', 'emacs', 'nano', 'ne', 'tilde'):
            if _command_exists(candidate):
                editor = candidate
                break
        else:
            print(f'{colorama.Fore.RED}Unable to find editor software, set {colorama.Fore.YELLOW}$EDITOR')
            exit(2)

    this = os.path.abspath(__file__)
    cmd = f'{editor} {this}'
    if not os.access(this, os.W_OK):
        if _command_exists('sudo'):
            cmd = 'sudo ' + cmd
            if VERBOSE:
                print(
                    f'{colorama.Fore.CYAN}'
                    f'Running editor in escalated mode ({colorama.Fore.YELLOW}sudo{colorama.Fore.CYAN})'
                )
        elif _command_exists('doas'):
            cmd = 'doas ' + cmd
            if VERBOSE:
                print(
                f'{colorama.Fore.CYAN}'
                'Running editor in escalated mode ({colorama.Fore.YELLOW}doas{colorama.Fore.CYAN})'
            )
        else:
            if VERBOSE:
                print(
                    f'{colorama.Fore.RED}'
                    'Running editor non-escalated, sudo/doas unaviable '
                    f'({colorama.Fore.YELLOW}no write perms){colorama.Fore.RED}'
                )
    if VERBOSE:
        print(f'Starting editor: {colorama.Fore.YELLOW}{cmd}')
    return subprocess.run(cmd, shell=True)


def run_check():
    not_matched = []
    for file, checksum in files.items():
        try:
            sha256_sum = _get_checksum(file)
        except PermissionError as e:
            print(
                f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': "
                f'{colorama.Fore.YELLOW}SKIPPED [PermissionError - no read perms]')
            continue

        if sha256_sum != checksum:
            not_matched.append(file)
            print(
                f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': "
                f'{colorama.Fore.RED}FAIL [Checksum Mismatch]'
            )
            if VERBOSE:
                print(f"   -> detected: {colorama.Fore.CYAN}{sha256_sum}")
                print(f"   -> stored:   {colorama.Fore.CYAN}{checksum}")
        else:
            print(f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': {colorama.Fore.GREEN}OK")
    return not_matched


def analyze_args():
    # Using globals isn't usually ideal solution,
    # but it is the easiest way to handle this
    global VERBOSE, ENABLE_UPDATE, AUTO_UPDATE, TO_ADD

    try:
        args = sys.argv[1:]
    except IndexError:
        return

    for arg in args:
        if arg in ('-u', '--update'):
            ENABLE_UPDATE = True
        elif arg == '--no-confirm':
            AUTO_UPDATE = True
        elif arg == '--auto-update':
            ENABLE_UPDATE = True
            AUTO_UPDATE = True
        elif '--add=' in arg or '-a=' in arg:
            path = arg.replace('--add=', '').replace('-a=', '')
            if os.path.exists(path):
                TO_ADD.append(path)
            else:
                print(
                    f'{colorama.Fore.RED}FileNotFoundError: {colorama.Fore.RESET}'
                    f"'{colorama.Fore.BLUE}{path}{colorama.Fore.RESET}' -> invalid path"
                )
                exit(2)
        elif arg in ('-v', '--verbose'):
            VERBOSE = True
        elif arg in ('-e', '--edit'):
            run_editor()
            exit()
        elif arg in ('-h', '--help'):
            _print_help()
            exit()
        else:
            print(f'{colorama.Fore.RED}Unrecognized flag: {colorama.Fore.YELLOW}{arg}')
            _print_help(prepend_newline=True)
            exit(2)


if __name__ == '__main__':
    analyze_args()

    # if file adding was requested, handle it
    if len(TO_ADD) > 0:
        for file in TO_ADD:
            checksum = _get_checksum(file)
            if _add_file(file, checksum):
                print(
                    f"Added '{colorama.Fore.YELLOW}{file}{colorama.Fore.RESET}': "
                    f"'{colorama.Fore.CYAN}{checksum}{colorama.Fore.RESET}'"
                )
        exit(0)  # when adding files, don't run the check too

    # run the actual checksum verifier
    not_matched = run_check()

    if len(not_matched) > 0 and ENABLE_UPDATE is True:
        print(f'\nFiles with mismatched checksums:')
        unfixed = not_matched.copy()
        for mismatched_file in not_matched:
            if ask_update(mismatched_file) is True:
                # User did not choose to update the checksum
                unfixed.remove(mismatched_file)
        if len(unfixed) > 0:
           exit(1)  # we still have some unfixed mismatched checksums, exit with 1
    elif len(not_matched) > 0:
        print(f'\nFiles with mismatched checksums:')
        for mismatched_file in not_matched:
            print(
                f'{colorama.Fore.RED}  - '
                f"{colorama.Fore.RESET}'{colorama.Fore.BLUE}{mismatched_file}{colorama.Fore.RESET}'"
            )
        exit(1)  # exit with error code in case we have changed checksums
    else:
        print(f'\n{colorama.Fore.GREEN}All checksums are correct')