From f01fd833970dcea4c056bfe2551be13afbf94985 Mon Sep 17 00:00:00 2001 From: Ryan Kurtz Date: Mon, 28 Oct 2024 13:14:13 -0400 Subject: [PATCH] GP-5019: PyGhidra launches better on externally managed PEP 668 systems (like ubuntu) --- Ghidra/Features/PyGhidra/pyghidra_launcher.py | 171 ++++++++++++++---- 1 file changed, 136 insertions(+), 35 deletions(-) diff --git a/Ghidra/Features/PyGhidra/pyghidra_launcher.py b/Ghidra/Features/PyGhidra/pyghidra_launcher.py index c1934d9d25..5752b71f91 100644 --- a/Ghidra/Features/PyGhidra/pyghidra_launcher.py +++ b/Ghidra/Features/PyGhidra/pyghidra_launcher.py @@ -14,41 +14,121 @@ # limitations under the License. ## import argparse +import platform import os import sys import subprocess +import sysconfig +import venv from pathlib import Path -from typing import List -from sys import stderr +from typing import List, Dict +from sys import stderr, version -def upgrade(pip_args: List[str], dist_dir: Path, current_pyghidra_version: str) -> bool: - from packaging.version import Version # if pyghidra imported, we know we have packaging +def get_application_properties(install_dir: Path) -> Dict[str, str]: + app_properties_path: Path = install_dir / 'Ghidra' / 'application.properties' + props: Dict[str, str] = {} + with open(app_properties_path, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('#') or line.startswith('!'): + continue + key, value = line.split('=', 1) + if key: + props[key] = value + return props + +def get_user_settings_dir(install_dir: Path) -> Path: + props: Dict[str, str] = get_application_properties(install_dir) + app_name: str = props['application.name'].replace(' ', '').lower() + app_version: str = props['application.version'] + app_release_name: str = props['application.release.name'] + versioned_name: str = f'{app_name}_{app_version}_{app_release_name}' + xdg_config_home: str = os.environ.get('XDG_CONFIG_HOME') + if xdg_config_home: + return Path(xdg_config_home) / app_name / versioned_name + if platform.system() == 'Windows': + return Path(os.environ['APPDATA']) / app_name / versioned_name + if platform.system() == 'Darwin': + return Path.home() / 'Library' / app_name / versioned_name + return Path.home() / '.config' / app_name / versioned_name + +def in_venv() -> bool: + return sys.prefix != sys.base_prefix + +def is_externally_managed() -> bool: + marker: Path = Path(sysconfig.get_path("stdlib", sysconfig.get_default_scheme())) / 'EXTERNALLY-MANAGED' + return marker.is_file() + +def get_venv_exe(venv_dir: Path) -> str: + win_python_cmd: str = str(venv_dir / 'Scripts' / 'python.exe') + linux_python_cmd: str = str(venv_dir / 'bin' / 'python3') + return win_python_cmd if platform.system() == 'Windows' else linux_python_cmd + +def get_ghidra_venv(install_dir: Path) -> Path: + user_settings_dir: Path = get_user_settings_dir(install_dir) + venv_dir: Path = user_settings_dir / 'venv' + return venv_dir + +def create_ghidra_venv(venv_dir: Path) -> None: + print(f'Creating Ghidra virtual environemnt at {venv_dir}...') + venv.create(venv_dir, with_pip=True) + +def version_tuple(v): + filled = [] + for point in v.split("."): + filled.append(point.zfill(8)) + return tuple(filled) + +def get_package_version(python_cmd: str, package: str) -> str: + version = None + result = subprocess.Popen([python_cmd, '-m', 'pip', 'show', package], stdout=subprocess.PIPE, text=True) + for line in result.stdout.readlines(): + line = line.strip() + print(line) + key, value = line.split(':', 1) + if key == 'Version': + version = value.strip() + return version + +def install(install_dir: Path, python_cmd: str, pip_args: List[str], offer_venv: bool) -> bool: + install_choice: str = input('Do you wish to install PyGhidra (y/n)? ') + if install_choice.lower() in ('y', 'yes'): + if offer_venv: + ghidra_venv_choice: str = input('Install into new Ghidra virtual environment (y/n)? ') + if ghidra_venv_choice.lower() in ('y', 'yes'): + venv_dir = get_ghidra_venv(install_dir) + create_ghidra_venv(venv_dir) + python_cmd = get_venv_exe(venv_dir) + elif ghidra_venv_choice.lower() in ('n', 'no'): + system_venv_choice: str = input('Install into system environment (y/n)? ') + if not system_venv_choice.lower() in ('y', 'yes'): + print('Must answer "y" to the prior choices, or launch in an already active virtual environment.') + return None + else: + print('Please answer yes or no.') + return None + subprocess.check_call([python_cmd] + pip_args) + return python_cmd + elif not install_choice.lower() in ('n', 'no'): + print('Please answer yes or no.') + return None + +def upgrade(python_cmd: str, pip_args: List[str], dist_dir: Path, current_pyghidra_version: str) -> bool: included_pyghidra: Path = next(dist_dir.glob('pyghidra-*.whl'), None) if included_pyghidra is None: print('Warning: included pyghidra wheel was not found', file=sys.stderr) return - included_version: Version = Version(included_pyghidra.name.split('-')[1]) - current_version: Version = Version(current_pyghidra_version) - if included_version > current_version: + included_version = included_pyghidra.name.split('-')[1] + current_version = current_pyghidra_version + if version_tuple(included_version) > version_tuple(current_version): choice: str = input(f'Do you wish to upgrade PyGhidra {current_version} to {included_version} (y/n)? ') if choice.lower() in ('y', 'yes'): pip_args.append('-U') - subprocess.check_call(pip_args) + subprocess.check_call([python_cmd] + pip_args) return True else: print('Skipping upgrade') return False - -def install(pip_args: List[str], dist_dir: Path) -> bool: - choice: str = input('Do you wish to install PyGhidra (y/n)? ') - if choice.lower() in ('y', 'yes'): - subprocess.check_call(pip_args) - return True - elif choice.lower() in ('n', 'no'): - return False - else: - print('Please answer yes or no.') - return False def main() -> None: # Parse command line arguments @@ -62,34 +142,55 @@ def main() -> None: # Setup variables python_cmd: str = sys.executable install_dir: Path = Path(args.install_dir) - venv_dir: Path = install_dir / 'build' / 'venv' pyghidra_dir: Path = install_dir / 'Ghidra' / 'Features' / 'PyGhidra' - src_dir: Path = pyghidra_dir / 'src' / 'main' / 'py' dist_dir: Path = pyghidra_dir / 'pypkg' / 'dist' - + dev_venv_dir = install_dir / 'build' / 'venv' + release_venv_dir = get_ghidra_venv(install_dir) + # If headless, force console mode if args.headless: args.console = True if args.dev: # If in dev mode, launch PyGhidra from the source tree using the development virtual environment - if not venv_dir.is_dir(): + if not dev_venv_dir.is_dir(): print('Virtual environment not found!') print('Run "gradle prepdev" and try again.') - return - win_python_cmd = str(venv_dir / 'Scripts' / 'python.exe') - linux_python_cmd = str(venv_dir / 'bin' / 'python3') - python_cmd = win_python_cmd if os.name == 'nt' else linux_python_cmd + sys.exit(1) + python_cmd = get_venv_exe(dev_venv_dir) else: # If in release mode, offer to install or upgrade PyGhidra before launching from user-controlled environment - pip_args: List[str] = [python_cmd, '-m', 'pip', 'install', '--no-index', '-f', str(dist_dir), 'pyghidra'] - try: - import pyghidra - upgrade(pip_args, dist_dir, pyghidra.__version__) - except ImportError: - if not install(pip_args, dist_dir): - return - + pip_args: List[str] = ['-m', 'pip', 'install', '--no-index', '-f', str(dist_dir), 'pyghidra'] + + # Setup the proper execution environment: + # 1) If we are already in a virtual environment, use that + # 2) If the Ghidra user settings virtual environment exists, use that + # 3) If we are "externally managed", automatically create/use the Ghidra user settings virtual environment + offer_venv: bool = False + if in_venv(): + # If we are already in a virtual environment, assume that's where the user wants to be + print(f'Using active virtual environment: {sys.prefix}') + elif os.path.isdir(release_venv_dir): + # If the Ghidra user settings venv exists, use that + python_cmd = get_venv_exe(release_venv_dir) + print(f'Using Ghidra virtual environment: {release_venv_dir}') + elif is_externally_managed(): + print('Externally managed environment detected!') + create_ghidra_venv(release_venv_dir) + python_cmd = get_venv_exe(release_venv_dir) + else: + offer_venv = True + + # If PyGhidra is not installed in the execution environment, offer to install it + # If it's already installed, offer to upgrade (if applicable) + current_pyghidra_version = get_package_version(python_cmd, 'pyghidra') + if current_pyghidra_version is None: + python_cmd = install(install_dir, python_cmd, pip_args, offer_venv) + if not python_cmd: + sys.exit(1) + else: + upgrade(python_cmd, pip_args, dist_dir, current_pyghidra_version) + # Launch PyGhidra py_args: List[str] = [python_cmd, '-m', 'pyghidra.ghidra_launch', '--install-dir', str(install_dir)] if args.headless: