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..022a34c --- /dev/null +++ b/modloader/modzip.py @@ -0,0 +1,78 @@ +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 _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] + 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: + for name in z.namelist(): + _zip_security_check(zip_path, name) + 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)) + _unpack_zip_path_to_path(z, mod_path, folder_path, debug=debug) + return folder_path