import gzip import os import shutil import subprocess import tarfile import uuid import zlib from typing import List, Tuple from lutris import settings from lutris.exceptions import MissingExecutableError from lutris.util import system from lutris.util.log import logger class ExtractError(Exception): """Exception raised when and archive fails to extract""" def extract_archive(path: str, to_directory: str = ".", merge_single: bool = True, extractor=None) -> Tuple[str, str]: path = os.path.abspath(path) logger.debug("Extracting %s to %s", path, to_directory) if extractor is None: extractor = _guess_extractor(path) opener, mode = _get_archive_opener(extractor) temp_path = temp_dir = os.path.join(to_directory, ".extract-%s" % _random_id()) try: _do_extract(path, temp_path, opener, mode, extractor) except (OSError, zlib.error, tarfile.ReadError, EOFError) as ex: logger.error("Extraction failed: %s", ex) raise ExtractError(str(ex)) from ex if merge_single: extracted = os.listdir(temp_path) if len(extracted) == 1: temp_path = os.path.join(temp_path, extracted[0]) if os.path.isfile(temp_path): destination_path = os.path.join(to_directory, extracted[0]) if os.path.isfile(destination_path): logger.warning("Overwrite existing file %s", destination_path) os.remove(destination_path) if os.path.isdir(destination_path): os.rename(destination_path, destination_path + _random_id()) shutil.move(temp_path, to_directory) os.removedirs(temp_dir) else: for archive_file in os.listdir(temp_path): source_path = os.path.join(temp_path, archive_file) destination_path = os.path.join(to_directory, archive_file) # logger.debug("Moving extracted files from %s to %s", source_path, destination_path) if system.path_exists(destination_path): logger.warning("Overwrite existing path %s", destination_path) if os.path.isfile(destination_path): os.remove(destination_path) shutil.move(source_path, destination_path) elif os.path.isdir(destination_path): try: system.merge_folders(source_path, destination_path) except OSError as ex: logger.error( "Failed to merge to destination %s: %s", destination_path, ex, ) raise ExtractError(str(ex)) from ex else: shutil.move(source_path, destination_path) system.delete_folder(temp_dir) logger.debug("Finished extracting %s to %s", path, to_directory) return path, to_directory def _guess_extractor(path): """Guess what extractor should be used from a file name""" if path.endswith(".tar"): extractor = "tar" elif path.endswith((".tar.gz", ".tgz")): extractor = "tgz" elif path.endswith((".tar.xz", ".txz", ".tar.lzma")): extractor = "txz" elif path.endswith((".tar.bz2", ".tbz2", ".tbz")): extractor = "tbz2" elif path.endswith((".tar.zst", ".tzst")): extractor = "tzst" elif path.endswith(".gz"): extractor = "gzip" elif path.endswith(".exe"): extractor = "exe" elif path.endswith(".deb"): extractor = "deb" elif path.casefold().endswith(".appimage"): extractor = "AppImage" else: extractor = None return extractor def _get_archive_opener(extractor): """Return the archive opener and optional mode for an extractor""" mode = None if extractor == "tar": opener, mode = tarfile.open, "r:" elif extractor == "tgz": opener, mode = tarfile.open, "r:gz" elif extractor == "txz": opener, mode = tarfile.open, "r:xz" elif extractor in ("tbz2", "bz2"): # bz2 is used for .tar.bz2 in some installer scripts opener, mode = tarfile.open, "r:bz2" elif extractor == "tzst": opener, mode = tarfile.open, "r:zst" # Note: not supported by tarfile yet elif extractor == "gzip": opener = "gz" elif extractor == "gog": opener = "innoextract" elif extractor == "exe": opener = "exe" elif extractor == "deb": opener = "deb" elif extractor == "AppImage": opener = "AppImage" else: opener = "7zip" return opener, mode def _random_id(): """Return a random ID""" return str(uuid.uuid4())[:8] def _do_extract(archive: str, dest: str, opener, mode: str = None, extractor=None) -> None: if opener == "gz": _decompress_gz(archive, dest) elif opener == "7zip": _extract_7zip(archive, dest, archive_type=extractor) elif opener == "exe": _extract_exe(archive, dest) elif opener == "innoextract": _extract_gog(archive, dest) elif opener == "deb": _extract_deb(archive, dest) elif opener == "AppImage": _extract_AppImage(archive, dest) else: handler = opener(archive, mode) handler.extractall(dest) handler.close() def _decompress_gz(file_path: str, dest_path: str): """Decompress a gzip file.""" if dest_path: dest_filename = os.path.join(dest_path, os.path.basename(file_path[:-3])) else: dest_filename = file_path[:-3] os.makedirs(os.path.dirname(dest_filename), exist_ok=True) with open(dest_filename, "wb") as dest_file: gzipped_file = gzip.open(file_path, "rb") dest_file.write(gzipped_file.read()) gzipped_file.close() def _extract_7zip(path: str, dest: str, archive_type: str = None) -> None: _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7z") if not system.path_exists(_7zip_path): _7zip_path = system.find_required_executable("7z") command = [_7zip_path, "x", path, "-o{}".format(dest), "-aoa"] if archive_type and archive_type != "auto": command.append("-t{}".format(archive_type)) subprocess.call(command) def _extract_exe(path: str, dest: str) -> None: if _check_inno_exe(path): _decompress_gog(path, dest) else: # use 7za to check if exe is an archive _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7za") if not system.path_exists(_7zip_path): _7zip_path = system.find_required_executable("7za") command = [_7zip_path, "t", path] return_code = subprocess.call(command) if return_code == 0: _extract_7zip(path, dest) else: raise RuntimeError("specified exe is not an archive or GOG setup file") def _check_inno_exe(path) -> bool: """Check if a path in a compatible innosetup archive""" try: innoextract_path = _get_innoextract_path() except MissingExecutableError: logger.warning("Innoextract not found, can't determine type of archive %s", path) return False command = [innoextract_path, "-i", path] return_code = subprocess.call(command) return return_code == 0 def _decompress_gog(file_path: str, destination_path: str) -> None: innoextract_path = _get_innoextract_path() system.create_folder(destination_path) # innoextract cannot do mkdir -p process = subprocess.run( [innoextract_path, "-m", "-g", "-d", destination_path, "-e", file_path], capture_output=True ) if process.returncode != 0: stderr = process.stderr.decode().strip() logger.error("Innoextract failed to extract GOG setup file: %s", stderr) raise RuntimeError("innoextract failed to extract GOG setup file") def _get_innoextract_path() -> str: """Return the path where innoextract is installed""" inno_dirs = [path for path in os.listdir(settings.RUNTIME_DIR) if path.startswith("innoextract")] for inno_dir in inno_dirs: inno_path = os.path.join(settings.RUNTIME_DIR, inno_dir, "innoextract") if system.path_exists(inno_path): return inno_path inno_path = system.find_required_executable("innoextract") logger.warning("innoextract not available in the runtime folder, using some random version") return inno_path def _extract_gog(path: str, dest: str) -> None: if _check_inno_exe(path): _decompress_gog(path, dest) else: raise RuntimeError("specified exe is not a GOG setup file") def _extract_deb(archive: str, dest: str) -> None: """Extract the contents of a deb file to a destination folder""" _extract_7zip(archive, dest, archive_type="ar") debian_folder = os.path.join(dest, "debian") os.makedirs(debian_folder) control_file_exts = [".gz", ".xz", ".zst", ""] for extension in control_file_exts: control_tar_path = os.path.join(dest, "control.tar{}".format(extension)) if os.path.exists(control_tar_path): shutil.move(control_tar_path, debian_folder) break data_file_exts = [".gz", ".xz", ".zst", ".bz2", ".lzma", ""] for extension in data_file_exts: data_tar_path = os.path.join(dest, "data.tar{}".format(extension)) if os.path.exists(data_tar_path): extract_archive(data_tar_path, dest) os.remove(data_tar_path) break def _extract_AppImage(path: str, dest: str) -> None: """This is really here to prevent 7-zip from extracting the AppImage; we want to just use this sort of file as-is.""" system.create_folder(dest) shutil.copy(path, dest) def get_innoextract_list(file_path: str) -> List[str]: """Return the list of files contained in a GOG archive""" output = system.read_process_output([_get_innoextract_path(), "-lmq", file_path]) return [line[3:] for line in output.split("\n") if line]