"""Implements `Parser` interface to create an abstraction to parse binary files. """ from __future__ import annotations import os import re import shutil import stat import subprocess from abc import ABC, abstractmethod from contextlib import suppress from pathlib import Path from tempfile import TemporaryDirectory from cx_Freeze._compat import IS_MINGW, IS_WINDOWS from cx_Freeze.exception import PlatformError # In Windows, to get dependencies, the default is to use lief package, # but LIEF can be disabled with: # set CX_FREEZE_BIND=imagehlp if IS_WINDOWS or IS_MINGW: import lief with suppress(ImportError): from .util import BindError, GetDependentFiles try: # LIEF 0.15+ lief.logging.set_level(lief.logging.LEVEL.ERROR) except AttributeError: lief.logging.set_level(lief.logging.LOGGING_LEVEL.ERROR) LIEF_DISABLED = os.environ.get("CX_FREEZE_BIND", "") == "imagehlp" PE_EXT = (".exe", ".dll", ".pyd") MAGIC_ELF = b"\x7fELF" NON_ELF_EXT = ".a:.c:.h:.py:.pyc:.pyi:.pyx:.pxd:.txt:.html:.xml".split(":") NON_ELF_EXT += ".png:.jpg:.gif:.jar:.json".split(":") class Parser(ABC): """`Parser` interface.""" def __init__( self, path: list[str], bin_path_includes: list[str], silent: int = 0 ) -> None: self._path: list[str] = path self._bin_path_includes: list[str] = bin_path_includes self._silent: int = silent self.dependent_files: dict[Path, set[Path]] = {} self.linker_warnings: dict = {} @property def search_path(self) -> list[Path]: """The default search path.""" # This cannot be cached because os.environ["PATH"] can be changed in # freeze module before the call to get_dependent_files. env_path = os.environ["PATH"].split(os.pathsep) new_path = [] for path in self._path + self._bin_path_includes + env_path: resolved_path = Path(path).resolve() if resolved_path not in new_path and resolved_path.is_dir(): new_path.append(resolved_path) return new_path def find_library( self, name: str, search_path: list[str | Path] | None = None ) -> Path | None: if search_path is None: search_path = self.search_path for directory in map(Path, search_path): library = directory / name if library.is_file(): return library.resolve() return None def get_dependent_files(self, filename: str | Path) -> set[Path]: """Return the file's dependencies using platform-specific tools.""" filename = Path(filename).resolve() with suppress(KeyError): return self.dependent_files[filename] if not self._is_binary(filename): return set() dependent_files: set[Path] = self._get_dependent_files(filename) self.dependent_files[filename] = dependent_files return dependent_files @abstractmethod def _get_dependent_files(self, filename: Path) -> set[Path]: """Return the file's dependencies using platform-specific tools (lief package or the imagehlp library on Windows, otool on Mac OS X or ldd on Linux); limit this list by the exclusion lists as needed. (Implemented separately for each platform). """ @staticmethod def _is_binary(filename: Path) -> bool: """Determines whether the file is a binary (executable, shared library) file. (Overridden in each platform). """ return filename.is_file() class PEParser(Parser): """`PEParser` is based on the `lief` package. If it is disabled, use the old friend `cx_Freeze.util` extension module. """ def __init__( self, path: list[str], bin_path_includes: list[str], silent: int = 0 ) -> None: super().__init__(path, bin_path_includes, silent) if hasattr(lief.PE, "ParserConfig"): # LIEF 0.14+ imports_only = lief.PE.ParserConfig() imports_only.parse_exports = False imports_only.parse_imports = True imports_only.parse_reloc = False imports_only.parse_rsrc = False imports_only.parse_signature = False self.imports_only = imports_only resource_only = lief.PE.ParserConfig() resource_only.parse_exports = False resource_only.parse_imports = False resource_only.parse_reloc = False resource_only.parse_rsrc = True resource_only.parse_signature = False self.resource_only = resource_only else: self.imports_only = None self.resource_only = None @staticmethod def is_pe(filename: str | Path) -> bool: """Determines whether the file is a PE file.""" if isinstance(filename, str): filename = Path(filename) return filename.suffix.lower().endswith(PE_EXT) and filename.is_file() _is_binary = is_pe def _get_dependent_files_lief(self, filename: Path) -> set[Path]: with filename.open("rb", buffering=0) as raw: binary = lief.PE.parse(raw, self.imports_only or filename.name) if not binary: return set() libraries: list[str] = [] if binary.has_imports: libraries += binary.libraries for delay_import in binary.delay_imports: libraries.append(delay_import.name) dependent_files: set[Path] = set() search_path = [filename.parent, *self.search_path] for name in libraries: library = self.find_library(name, search_path) if library: dependent_files.add(library) if name in self.linker_warnings: self.linker_warnings[name] = False elif name not in self.linker_warnings: self.linker_warnings[name] = True return dependent_files def _get_dependent_files_imagehlp(self, filename: Path) -> set[Path]: env_path = os.environ["PATH"] os.environ["PATH"] = os.pathsep.join( [os.path.normpath(path) for path in self.search_path] ) try: return {Path(dep) for dep in GetDependentFiles(filename)} except BindError as exc: # Sometimes this gets called when filename is not actually # a library (See issue 88). if self._silent < 3: print("WARNING: ignoring error during ", end="") print(f"GetDependentFiles({filename}):", exc) finally: os.environ["PATH"] = env_path return set() if LIEF_DISABLED: _get_dependent_files = _get_dependent_files_imagehlp else: _get_dependent_files = _get_dependent_files_lief def read_manifest(self, filename: str | Path) -> str: """:return: the XML schema of the manifest included in the executable :rtype: str """ if isinstance(filename, str): filename = Path(filename) with filename.open("rb", buffering=0) as raw: binary = lief.PE.parse(raw, self.resource_only or filename.name) resources_manager = binary.resources_manager return ( resources_manager.manifest if resources_manager.has_manifest else None ) def write_manifest(self, filename: str | Path, manifest: str) -> None: """:return: write the XML schema of the manifest into the executable :rtype: str """ if isinstance(filename, str): filename = Path(filename) with filename.open("rb", buffering=0) as raw: binary = lief.PE.parse(raw, self.resource_only or filename.name) resources_manager = binary.resources_manager resources_manager.manifest = manifest builder = lief.PE.Builder(binary) builder.build_resources(True) builder.build() with TemporaryDirectory(prefix="cxfreeze-") as tmp_dir: tmp_path = Path(tmp_dir, filename.name) builder.write(os.fspath(tmp_path)) shutil.move(tmp_path, filename) class ELFParser(Parser): """`ELFParser` is based on the logic around invoking `patchelf` and `ldd`. """ def __init__( self, path: list[str], bin_path_includes: list[str], silent: int = 0 ) -> None: super().__init__(path, bin_path_includes, silent) self._patchelf = shutil.which("patchelf") self._verify_patchelf() @staticmethod def is_elf(filename: str | Path) -> bool: """Check if the executable is an ELF.""" if isinstance(filename, str): filename = Path(filename) if ( filename.suffix in NON_ELF_EXT or filename.is_symlink() or not filename.is_file() ): return False with open(filename, "rb") as binary: four_bytes = binary.read(4) return bool(four_bytes == MAGIC_ELF) _is_binary = is_elf def _get_dependent_files(self, filename: Path) -> set[Path]: dependent_files: set[Path] = set() split_string = " => " dependent_file_index = 1 args = ("ldd", filename) env = os.environ.copy() env.pop("LD_PRELOAD", None) process = subprocess.run( args, check=False, capture_output=True, encoding="utf_8", env=env ) for line in process.stdout.splitlines(): parts = line.expandtabs().strip().split(split_string) if len(parts) != 2: continue partname = parts[dependent_file_index].strip() if partname == filename.name: continue if partname in ("not found", "(file not found)"): partname = Path(parts[0]) for bin_path in self._bin_path_includes: partname = Path(bin_path, partname) if partname.is_file(): dependent_files.add(partname) name = partname.name if name in self.linker_warnings: self.linker_warnings[name] = False break if not partname.is_file(): name = partname.name if name not in self.linker_warnings: self.linker_warnings[name] = True continue if partname.startswith("("): continue pos = partname.find(" (") if pos >= 0: partname = partname[:pos].strip() if partname: dependent_files.add(Path(partname)) if process.returncode and self._silent < 3: print("WARNING:", *args, "returns:") print(process.stderr, end="") return dependent_files def get_rpath(self, filename: str | Path) -> str: """Gets the rpath of the executable.""" with suppress(subprocess.CalledProcessError): return self.run_patchelf(["--print-rpath", filename]).strip() return "" def replace_needed( self, filename: str | Path, so_name: str, new_so_name: str ) -> None: """Replace DT_NEEDED entry in the dynamic table.""" self._set_write_mode(filename) self.run_patchelf(["--replace-needed", so_name, new_so_name, filename]) def set_rpath(self, filename: str | Path, rpath: str) -> None: """Sets the rpath of the executable.""" self._set_write_mode(filename) if rpath == "$ORIGIN/.": rpath = "$ORIGIN" if rpath == self.get_rpath(filename): return try: self.run_patchelf(["--set-rpath", rpath, filename]) except subprocess.CalledProcessError: self.run_patchelf(["--remove-rpath", filename]) self.run_patchelf(["--add-rpath", rpath, filename]) def set_soname(self, filename: str | Path, new_so_name: str) -> None: """Sets DT_SONAME entry in the dynamic table.""" self._set_write_mode(filename) self.run_patchelf(["--set-soname", new_so_name, filename]) def run_patchelf(self, args: list[str]) -> str: process = subprocess.run( [self._patchelf, *args], check=True, capture_output=True, text=True ) if self._silent < 1: print("patchelf", *args, "returns:", repr(process.stdout)) if process.stderr: print("patchelf errors:", repr(process.stderr)) return process.stdout @staticmethod def _set_write_mode(filename: str | Path) -> None: filename = Path(filename) mode = filename.stat().st_mode if mode & stat.S_IWUSR == 0: filename.chmod(mode | stat.S_IWUSR) def _verify_patchelf(self) -> None: """Looks for the ``patchelf`` external binary in the PATH, checks for the required version, and throws an exception if a proper version can't be found. Otherwise, silence is golden. """ if not self._patchelf: msg = "Cannot find required utility `patchelf` in PATH" raise PlatformError(msg) try: version = self.run_patchelf(["--version"]) except subprocess.CalledProcessError: msg = "Could not call `patchelf` binary" raise PlatformError(msg) from None mobj = re.match(r"patchelf\s+(\d+(.\d+)?)", version) if mobj and tuple(map(int, mobj.group(1).split("."))) >= (0, 14): return msg = f"patchelf {version} found. cx_Freeze requires patchelf >= 0.14." raise ValueError(msg)