"""Dependency discovery — detect Java, Ghidra, PyGhidra with status and remediation. Precedence order: 1. Environment variables (JAVA_HOME, GHIDRA_INSTALL_DIR) 2. Common installation paths 3. PATH-based discovery """ from __future__ import annotations import dataclasses import os import shutil import subprocess import sys from pathlib import Path from typing import Any @dataclasses.dataclass class Dependency: """A discovered dependency with its status and remediation hint. Attributes: name: Component name ("java", "ghidra", "pyghidra"). status: "present", "missing", or "error". version: Detected version string, or None if not found. path: Resolved path to the component, or None. message: Human-readable diagnostic message. remediation: Human-readable instruction for fixing the issue. """ name: str status: str version: str | None = None path: str | None = None message: str = "" remediation: str = "" def to_dict(self) -> dict[str, Any]: return { "name": self.name, "status": self.status, "version": self.version, "path": self.path, "message": self.message, "remediation": self.remediation, } # --------------------------------------------------------------------------- # Java discovery # --------------------------------------------------------------------------- def _find_java() -> Dependency: """Discover Java JDK installation. Checks JAVA_HOME first, then scans common macOS/Linux paths, then falls back to PATH-based discovery. """ java_home = os.environ.get("JAVA_HOME", "") # 1. JAVA_HOME env var if java_home: java_bin = Path(java_home) / "bin" / "java" if java_bin.exists(): version = _run_version([str(java_bin), "-version"]) if version: return Dependency( name="java", status="present", version=version, path=str(java_bin), message=f"Java found at {java_bin} (version: {version})", remediation="", ) # 2. Common macOS homebrew paths if sys.platform == "darwin": candidate_dirs = [ Path("/opt/homebrew/opt/openjdk@21"), Path("/opt/homebrew/opt/openjdk@17"), Path("/opt/homebrew/opt/openjdk"), Path("/usr/local/opt/openjdk@21"), Path("/usr/local/opt/openjdk@17"), Path("/usr/local/opt/openjdk"), ] for d in candidate_dirs: java_bin = d / "bin" / "java" if java_bin.exists(): version = _run_version([str(java_bin), "-version"]) if version: return Dependency( name="java", status="present", version=version, path=str(java_bin), message=f"Java found at {java_bin} (version: {version})", remediation="", ) # 3. Common Linux paths if sys.platform == "linux": for d in [ Path("/usr/lib/jvm/java-21-openjdk"), Path("/usr/lib/jvm/java-17-openjdk"), Path("/usr/lib/jvm/default-java"), ]: java_bin = d / "bin" / "java" if java_bin.exists(): version = _run_version([str(java_bin), "-version"]) if version: return Dependency( name="java", status="present", version=version, path=str(java_bin), message=f"Java found at {java_bin} (version: {version})", remediation="", ) # 4. PATH-based fallback java_path = shutil.which("java") if java_path: version = _run_version(["java", "-version"]) if version: return Dependency( name="java", status="present", version=version, path=java_path, message=f"Java found on PATH at {java_path} (version: {version})", remediation="", ) # Java not found return Dependency( name="java", status="missing", version=None, path=None, message="Java JDK 17 or later is not installed.", remediation=( "Install Java JDK 17+ (recommended: OpenJDK 21). " "On macOS: brew install openjdk@21. " "On Linux: apt install openjdk-21-jdk or yum install java-21-openjdk-devel. " "Set JAVA_HOME to the JDK root directory." ), ) # --------------------------------------------------------------------------- # Ghidra discovery # --------------------------------------------------------------------------- def _find_ghidra() -> Dependency: """Discover Ghidra installation. Checks GHIDRA_INSTALL_DIR first, then scans common macOS/Linux paths. """ ghidra_dir = os.environ.get("GHIDRA_INSTALL_DIR", "") # 1. GHIDRA_INSTALL_DIR env var if ghidra_dir: ghidra_path = Path(ghidra_dir) if ghidra_path.exists(): version = _detect_ghidra_version(ghidra_path) if version: return Dependency( name="ghidra", status="present", version=version, path=str(ghidra_path), message=f"Ghidra found at {ghidra_path} (version: {version})", remediation="", ) # 2. Common macOS paths candidate_dirs: list[Path] = [ Path.home() / ".local" / "opt" / "ghidra", Path("/opt/ghidra"), Path("/usr/local/ghidra"), ] for base in candidate_dirs: if base.exists(): # Look for versioned subdirs like ghidra_12.1.2_PUBLIC for entry in sorted(base.iterdir(), reverse=True): if entry.is_dir() and "ghidra" in entry.name.lower(): version = _detect_ghidra_version(entry) if version: return Dependency( name="ghidra", status="present", version=version, path=str(entry), message=f"Ghidra found at {entry} (version: {version})", remediation="", ) # Ghidra not found return Dependency( name="ghidra", status="missing", version=None, path=None, message="Ghidra is not installed.", remediation=( "Download Ghidra from https://ghidra-sre.org/. " "Extract to ~/.local/opt/ghidra/ghidra__PUBLIC. " "Set GHIDRA_INSTALL_DIR to the extracted directory. " "Requires Java JDK 17+." ), ) def _detect_ghidra_version(ghidra_path: Path) -> str | None: """Try to detect Ghidra version from the directory name or application.properties.""" # Method 1: directory name pattern (ghidra_12.1.2_PUBLIC) dir_name = ghidra_path.name import re m = re.match(r"ghidra[_-](\d+\.\d+(?:\.\d+)?)", dir_name, re.IGNORECASE) if m: return m.group(1) # Method 2: look for application.properties props = ghidra_path / "Ghidra" / "application.properties" if props.exists(): try: content = props.read_text() m = re.search(r"application\.version\s*=\s*(\S+)", content) if m: return m.group(1) except Exception: pass # Method 3: support/analyzeHeadless (Ghidra's headless launcher exists) headless = ghidra_path / "support" / "analyzeHeadless" if headless.exists(): return "unknown" return None # --------------------------------------------------------------------------- # PyGhidra discovery # --------------------------------------------------------------------------- def _find_pyghidra() -> Dependency: """Discover PyGhidra Python package. Tries to import pyghidra. If it fails, checks if it can be installed via pip. """ try: import pyghidra # type: ignore[import-not-found,unused-ignore] version = getattr(pyghidra, "__version__", "unknown") pyghidra_path = getattr(pyghidra, "__file__", None) return Dependency( name="pyghidra", status="present", version=str(version), path=str(pyghidra_path), message=f"PyGhidra {version} is installed.", remediation="", ) except ImportError: pass # Check if pip is available for installation pip_cmd = _find_pip() pip_msg = "" if pip_cmd: pip_msg = f" Run: {pip_cmd} install pyghidra" return Dependency( name="pyghidra", status="missing", version=None, path=None, message="PyGhidra Python package is not installed.", remediation=f"Install PyGhidra via pip.{pip_msg}", ) def _find_pip() -> str | None: """Find a usable pip command.""" candidates = ["pip3", "pip", f"{sys.executable} -m pip"] for cmd in candidates: pip_path = shutil.which(cmd.split()[0]) if pip_path: return cmd return None # --------------------------------------------------------------------------- # Utility # --------------------------------------------------------------------------- def _run_version(cmd: list[str]) -> str | None: """Run a command and extract a version string from its combined output. For 'java -version' which prints to stderr, we capture all output. """ try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=10, ) output = (result.stdout + result.stderr).strip() if output: # Take the first non-empty line as the version info for line in output.splitlines(): line = line.strip() if line: return line return None except (FileNotFoundError, subprocess.TimeoutExpired, PermissionError): return None # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- def discover_dependencies() -> list[Dependency]: """Discover all external dependencies and return their current status. Returns: List of Dependency objects, one per component (java, ghidra, pyghidra). """ return [ _find_java(), _find_ghidra(), _find_pyghidra(), ]