From 5b2ebada7819a4614e0669d3bf762de0ce80db82 Mon Sep 17 00:00:00 2001 From: derrod Date: Tue, 19 May 2020 18:19:15 +0200 Subject: [PATCH] [cli/utils/models] Add "verify-game" command to check game install --- legendary/cli.py | 50 ++++++++++++++++++++++++++++++++++++++-- legendary/models/game.py | 5 ++++ legendary/utils/lfs.py | 29 ++++++++++------------- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/legendary/cli.py b/legendary/cli.py index 30d5a66..5f9b847 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -17,8 +17,9 @@ from sys import exit, stdout from legendary import __version__, __codename__ from legendary.core import LegendaryCore from legendary.models.exceptions import InvalidCredentialsError -from legendary.models.game import SaveGameStatus +from legendary.models.game import SaveGameStatus, VerifyResult from legendary.utils.custom_parser import AliasedSubParsersAction +from legendary.utils.lfs import validate_files # todo custom formatter for cli logger (clean info, highlighted error/warning) logging.basicConfig( @@ -585,6 +586,47 @@ class LegendaryCLI: except Exception as e: logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.') + def verify_game(self, args): + if not self.core.is_installed(args.app_name): + logger.error(f'Game "{args.app_name}" is not installed') + return + + logger.info(f'Loading installed manifest for "{args.app_name}"') + igame = self.core.get_installed_game(args.app_name) + manifest_data, _ = self.core.get_installed_manifest(args.app_name) + manifest = self.core.load_manfiest(manifest_data) + + files = sorted(manifest.file_manifest_list.elements, + key=lambda a: a.filename.lower()) + + # build list of hashes + file_list = [(f.filename, f.sha_hash.hex()) for f in files] + total = len(file_list) + num = 0 + failed = [] + missing = [] + + for result, path in validate_files(igame.install_path, file_list): + stdout.write(f'Verification progress: {num}/{total} ({num * 100 / total:.01f}%)\t\r') + stdout.flush() + num += 1 + + if result == VerifyResult.HASH_MATCH: + continue + elif result == VerifyResult.HASH_MISMATCH: + logger.error(f'File does not match hash: "{path}"') + failed.append(path) + elif result == VerifyResult.FILE_MISSING: + logger.error(f'File is missing: "{path}"') + missing.append(path) + + stdout.write(f'Verification progress: {num}/{total} ({num * 100 / total:.01f}%)\t\n') + + if not missing and not failed: + logger.info('Verification finished successfully.') + else: + logger.fatal(f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.') + def main(): parser = argparse.ArgumentParser(description=f'Legendary v{__version__} - "{__codename__}"') @@ -611,6 +653,7 @@ def main(): list_saves_parser = subparsers.add_parser('list-saves', help='List available cloud saves') download_saves_parser = subparsers.add_parser('download-saves', help='Download all cloud saves') sync_saves_parser = subparsers.add_parser('sync-saves', help='Sync cloud saves') + verify_parser = subparsers.add_parser('verify-game', help='Verify a game\'s local files') install_parser.add_argument('app_name', help='Name of the app', metavar='') uninstall_parser.add_argument('app_name', help='Name of the app', metavar='') @@ -623,6 +666,7 @@ def main(): help='Name of the app (optional)') sync_saves_parser.add_argument('app_name', nargs='?', metavar='', default='', help='Name of the app (optional)') + verify_parser.add_argument('app_name', help='Name of the app (optional)', metavar='') # importing only works on Windows right now if os.name == 'nt': @@ -742,7 +786,7 @@ def main(): if args.subparser_name not in ('auth', 'list-games', 'list-installed', 'list-files', 'launch', 'download', 'uninstall', 'install', 'update', - 'list-saves', 'download-saves', 'sync-saves'): + 'list-saves', 'download-saves', 'sync-saves', 'verify-game'): print(parser.format_help()) # Print the main help *and* the help for all of the subcommands. Thanks stackoverflow! @@ -788,6 +832,8 @@ def main(): cli.download_saves(args) elif args.subparser_name == 'sync-saves': cli.sync_saves(args) + elif args.subparser_name == 'verify-game': + cli.verify_game(args) except KeyboardInterrupt: logger.info('Command was aborted via KeyboardInterrupt, cleaning up...') diff --git a/legendary/models/game.py b/legendary/models/game.py index 43852ad..2852ee1 100644 --- a/legendary/models/game.py +++ b/legendary/models/game.py @@ -129,3 +129,8 @@ class SaveGameStatus(Enum): SAME_AGE = 2 NO_SAVE = 3 + +class VerifyResult(Enum): + HASH_MATCH = 0 + HASH_MISMATCH = 1 + FILE_MISSING = 2 diff --git a/legendary/utils/lfs.py b/legendary/utils/lfs.py index f6b6e7d..098503e 100644 --- a/legendary/utils/lfs.py +++ b/legendary/utils/lfs.py @@ -5,7 +5,9 @@ import shutil import hashlib import logging -from typing import List +from typing import List, Iterator + +from legendary.models.game import VerifyResult logger = logging.getLogger('LFS Utils') @@ -24,7 +26,7 @@ def delete_folder(path: str, recursive=True) -> bool: return True -def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1') -> list: +def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1') -> Iterator[tuple]: """ Validates the files in filelist in path against the provided hashes @@ -34,24 +36,18 @@ def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1') -> l :return: list of files that failed hash check """ - failed = list() + if not filelist: + raise ValueError('No files to validate!') if not os.path.exists(base_path): - logger.error('Path does not exist!') - failed.extend(i[0] for i in filelist) - return failed - - if not filelist: - logger.info('No files to validate') - return failed + raise OSError('Path does not exist') for file_path, file_hash in filelist: full_path = os.path.join(base_path, file_path) - logger.debug(f'Checking "{file_path}"...') + # logger.debug(f'Checking "{file_path}"...') if not os.path.exists(full_path): - logger.warning(f'File "{full_path}" does not exist!') - failed.append(file_path) + yield VerifyResult.FILE_MISSING, file_path continue with open(full_path, 'rb') as f: @@ -60,10 +56,9 @@ def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1') -> l real_file_hash.update(chunk) if file_hash != real_file_hash.hexdigest(): - logger.error(f'Hash for "{full_path}" does not match!') - failed.append(file_path) - - return failed + yield VerifyResult.HASH_MISMATCH, file_path + else: + yield VerifyResult.HASH_MATCH, file_path def clean_filename(filename):