From 6678421ca9d631c90b39e4259f72e1fc2e639803 Mon Sep 17 00:00:00 2001 From: 4onen Date: Sun, 21 Jul 2024 16:43:18 -0500 Subject: [PATCH 1/2] Initial mod zip unpacking implementation. --- modloader/__init__.py | 42 +++++++++++++++++++++++- modloader/modzip.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 modloader/modzip.py diff --git a/modloader/__init__.py b/modloader/__init__.py index adcc55d..815ae60 100644 --- a/modloader/__init__.py +++ b/modloader/__init__.py @@ -162,6 +162,24 @@ def resolve_dependencies(): modinfo.mod_load_order[:] = mod_load_order +def mid_init_utter_restart(): + """Restart the game entirely (in case of late installed mods)""" + class Dummy: + def kill_textures_and_surfaces(self): + pass + + def deinit(self): + pass + + def finish_pending(self): + pass + + renpy.game.interface = Dummy() + renpy.display.interface = Dummy() + renpy.display.draw = Dummy() + renpy.exports.utter_restart() + + def main(reload_mods=False): """Load the mods""" @@ -200,8 +218,30 @@ def main(reload_mods=False): modinfo.reset_mods() - modules = [] + modules = [] # List of modules already reloaded by rreload valid_files = ['.DS_Store'] + zip_files = [ + f for f in modinfo.get_mod_folders() + if f.lower().endswith('.zip') and len(f) > 4 + ] + if zip_files: + modzip_imported_before = 'modloader.modzip' in sys.modules + import modloader.modzip + unpacked = False + for zip_file in zip_files: + modinfo.get_mod_folders().remove(zip_file) + zip_path = os.path.join(get_mod_path(), zip_file) + unpacked_path = modloader.modzip.unpack(zip_path, verbose=True) + unpacked = unpacked or bool(unpacked_path) + if unpacked: + if modzip_imported_before: + raise ImportError("Game appears to have found new mods to unpack after restarting. Please quit and restart the game.\nIf the error persists, unpack all mods manually and remove the zip files from the mods directory.") + # Because we unpacked a mod, we need to make sure Ren'y has parsed + # the new files before we continue. Since that's sketchy at best, + # we just restart the game so we can act like the unpacked mods + # were there all along. + mid_init_utter_restart() + for mod in modinfo.get_mod_folders(): if mod in valid_files: modinfo.get_mod_folders().remove(mod) diff --git a/modloader/modzip.py b/modloader/modzip.py new file mode 100644 index 0000000..201da78 --- /dev/null +++ b/modloader/modzip.py @@ -0,0 +1,74 @@ +import os.path +import shutil +import zipfile + +__all__ = ('unpack',) + +def _zip_security_check(zip_path, member_name): + def fail(msg): + # Note: We _intentionally_ don't print the member_name here, + # as it could be used to abuse the error message to give the + # user malicious instructions. + # It's up to the user to complain about the error message to + # the mod author, who can fix the zip if they're not malicious. + raise EnvironmentError("Zip file {} contains {} in a member path. For security reasons this is not allowed.".format(zip_path, msg)) + if '..' in member_name: + fail('".." (directory climbing)') + if '\\' in member_name: + fail('"\\" (backslash)') + if member_name.startswith('/'): + fail('absolute paths') + if member_name.startswith('./'): + fail('relative paths') + return True + +def _unpack_zip_path_to_path(zip_file, subpath, target_path, debug=False): + if subpath and subpath[-1] != '/': + subpath += '/' + for name in zip_file.namelist(): + if name.startswith(subpath) and name != subpath: + zipinfo_obj = zip_file.getinfo(name) + old_filename = zipinfo_obj.filename + zipinfo_obj.filename = name[len(subpath):] + if debug: + print(zipinfo_obj.filename, '=>', os.path.join(target_path, zipinfo_obj.filename)) + zip_file.extract(name, target_path) + zipinfo_obj.filename = old_filename + +def unpack(zip_path, verbose=False, debug=False): + # Check for an unpacked folder at the same location as the zip but without the .zip extension + folder_path = zip_path[:-4] + if os.path.isdir(folder_path): + # Check if the zip file is newer than the folder + if os.path.getmtime(zip_path) < os.path.getmtime(folder_path): + if verbose: + print('Folder "{}" is up to date from zip "{}". Skipping...'.format(folder_path, zip_path)) + return None + else: + if verbose: + print('Folder "{}" is outdated from zip "{}". Replacing...'.format(folder_path, zip_path)) + shutil.rmtree(folder_path) + with zipfile.ZipFile(zip_path, 'r') as z: + # Now we need to make sure we're extracting the right nesting depth + # If there is an __init__.py file in the root of the zip, extract that level. + # Otherwise, check if there's a folder in the root of the zip and recurse to check for __init__.py + # Repeat until we either have to choose between multiple folders or we find an __init__.py + # Extract the complete contents of the folder with the __init__.py + # If there is no __init__.py, raise an error + for name in z.namelist(): + _zip_security_check(zip_path, name) + init_files = [f for f in z.namelist() if f.endswith('__init__.py')] + if len(init_files) == 0: + raise EnvironmentError("Zip file {} does not appear to be a packaged mod. It is missing an __init__.py file.".format(zip_path)) + # Sort the init profiles by path depth + init_files.sort(key=lambda x: x.count('/')) + # Check if the first two items have the same depth, if so error out + if len(init_files) > 1 and init_files[0].count('/') == init_files[1].count('/'): + raise EnvironmentError("Zip file {} contains multiple __init__.py files at the shallowest depth. Cannot determine which to extract.".format(zip_path)) + # Extract the folder containing the __init__.py file + init_path = init_files[0] + mod_path = init_path.rsplit('/', 1)[0] if '/' in init_path else '' + if verbose: + print('Extracting "{}" into "{}"'.format(os.path.join(zip_path,mod_path), folder_path)) + _unpack_zip_path_to_path(z, mod_path, folder_path, debug=debug) + return folder_path From 7232263f12c790a76136daebc8d56d2ee5c8dc44 Mon Sep 17 00:00:00 2001 From: 4onen Date: Sun, 21 Jul 2024 17:05:24 -0500 Subject: [PATCH 2/2] Factor out modzip init file discovery to function. --- modloader/modzip.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/modloader/modzip.py b/modloader/modzip.py index 201da78..022a34c 100644 --- a/modloader/modzip.py +++ b/modloader/modzip.py @@ -35,6 +35,23 @@ def _unpack_zip_path_to_path(zip_file, subpath, target_path, debug=False): zip_file.extract(name, target_path) zipinfo_obj.filename = old_filename +def _shallowest_init_path(zip_file, zip_path): + # If there's an __init__.py file in the root of the zip, extract there. + # Otherwise, extract the shallowest __init__.py file that doesn't have + # another __init__.py file at the same depth. + # If there are multiple __init__.py files at the same depth, error out. + # If there are no __init__.py files, error out. + init_files = [f for f in zip_file.namelist() if f.endswith('__init__.py')] + if len(init_files) == 0: + raise EnvironmentError("Zip file {} does not appear to be a packaged mod. It is missing an __init__.py file.".format(zip_path)) + # Sort the init profiles by path depth + init_files.sort(key=lambda x: x.count('/')) + # Check if the first two items have the same depth, if so error out + if len(init_files) > 1 and init_files[0].count('/') == init_files[1].count('/'): + raise EnvironmentError("Zip file {} contains multiple __init__.py files at the shallowest depth. Cannot determine which to extract.".format(zip_path)) + # Extract the folder containing the __init__.py file + return init_files[0] + def unpack(zip_path, verbose=False, debug=False): # Check for an unpacked folder at the same location as the zip but without the .zip extension folder_path = zip_path[:-4] @@ -49,24 +66,11 @@ def unpack(zip_path, verbose=False, debug=False): print('Folder "{}" is outdated from zip "{}". Replacing...'.format(folder_path, zip_path)) shutil.rmtree(folder_path) with zipfile.ZipFile(zip_path, 'r') as z: - # Now we need to make sure we're extracting the right nesting depth - # If there is an __init__.py file in the root of the zip, extract that level. - # Otherwise, check if there's a folder in the root of the zip and recurse to check for __init__.py - # Repeat until we either have to choose between multiple folders or we find an __init__.py - # Extract the complete contents of the folder with the __init__.py - # If there is no __init__.py, raise an error for name in z.namelist(): _zip_security_check(zip_path, name) - init_files = [f for f in z.namelist() if f.endswith('__init__.py')] - if len(init_files) == 0: - raise EnvironmentError("Zip file {} does not appear to be a packaged mod. It is missing an __init__.py file.".format(zip_path)) - # Sort the init profiles by path depth - init_files.sort(key=lambda x: x.count('/')) - # Check if the first two items have the same depth, if so error out - if len(init_files) > 1 and init_files[0].count('/') == init_files[1].count('/'): - raise EnvironmentError("Zip file {} contains multiple __init__.py files at the shallowest depth. Cannot determine which to extract.".format(zip_path)) - # Extract the folder containing the __init__.py file - init_path = init_files[0] + init_path = _shallowest_init_path(z, zip_path) + # We don't use the os.path module to split the path because the zip + # standard uses forward slashes as path separators. mod_path = init_path.rsplit('/', 1)[0] if '/' in init_path else '' if verbose: print('Extracting "{}" into "{}"'.format(os.path.join(zip_path,mod_path), folder_path))