mirror of
				https://github.com/ItsDrike/dotfiles.git
				synced 2025-11-04 01:16:35 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			292 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			292 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
#!/bin/python3
 | 
						|
import json
 | 
						|
import subprocess
 | 
						|
import sys
 | 
						|
import argparse
 | 
						|
from pathlib import Path
 | 
						|
 | 
						|
try:
 | 
						|
    import colorama
 | 
						|
except ImportError:
 | 
						|
    from unittest.mock import Mock
 | 
						|
    class NoReprMock(Mock):
 | 
						|
        __repr__ = lambda self: ""
 | 
						|
    colorama = NoReprMock()
 | 
						|
 | 
						|
colorama.init(autoreset=True)
 | 
						|
 | 
						|
 | 
						|
# default path to the JSON file that stores known file checksums
 | 
						|
# this can be overridden by using `--checksum-file=path` flag
 | 
						|
CHECKSUM_FILE = Path('/usr/local/share/tamper-check/checksums.json')
 | 
						|
 | 
						|
 | 
						|
def _yes_no(text: str, add_yn: bool = True) -> bool:
 | 
						|
    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 _get_checksum_dict(checksum_file: Path) -> dict[Path, str]:
 | 
						|
    """Read the JSON checksum file and return it as python dictionary object."""
 | 
						|
    try:
 | 
						|
        with open(checksum_file, 'r') as f:
 | 
						|
            checksums = json.load(f)
 | 
						|
    except FileNotFoundError:
 | 
						|
        print(
 | 
						|
            f'{colorama.Fore.YELLOW}Checksum file not found: {colorama.Fore.RESET}'
 | 
						|
            f"'{colorama.Fore.BLUE}{checksum_file}{colorama.Fore.RESET}'{colorama.Fore.YELLOW} "
 | 
						|
            'Creating new empty checksum file...'
 | 
						|
        )
 | 
						|
        checksum_file.parent.mkdir(parents=True, exist_ok=True)
 | 
						|
        checksums = {}
 | 
						|
        with open(checksum_file, 'w') as f:
 | 
						|
            json.dump(checksums, f, indent=4)
 | 
						|
        return checksums
 | 
						|
    except PermissionError:
 | 
						|
        print(
 | 
						|
            f'{colorama.Fore.RED}PermissionError: {colorama.Fore.RESET}'
 | 
						|
            'to run tamper-check you must have read access to checksum file: '
 | 
						|
            f"'{colorama.Fore.BLUE}{checksum_file}{colorama.Fore.RESET}' (forgot sudo?)"
 | 
						|
        )
 | 
						|
        exit(2)
 | 
						|
    except json.decoder.JSONDecodeError as e:
 | 
						|
        print(
 | 
						|
            f'{colorama.Fore.RED}Checksum file is corrupted, unable to decode JSON. '
 | 
						|
            f"{colorama.Fore.RESET}('{colorama.Fore.BLUE}{checksum_file}{colorama.Fore.RESET}').\n"
 | 
						|
            f'Error text: {e}'
 | 
						|
        )
 | 
						|
        exit(3)
 | 
						|
    else:
 | 
						|
        dct = {}
 | 
						|
        for file_str, checksum in checksums.items():
 | 
						|
            dct[Path(file_str)] = checksum
 | 
						|
        return dct
 | 
						|
 | 
						|
 | 
						|
def _get_checksum(file: Path) -> str:
 | 
						|
    """Obtain a checksum of given 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:
 | 
						|
        raise FileNotFoundError(f"'{file}' not found, can't produce sha256 checksum")
 | 
						|
    elif "Permission denied" in proc_stdout:
 | 
						|
        raise PermissionError(f"Unable to read file '{file}'")
 | 
						|
    elif "Is a directory" in proc_stdout:
 | 
						|
        raise RuntimeError(f"{file} is a directory, can't produce sha256sum")
 | 
						|
 | 
						|
    return proc_stdout.replace(f'  {file}\n', '')
 | 
						|
 | 
						|
 | 
						|
def _update_checksum(file_path: Path, checksum: str, checksum_file: Path, new_entry: bool = False) -> None:
 | 
						|
    """Update existing checksums or add new file entries in checksum_file"""
 | 
						|
    checksums = _get_checksum_dict(checksum_file)
 | 
						|
 | 
						|
    if new_entry and file_path in checksums:
 | 
						|
        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 checksum file perhaps you wanted `--update`?"
 | 
						|
        )
 | 
						|
        raise SystemExit(3)
 | 
						|
 | 
						|
    checksums[file_path] = checksum
 | 
						|
 | 
						|
    writeable_checksums = {str(file_path): file_checksum for file_path, file_checksum in checksums.items()}
 | 
						|
    try:
 | 
						|
        with open(checksum_file, 'w') as f:
 | 
						|
            json.dump(writeable_checksums, f, indent=4)
 | 
						|
    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}{checksum_file}{colorama.Fore.RESET}' (forgot sudo?)"
 | 
						|
        )
 | 
						|
        raise SystemExit(2)
 | 
						|
 | 
						|
 | 
						|
def update(file_path: Path, checksum_file: Path, text: str, no_confirm: bool = False) -> bool:
 | 
						|
    """Ask user if a file should be updated, or update automatically if no_confirm is True"""
 | 
						|
    new_checksum = _get_checksum(file_path)
 | 
						|
 | 
						|
    if no_confirm:
 | 
						|
        print(text + ' checksum auto-updating')
 | 
						|
    elif not _yes_no(text + ' update checksum?'):
 | 
						|
        print(f'{colorama.Fore.RED}  -> Staying mismatched')
 | 
						|
        return False
 | 
						|
 | 
						|
    _update_checksum(file_path, new_checksum, checksum_file)
 | 
						|
    print(f'{colorama.Fore.GREEN}  -> Updated')
 | 
						|
    return True
 | 
						|
 | 
						|
 | 
						|
def run_check(checksum_file: Path, verbose: bool) -> list[Path]:
 | 
						|
    """
 | 
						|
    Go through all files listed in checksum_file and make sure that the checksums are matching.
 | 
						|
    Return all entries which didn't match.
 | 
						|
    """
 | 
						|
    checksums = _get_checksum_dict(checksum_file)
 | 
						|
 | 
						|
    not_matched = []
 | 
						|
    for file, stored_checksum in checksums.items():
 | 
						|
        line = f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': "
 | 
						|
 | 
						|
        try:
 | 
						|
            real_sha256_sum = _get_checksum(file)
 | 
						|
        except PermissionError as exc:
 | 
						|
            print(line + f'{colorama.Fore.YELLOW}SKIPPED [PermissionError - no read perms]')
 | 
						|
            if verbose:
 | 
						|
                print(f'   -> Error text: {colorama.Fore.CYAN}{exc}')
 | 
						|
            continue
 | 
						|
        except FileNotFoundError as exc:
 | 
						|
            print(line + f'{colorama.Fore.YELLOW}FAILED [FileNotFound - fix checksum file]')
 | 
						|
            if verbose:
 | 
						|
                print(f'   -> Error text: {colorama.Fore.CYAN}{exc}')
 | 
						|
            continue
 | 
						|
        except RuntimeError as exc:
 | 
						|
            print(line + f'{colorama.Fore.YELLOW}FAILED [{exc.__class__.__name__}: {exc} - fix checksum file]')
 | 
						|
            if verbose:
 | 
						|
                print(f'   -> Error text: {colorama.Fore.CYAN}{exc}')
 | 
						|
            continue
 | 
						|
 | 
						|
        if real_sha256_sum == stored_checksum:
 | 
						|
            print(line + f'{colorama.Fore.GREEN}OK')
 | 
						|
        else:
 | 
						|
            not_matched.append(file)
 | 
						|
            print(line + f'{colorama.Fore.RED}FAIL [Checksum Mismatch]')
 | 
						|
            if verbose:
 | 
						|
                print(f'   -> detected: {colorama.Fore.CYAN}{real_sha256_sum}')
 | 
						|
                print(f'   -> stored:   {colorama.Fore.CYAN}{stored_checksum}')
 | 
						|
 | 
						|
    return not_matched
 | 
						|
 | 
						|
 | 
						|
def parse_args(*, checksum_file_default) -> dict:
 | 
						|
    parser = argparse.ArgumentParser(
 | 
						|
        description='tamper-check is a command line utility to automate checking for file edits. '
 | 
						|
        'This is achieved by storing sha256 checksums of each added file and comparing them.'
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '-v', '--verbose', action='store_true',
 | 
						|
        help='Verbose mode, show checksums on failures and some more info'
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '-u', '--update', action='store_true',
 | 
						|
        help='If invalid checksum is found, ask user if it should be updated (y/n)'
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '--no-confirm', action='store_true',
 | 
						|
        help='Used in combination with `--update`, automatically assumes `y` for all questions'
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '--checksum-file', metavar='FILE', type=Path, default=checksum_file_default,
 | 
						|
        help='JSON file storing the file checksums'
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '-a', '--add', metavar='FILE', nargs='+', action='extend', type=Path, default=[],
 | 
						|
        dest='files_to_add', help='Add a new file to the list of check entries'
 | 
						|
    )
 | 
						|
 | 
						|
    namespace = parser.parse_args()
 | 
						|
    cli_args = {k: v for k, v in vars(namespace).items()}
 | 
						|
 | 
						|
    # Handle non-existing paths
 | 
						|
    for path in cli_args['files_to_add']:
 | 
						|
        if not path.exists():
 | 
						|
            raise FileNotFoundError(path)
 | 
						|
        if not path.is_file():
 | 
						|
            raise RuntimeError("Can't add a directory")
 | 
						|
    if not cli_args['checksum_file'].exists():
 | 
						|
        raise FileNotFoundError(cli_args['checksum_file'])
 | 
						|
 | 
						|
    return cli_args
 | 
						|
 | 
						|
 | 
						|
def main() -> int:
 | 
						|
    """Run the program as intended, return the exit code"""
 | 
						|
    try:
 | 
						|
        run_parameters = parse_args(checksum_file_default=CHECKSUM_FILE)
 | 
						|
    except FileNotFoundError as exc:
 | 
						|
        path = exc.args[0]
 | 
						|
        print(
 | 
						|
            f'{colorama.Fore.RED}FileNotFoundError: {colorama.Fore.RESET}'
 | 
						|
            f"'{colorama.Fore.BLUE}{path}{colorama.Fore.RESET}' -> invalid path"
 | 
						|
        )
 | 
						|
        return 2
 | 
						|
    except RuntimeError as exc:
 | 
						|
        print(
 | 
						|
            f'{colorama.Fore.RED}{exc.__class__.__name__}: {colorama.Fore.RESET}'
 | 
						|
            f"'{colorama.Fore.BLUE}{exc}{colorama.Fore.RESET}'"
 | 
						|
        )
 | 
						|
        return 2
 | 
						|
    except SystemExit as exc:
 | 
						|
        return exc.code
 | 
						|
 | 
						|
    if len(run_parameters["files_to_add"]) > 0:
 | 
						|
        for file_to_add in run_parameters["files_to_add"]:
 | 
						|
            checksum = _get_checksum(file_to_add)
 | 
						|
            try:
 | 
						|
                _update_checksum(
 | 
						|
                    file_to_add, checksum,
 | 
						|
                    run_parameters["checksum_file"],
 | 
						|
                    new_entry=True
 | 
						|
                )
 | 
						|
            except SystemExit as e:
 | 
						|
                return e.code
 | 
						|
 | 
						|
            print(
 | 
						|
                f"Added '{colorama.Fore.BLUE}{file_to_add}{colorama.Fore.RESET}': "
 | 
						|
                f"'{colorama.Fore.CYAN}{checksum}{colorama.Fore.RESET}'"
 | 
						|
            )
 | 
						|
 | 
						|
        return 0  # don't proceed to check if we're adding files
 | 
						|
 | 
						|
    # Run the check
 | 
						|
    mismatched_files = run_check(run_parameters["checksum_file"], run_parameters["verbose"])
 | 
						|
    if len(mismatched_files) == 0:
 | 
						|
        return 0  # all files are ok
 | 
						|
 | 
						|
    print("\nFiles with mismatched checksums:")
 | 
						|
    prefix = f"{colorama.Fore.RED}  - {colorama.Fore.RESET}"
 | 
						|
    unfixed = []
 | 
						|
    for mismatched_file in mismatched_files:
 | 
						|
        line = prefix + f"'{colorama.Fore.BLUE}{mismatched_file}{colorama.Fore.RESET}'"
 | 
						|
 | 
						|
        if not run_parameters["update"]:
 | 
						|
            unfixed.append(mismatched_file)
 | 
						|
            print(line)
 | 
						|
            continue
 | 
						|
 | 
						|
        if not update(
 | 
						|
            file_path=mismatched_file,
 | 
						|
            checksum_file=run_parameters["checksum_file"],
 | 
						|
            no_confirm=run_parameters["no_confirm"],
 | 
						|
            text=line
 | 
						|
        ):
 | 
						|
            unfixed.append(mismatched_file)
 | 
						|
 | 
						|
    if len(unfixed) > 0:
 | 
						|
        return 1
 | 
						|
 | 
						|
    print(f'\n{colorama.Fore.GREEN}All checksums are correct')
 | 
						|
    return 0
 | 
						|
 | 
						|
 | 
						|
if __name__ == '__main__':
 | 
						|
    exit_code = main()
 | 
						|
    try:
 | 
						|
        exit(exit_code)  # exit gracefully, with silent exit code
 | 
						|
    except TypeError:
 | 
						|
        # Some python interpreters/extensions (such as IPython) don't like exit.
 | 
						|
        # sys.exit will raise a full exception and go to python traceback, exiting
 | 
						|
        # with code 1. The real exit code will be preserved in the traceback.
 | 
						|
        # This isn't ideal, but it's better than out of the place TypeError
 | 
						|
        # and with exit code 0, this will exit normally.
 | 
						|
        # CPython doesn't do this and most users will never experience this.
 | 
						|
        sys.exit(exit_code)
 |