diff --git a/src/ghidra/bridge.rs b/src/ghidra/bridge.rs index 4d60730..b49cb6e 100644 --- a/src/ghidra/bridge.rs +++ b/src/ghidra/bridge.rs @@ -1,477 +1,508 @@ -//! Ghidra Bridge - manages a persistent Ghidra process. -//! -//! Instead of spawning a new `analyzeHeadless` process for each command, -//! the bridge maintains a single long-running Ghidra process that serves -//! commands via a TCP socket. - -use std::io::{BufRead, BufReader, Write}; -use std::net::TcpStream; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use tracing::{debug, error, info, warn}; - -/// Default bridge port -const DEFAULT_BRIDGE_PORT: u16 = 18700; - -/// Response from the bridge -#[derive(Debug, Deserialize)] -pub struct BridgeResponse { - pub status: String, - pub data: Option, - #[serde(default)] - pub message: Option, -} - -/// Request to the bridge -#[derive(Debug, Serialize)] -struct BridgeRequest { - command: String, - #[serde(skip_serializing_if = "Option::is_none")] - args: Option, -} - -/// How to start the bridge - import a new binary or open an existing program. -pub enum BridgeStartMode { - /// Import a binary file into the project, then start bridge - Import { - binary_path: String, - }, - /// Open an existing program in the project - Process { - program_name: String, - }, -} - -/// Manages a persistent Ghidra bridge process. -pub struct GhidraBridge { - /// Child process handle - child: Option, - /// TCP connection to the bridge - stream: Option, - /// Bridge port - port: u16, - /// Project name - project_name: String, - /// Path to Ghidra installation - ghidra_install_dir: PathBuf, - /// Project directory - project_dir: PathBuf, - /// Whether the bridge is running - running: Arc, -} - -impl GhidraBridge { - /// Create a new bridge (not started yet). - pub fn new( - ghidra_install_dir: PathBuf, - project_dir: PathBuf, - project_name: String, - ) -> Self { - Self { - child: None, - stream: None, - port: DEFAULT_BRIDGE_PORT, - project_name, - ghidra_install_dir, - project_dir, - running: Arc::new(AtomicBool::new(false)), - } - } - - /// Start the bridge with the given mode. - pub fn start(&mut self, mode: BridgeStartMode) -> Result<()> { - if self.running.load(Ordering::SeqCst) { - return Ok(()); - } - - info!("Starting Ghidra bridge..."); - - // Find headless script (pyghidraRun or analyzeHeadless) - let headless_script = self.find_headless_script()?; - let is_pyghidra = headless_script - .file_name() - .map(|n| n.to_string_lossy().contains("pyghidra")) - .unwrap_or(false); - - // Get bridge script path - let bridge_script = self.get_bridge_script_path()?; - - // Build command - pyghidraRun needs different arguments - let mut cmd = Command::new(&headless_script); - - // analyzeHeadless/pyghidraRun expects: - // self.project_dir is the FULL project path (e.g., /c/Users/dev/git/ghidra-altium) - // We need to split it into parent dir and project name for Ghidra's CLI - let ghidra_project_dir = self - .project_dir - .parent() - .unwrap_or(&self.project_dir); - let ghidra_project_name = self - .project_dir - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| self.project_name.clone()); - - if is_pyghidra { - cmd.arg("--headless") - .arg(ghidra_project_dir) - .arg(&ghidra_project_name); - } else { - cmd.arg(ghidra_project_dir) - .arg(&ghidra_project_name); - } - - // Add mode-specific args - match &mode { - BridgeStartMode::Import { binary_path } => { - cmd.arg("-import").arg(binary_path); - } - BridgeStartMode::Process { program_name } => { - cmd.arg("-process") - .arg(program_name) - .arg("-noanalysis"); - } - } - - // Add bridge script args - cmd.arg("-scriptPath") - .arg(bridge_script.parent().unwrap()) - .arg("-postScript") - .arg("bridge.py") - .arg(self.port.to_string()); - - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - // Spawn the process - let mut child = cmd.spawn().context("Failed to spawn Ghidra headless")?; - - // Wait for ready signal - let stdout = child.stdout.take().expect("stdout should be piped"); - let reader = BufReader::new(stdout); - - let mut ready = false; - let mut last_error = String::new(); - for line in reader.lines() { - let line = line?; - debug!("Ghidra: {}", line); - - // Capture Ghidra errors for better error messages - if line.contains("ERROR") { - last_error = line.clone(); - } - - // Look for ready signal - if line.contains("---GHIDRA_CLI_START---") { - // Read the next line for the JSON ready message - continue; - } - if line.contains("\"status\": \"ready\"") || line.contains("\"status\":\"ready\"") { - info!("Bridge is ready on port {}", self.port); - ready = true; - break; - } - if line.contains("---GHIDRA_CLI_END---") && ready { - break; - } - } - - if !ready { - // Check if process died - let detail = if !last_error.is_empty() { - format!(": {}", last_error) - } else { - String::new() - }; - match child.try_wait() { - Ok(Some(status)) => { - anyhow::bail!("Ghidra process exited with status: {}{}", status, detail); - } - Ok(None) => { - anyhow::bail!("Ghidra bridge did not send ready signal{}", detail); - } - Err(e) => { - anyhow::bail!("Error checking process status: {}", e); - } - } - } - - // Connect to the bridge - let stream = TcpStream::connect(format!("127.0.0.1:{}", self.port)) - .context("Failed to connect to bridge")?; - stream.set_read_timeout(Some(Duration::from_secs(300))).ok(); - stream.set_write_timeout(Some(Duration::from_secs(30))).ok(); - - self.child = Some(child); - self.stream = Some(stream); - self.running.store(true, Ordering::SeqCst); - - info!("Ghidra bridge started successfully"); - Ok(()) - } - - /// Send a command to the bridge. - /// - /// On I/O errors, checks if the bridge process has died and updates - /// state accordingly. Returns a specific error if the process died. - pub fn send_command Deserialize<'de>>( - &mut self, - command: &str, - args: Option, - ) -> Result> { - if !self.running.load(Ordering::SeqCst) { - anyhow::bail!("Bridge not running"); - } - - let stream = self - .stream - .as_mut() - .ok_or_else(|| anyhow::anyhow!("No connection to bridge"))?; - - let request = BridgeRequest { - command: command.to_string(), - args, - }; - - let request_json = serde_json::to_string(&request)?; - debug!("Sending: {}", request_json); - - // Send request - check process health on I/O error - if let Err(e) = writeln!(stream, "{}", request_json) { - if !self.check_health() { - anyhow::bail!("Bridge process died unexpectedly"); - } - return Err(e.into()); - } - if let Err(e) = stream.flush() { - if !self.check_health() { - anyhow::bail!("Bridge process died unexpectedly"); - } - return Err(e.into()); - } - - // Read response - check process health on I/O error - let mut reader = BufReader::new(stream.try_clone()?); - let mut response_line = String::new(); - if let Err(e) = reader.read_line(&mut response_line) { - if !self.check_health() { - anyhow::bail!("Bridge process died unexpectedly"); - } - return Err(e.into()); - } - - debug!("Received: {}", response_line.trim()); - - let response: BridgeResponse = serde_json::from_str(&response_line)?; - Ok(response) - } - - /// Stop the bridge. - pub fn stop(&mut self) -> Result<()> { - if !self.running.load(Ordering::SeqCst) { - return Ok(()); - } - - info!("Stopping Ghidra bridge..."); - - // Send shutdown command - if let Ok(response) = self.send_command::("shutdown", None) { - debug!("Shutdown response: {:?}", response); - } - - // Close stream - self.stream.take(); - - // Wait for child to exit - if let Some(mut child) = self.child.take() { - match child.wait_timeout(Duration::from_secs(10)) { - Ok(Some(status)) => { - info!("Ghidra process exited with status: {}", status); - } - Ok(None) => { - warn!("Ghidra process did not exit, killing..."); - child.kill().ok(); - } - Err(e) => { - error!("Error waiting for process: {}", e); - child.kill().ok(); - } - } - } - - self.running.store(false, Ordering::SeqCst); - info!("Bridge stopped"); - Ok(()) - } - - /// Check if the bridge is running. - pub fn is_running(&self) -> bool { - self.running.load(Ordering::SeqCst) - } - - /// Check if the bridge process is actually healthy (still running). - /// - /// Performs an OS-level check on the child process to detect if it - /// has exited unexpectedly. If the process has died, updates the running - /// flag and returns false. - pub fn check_health(&mut self) -> bool { - if let Some(ref mut child) = self.child { - match child.try_wait() { - Ok(None) => true, // Process still running - Ok(Some(status)) => { - // Process has exited - warn!("Bridge process exited with status: {}", status); - self.running.store(false, Ordering::SeqCst); - false - } - Err(e) => { - // Error checking process - assume dead - error!("Error checking bridge process health: {}", e); - self.running.store(false, Ordering::SeqCst); - false - } - } - } else { - // No child process - self.running.store(false, Ordering::SeqCst); - false - } - } - - /// Get the embedded bridge script path, writing all scripts to disk. - fn get_bridge_script_path(&self) -> Result { - let scripts_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))? - .join("ghidra-cli") - .join("scripts"); - - std::fs::create_dir_all(&scripts_dir)?; - - // Write all embedded Python scripts - // Bridge and its module dependencies - let scripts: &[(&str, &str)] = &[ - ("bridge.py", include_str!("scripts/bridge.py")), - ("comments.py", include_str!("scripts/comments.py")), - ("symbols.py", include_str!("scripts/symbols.py")), - ("types.py", include_str!("scripts/types.py")), - ("graph.py", include_str!("scripts/graph.py")), - ("find.py", include_str!("scripts/find.py")), - ("diff.py", include_str!("scripts/diff.py")), - ("patch.py", include_str!("scripts/patch.py")), - ("disasm.py", include_str!("scripts/disasm.py")), - ("stats.py", include_str!("scripts/stats.py")), - ("program.py", include_str!("scripts/program.py")), - ("script_runner.py", include_str!("scripts/script_runner.py")), - ("batch.py", include_str!("scripts/batch.py")), - ]; - - for (name, content) in scripts { - std::fs::write(scripts_dir.join(name), content)?; - } - - Ok(scripts_dir.join("bridge.py")) - } - - /// Find the analyzeHeadless script. - fn find_headless_script(&self) -> Result { - // First try pyghidraRun for Ghidra 12+ (required for Python support) - #[cfg(unix)] - let pyghidra_name = "pyghidraRun"; - #[cfg(windows)] - let pyghidra_name = "pyghidraRun.bat"; - - let support_dir = self.ghidra_install_dir.join("support"); - let pyghidra_path = support_dir.join(pyghidra_name); - - if pyghidra_path.exists() { - return Ok(pyghidra_path); - } - - // Fall back to analyzeHeadless for older versions - #[cfg(unix)] - let script_name = "analyzeHeadless"; - #[cfg(windows)] - let script_name = "analyzeHeadless.bat"; - - let script_path = support_dir.join(script_name); - - if script_path.exists() { - Ok(script_path) - } else { - anyhow::bail!( - "Neither pyghidraRun nor analyzeHeadless found at: {}", - support_dir.display() - ) - } - } -} - -impl Drop for GhidraBridge { - fn drop(&mut self) { - if let Err(e) = self.stop() { - error!("Error stopping bridge on drop: {}", e); - } - } -} - -/// Convenience wrapper for wait_timeout on Child -trait ChildExt { - fn wait_timeout( - &mut self, - timeout: Duration, - ) -> std::io::Result>; -} - -impl ChildExt for Child { - fn wait_timeout( - &mut self, - timeout: Duration, - ) -> std::io::Result> { - use std::thread; - use std::time::Instant; - - let start = Instant::now(); - loop { - match self.try_wait()? { - Some(status) => return Ok(Some(status)), - None => { - if start.elapsed() >= timeout { - return Ok(None); - } - thread::sleep(Duration::from_millis(100)); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bridge_request_serialization() { - let req = BridgeRequest { - command: "list_functions".to_string(), - args: Some(serde_json::json!({"limit": 100})), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("list_functions")); - assert!(json.contains("100")); - } - - #[test] - fn test_bridge_response_deserialization() { - let json = r#"{"status": "success", "data": {"count": 42}}"#; - let resp: BridgeResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.status, "success"); - assert!(resp.data.is_some()); - } -} +//! Ghidra Bridge - manages a persistent Ghidra process. +//! +//! Instead of spawning a new `analyzeHeadless` process for each command, +//! the bridge maintains a single long-running Ghidra process that serves +//! commands via a TCP socket. + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error, info, warn}; + +/// Default bridge port +const DEFAULT_BRIDGE_PORT: u16 = 18700; + +/// Response from the bridge +#[derive(Debug, Deserialize)] +pub struct BridgeResponse { + pub status: String, + pub data: Option, + #[serde(default)] + pub message: Option, +} + +/// Request to the bridge +#[derive(Debug, Serialize)] +struct BridgeRequest { + command: String, + #[serde(skip_serializing_if = "Option::is_none")] + args: Option, +} + +/// How to start the bridge - import a new binary or open an existing program. +pub enum BridgeStartMode { + /// Import a binary file into the project, then start bridge + Import { + binary_path: String, + }, + /// Open an existing program in the project + Process { + program_name: String, + }, +} + +/// Manages a persistent Ghidra bridge process. +pub struct GhidraBridge { + /// Child process handle + child: Option, + /// TCP connection to the bridge + stream: Option, + /// Bridge port + port: u16, + /// Project name + project_name: String, + /// Path to Ghidra installation + ghidra_install_dir: PathBuf, + /// Project directory + project_dir: PathBuf, + /// Whether the bridge is running + running: Arc, +} + +impl GhidraBridge { + /// Create a new bridge (not started yet). + pub fn new( + ghidra_install_dir: PathBuf, + project_dir: PathBuf, + project_name: String, + ) -> Self { + Self { + child: None, + stream: None, + port: DEFAULT_BRIDGE_PORT, + project_name, + ghidra_install_dir, + project_dir, + running: Arc::new(AtomicBool::new(false)), + } + } + + /// Start the bridge with the given mode. + pub fn start(&mut self, mode: BridgeStartMode) -> Result<()> { + if self.running.load(Ordering::SeqCst) { + return Ok(()); + } + + info!("Starting Ghidra bridge..."); + + // Find headless script (pyghidraRun or analyzeHeadless) + let headless_script = self.find_headless_script()?; + let is_pyghidra = headless_script + .file_name() + .map(|n| n.to_string_lossy().contains("pyghidra")) + .unwrap_or(false); + + // Get bridge script path + let bridge_script = self.get_bridge_script_path()?; + + // Build command - pyghidraRun needs different arguments + let mut cmd = Command::new(&headless_script); + + // analyzeHeadless/pyghidraRun expects: + // self.project_dir is the FULL project path (e.g., /c/Users/dev/git/ghidra-altium) + // We need to split it into parent dir and project name for Ghidra's CLI + let ghidra_project_dir = self + .project_dir + .parent() + .unwrap_or(&self.project_dir); + let ghidra_project_name = self + .project_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.project_name.clone()); + + if is_pyghidra { + cmd.arg("--headless") + .arg(ghidra_project_dir) + .arg(&ghidra_project_name); + } else { + cmd.arg(ghidra_project_dir) + .arg(&ghidra_project_name); + } + + // Add mode-specific args + match &mode { + BridgeStartMode::Import { binary_path } => { + cmd.arg("-import").arg(binary_path); + } + BridgeStartMode::Process { program_name } => { + cmd.arg("-process") + .arg(program_name) + .arg("-noanalysis"); + } + } + + // Add bridge script args + cmd.arg("-scriptPath") + .arg(bridge_script.parent().unwrap()) + .arg("-postScript") + .arg("bridge.py") + .arg(self.port.to_string()); + + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // Log the full command for debugging + info!("Ghidra command: {:?}", cmd); + + // Spawn the process + let mut child = cmd.spawn().context("Failed to spawn Ghidra headless")?; + info!("Ghidra process started with PID: {:?}", child.id()); + + // Spawn a thread to capture stderr + let stderr = child.stderr.take().expect("stderr should be piped"); + let stderr_handle = std::thread::spawn(move || { + let reader = BufReader::new(stderr); + let mut stderr_output = Vec::new(); + for line in reader.lines() { + if let Ok(line) = line { + // Log all stderr to info level so it's always visible + info!("[Ghidra stderr] {}", line); + stderr_output.push(line); + } + } + stderr_output + }); + + // Wait for ready signal from stdout + let stdout = child.stdout.take().expect("stdout should be piped"); + let reader = BufReader::new(stdout); + + let mut ready = false; + let mut last_error = String::new(); + let mut stdout_lines = Vec::new(); + for line in reader.lines() { + let line = line?; + // Log all stdout to info level so it's always visible during startup + info!("[Ghidra stdout] {}", line); + stdout_lines.push(line.clone()); + + // Capture Ghidra errors for better error messages + if line.contains("ERROR") || line.contains("Exception") || line.contains("SEVERE") { + last_error = line.clone(); + } + + // Look for ready signal + if line.contains("---GHIDRA_CLI_START---") { + // Read the next line for the JSON ready message + continue; + } + if line.contains("\"status\": \"ready\"") || line.contains("\"status\":\"ready\"") { + info!("Bridge is ready on port {}", self.port); + ready = true; + break; + } + if line.contains("---GHIDRA_CLI_END---") && ready { + break; + } + } + + if !ready { + // Wait for stderr thread and collect output + let stderr_output = stderr_handle.join().unwrap_or_default(); + + // Check if process died + let detail = if !last_error.is_empty() { + format!(": {}", last_error) + } else if !stderr_output.is_empty() { + // Include last few stderr lines + let last_stderr: Vec<_> = stderr_output.iter().rev().take(5).rev().cloned().collect::>(); + format!(": stderr: {}", last_stderr.join("\n")) + } else { + // Include last few stdout lines for context + let last_stdout: Vec<_> = stdout_lines.iter().rev().take(10).rev().cloned().collect::>(); + format!("\nLast stdout:\n{}", last_stdout.join("\n")) + }; + match child.try_wait() { + Ok(Some(status)) => { + anyhow::bail!("Ghidra process exited with status: {}{}", status, detail); + } + Ok(None) => { + anyhow::bail!("Ghidra bridge did not send ready signal{}", detail); + } + Err(e) => { + anyhow::bail!("Error checking process status: {}", e); + } + } + } + + // Connect to the bridge + let stream = TcpStream::connect(format!("127.0.0.1:{}", self.port)) + .context("Failed to connect to bridge")?; + stream.set_read_timeout(Some(Duration::from_secs(300))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(30))).ok(); + + self.child = Some(child); + self.stream = Some(stream); + self.running.store(true, Ordering::SeqCst); + + info!("Ghidra bridge started successfully"); + Ok(()) + } + + /// Send a command to the bridge. + /// + /// On I/O errors, checks if the bridge process has died and updates + /// state accordingly. Returns a specific error if the process died. + pub fn send_command Deserialize<'de>>( + &mut self, + command: &str, + args: Option, + ) -> Result> { + if !self.running.load(Ordering::SeqCst) { + anyhow::bail!("Bridge not running"); + } + + let stream = self + .stream + .as_mut() + .ok_or_else(|| anyhow::anyhow!("No connection to bridge"))?; + + let request = BridgeRequest { + command: command.to_string(), + args, + }; + + let request_json = serde_json::to_string(&request)?; + debug!("Sending: {}", request_json); + + // Send request - check process health on I/O error + if let Err(e) = writeln!(stream, "{}", request_json) { + if !self.check_health() { + anyhow::bail!("Bridge process died unexpectedly"); + } + return Err(e.into()); + } + if let Err(e) = stream.flush() { + if !self.check_health() { + anyhow::bail!("Bridge process died unexpectedly"); + } + return Err(e.into()); + } + + // Read response - check process health on I/O error + let mut reader = BufReader::new(stream.try_clone()?); + let mut response_line = String::new(); + if let Err(e) = reader.read_line(&mut response_line) { + if !self.check_health() { + anyhow::bail!("Bridge process died unexpectedly"); + } + return Err(e.into()); + } + + debug!("Received: {}", response_line.trim()); + + let response: BridgeResponse = serde_json::from_str(&response_line)?; + Ok(response) + } + + /// Stop the bridge. + pub fn stop(&mut self) -> Result<()> { + if !self.running.load(Ordering::SeqCst) { + return Ok(()); + } + + info!("Stopping Ghidra bridge..."); + + // Send shutdown command + if let Ok(response) = self.send_command::("shutdown", None) { + debug!("Shutdown response: {:?}", response); + } + + // Close stream + self.stream.take(); + + // Wait for child to exit + if let Some(mut child) = self.child.take() { + match child.wait_timeout(Duration::from_secs(10)) { + Ok(Some(status)) => { + info!("Ghidra process exited with status: {}", status); + } + Ok(None) => { + warn!("Ghidra process did not exit, killing..."); + child.kill().ok(); + } + Err(e) => { + error!("Error waiting for process: {}", e); + child.kill().ok(); + } + } + } + + self.running.store(false, Ordering::SeqCst); + info!("Bridge stopped"); + Ok(()) + } + + /// Check if the bridge is running. + pub fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + /// Check if the bridge process is actually healthy (still running). + /// + /// Performs an OS-level check on the child process to detect if it + /// has exited unexpectedly. If the process has died, updates the running + /// flag and returns false. + pub fn check_health(&mut self) -> bool { + if let Some(ref mut child) = self.child { + match child.try_wait() { + Ok(None) => true, // Process still running + Ok(Some(status)) => { + // Process has exited + warn!("Bridge process exited with status: {}", status); + self.running.store(false, Ordering::SeqCst); + false + } + Err(e) => { + // Error checking process - assume dead + error!("Error checking bridge process health: {}", e); + self.running.store(false, Ordering::SeqCst); + false + } + } + } else { + // No child process + self.running.store(false, Ordering::SeqCst); + false + } + } + + /// Get the embedded bridge script path, writing all scripts to disk. + fn get_bridge_script_path(&self) -> Result { + let scripts_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))? + .join("ghidra-cli") + .join("scripts"); + + std::fs::create_dir_all(&scripts_dir)?; + + // Write all embedded Python scripts + // Bridge and its module dependencies + let scripts: &[(&str, &str)] = &[ + ("bridge.py", include_str!("scripts/bridge.py")), + ("comments.py", include_str!("scripts/comments.py")), + ("symbols.py", include_str!("scripts/symbols.py")), + ("types.py", include_str!("scripts/types.py")), + ("graph.py", include_str!("scripts/graph.py")), + ("find.py", include_str!("scripts/find.py")), + ("diff.py", include_str!("scripts/diff.py")), + ("patch.py", include_str!("scripts/patch.py")), + ("disasm.py", include_str!("scripts/disasm.py")), + ("stats.py", include_str!("scripts/stats.py")), + ("program.py", include_str!("scripts/program.py")), + ("script_runner.py", include_str!("scripts/script_runner.py")), + ("batch.py", include_str!("scripts/batch.py")), + ]; + + for (name, content) in scripts { + std::fs::write(scripts_dir.join(name), content)?; + } + + Ok(scripts_dir.join("bridge.py")) + } + + /// Find the analyzeHeadless script. + fn find_headless_script(&self) -> Result { + // First try pyghidraRun for Ghidra 12+ (required for Python support) + #[cfg(unix)] + let pyghidra_name = "pyghidraRun"; + #[cfg(windows)] + let pyghidra_name = "pyghidraRun.bat"; + + let support_dir = self.ghidra_install_dir.join("support"); + let pyghidra_path = support_dir.join(pyghidra_name); + + if pyghidra_path.exists() { + return Ok(pyghidra_path); + } + + // Fall back to analyzeHeadless for older versions + #[cfg(unix)] + let script_name = "analyzeHeadless"; + #[cfg(windows)] + let script_name = "analyzeHeadless.bat"; + + let script_path = support_dir.join(script_name); + + if script_path.exists() { + Ok(script_path) + } else { + anyhow::bail!( + "Neither pyghidraRun nor analyzeHeadless found at: {}", + support_dir.display() + ) + } + } +} + +impl Drop for GhidraBridge { + fn drop(&mut self) { + if let Err(e) = self.stop() { + error!("Error stopping bridge on drop: {}", e); + } + } +} + +/// Convenience wrapper for wait_timeout on Child +trait ChildExt { + fn wait_timeout( + &mut self, + timeout: Duration, + ) -> std::io::Result>; +} + +impl ChildExt for Child { + fn wait_timeout( + &mut self, + timeout: Duration, + ) -> std::io::Result> { + use std::thread; + use std::time::Instant; + + let start = Instant::now(); + loop { + match self.try_wait()? { + Some(status) => return Ok(Some(status)), + None => { + if start.elapsed() >= timeout { + return Ok(None); + } + thread::sleep(Duration::from_millis(100)); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bridge_request_serialization() { + let req = BridgeRequest { + command: "list_functions".to_string(), + args: Some(serde_json::json!({"limit": 100})), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("list_functions")); + assert!(json.contains("100")); + } + + #[test] + fn test_bridge_response_deserialization() { + let json = r#"{"status": "success", "data": {"count": 42}}"#; + let resp: BridgeResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, "success"); + assert!(resp.data.is_some()); + } +} diff --git a/src/main.rs b/src/main.rs index 0936c60..4d9f0eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1090 +1,1092 @@ -mod cli; -mod config; -mod daemon; -mod error; -mod filter; -mod format; -mod ghidra; -mod ipc; -mod query; - -use clap::Parser; -use cli::{Cli, Commands, DaemonCommands, SetupArgs}; -use config::Config; -use daemon::process::{ensure_not_running, get_data_dir, get_running_daemon_info}; -use daemon::{run as run_daemon, DaemonConfig}; -use error::{GhidraError, Result}; -use format::{auto_detect_format, DefaultFormatter, Formatter, OutputFormat}; -use ghidra::GhidraClient; -use std::path::{Path, PathBuf}; -use tracing::info; - -#[tokio::main] -async fn main() { - // Initialize logging - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let cli = Cli::parse(); - - let result = match &cli.command { - Commands::Daemon(_) | Commands::Setup(_) => { - // Daemon and Setup commands are async - run_async(cli).await - } - _ => { - // Other commands can be sync or we check if daemon is running - run_with_daemon_check(cli).await - } - }; - - if let Err(e) = result { - eprintln!("Error: {}", e); - std::process::exit(1); - } -} - -fn run(cli: Cli) -> anyhow::Result<()> { - match cli.command { - // Non-daemon commands - Commands::Init => handle_init(), - Commands::Doctor => handle_doctor(), - Commands::Version => handle_version(), - Commands::Config(cmd) => handle_config_command(cmd), - Commands::SetDefault(args) => handle_set_default(args), - Commands::Project(args) => handle_project_command(args.command), - // Commands requiring daemon are handled by run_with_daemon_check - Commands::Import(_) - | Commands::Analyze(_) - | Commands::Quick(_) - | Commands::Query(_) - | Commands::Summary(_) - | Commands::Function(_) - | Commands::Strings(_) - | Commands::Memory(_) - | Commands::Dump(_) - | Commands::Decompile(_) - | Commands::XRef(_) => { - unreachable!("Daemon-required commands should go through run_with_daemon_check") - } - _ => { - println!("Command not yet implemented"); - Ok(()) - } - } -} - -/// Run async commands (daemon management, setup). -async fn run_async(cli: Cli) -> anyhow::Result<()> { - match cli.command { - Commands::Daemon(cmd) => handle_daemon_command(cmd).await, - Commands::Setup(args) => handle_setup(args).await, - _ => unreachable!("run_async called with non-async command"), - } -} - -/// Determines if a command requires the daemon to be running. -fn requires_daemon(command: &Commands) -> bool { - matches!( - command, - Commands::Import(_) - | Commands::Analyze(_) - | Commands::Quick(_) - | Commands::Query(_) - | Commands::Decompile(_) - | Commands::Function(_) - | Commands::Strings(_) - | Commands::Memory(_) - | Commands::Dump(_) - | Commands::Summary(_) - | Commands::XRef(_) - | Commands::Symbol(_) - | Commands::Type(_) - | Commands::Comment(_) - | Commands::Graph(_) - | Commands::Find(_) - | Commands::Diff(_) - | Commands::Patch(_) - | Commands::Script(_) - | Commands::Disasm(_) - | Commands::Batch(_) - | Commands::Stats(_) - | Commands::Program(_) - ) -} - -/// Extract the project name from a command's args (if present). -fn extract_project_from_command(command: &Commands) -> Option { - match command { - Commands::Import(args) => args.project.clone(), - Commands::Analyze(args) => args.project.clone(), - Commands::Quick(args) => args.project.clone(), - Commands::Query(args) => args.project.clone(), - Commands::Summary(args) => args.options.project.clone(), - Commands::Decompile(args) => args.options.project.clone(), - Commands::Function(cmd) => match cmd { - cli::FunctionCommands::List(opts) => opts.project.clone(), - cli::FunctionCommands::Decompile(args) => args.options.project.clone(), - cli::FunctionCommands::Get(args) => args.options.project.clone(), - cli::FunctionCommands::Disasm(args) => args.options.project.clone(), - cli::FunctionCommands::Calls(args) => args.options.project.clone(), - cli::FunctionCommands::XRefs(args) => args.options.project.clone(), - cli::FunctionCommands::Rename(args) => args.project.clone(), - cli::FunctionCommands::Create(args) => args.project.clone(), - cli::FunctionCommands::Delete(args) => args.options.project.clone(), - }, - Commands::Strings(cmd) => match cmd { - cli::StringsCommands::List(opts) => opts.project.clone(), - cli::StringsCommands::Refs(args) => args.options.project.clone(), - }, - Commands::Memory(cmd) => match cmd { - cli::MemoryCommands::Map(opts) => opts.project.clone(), - cli::MemoryCommands::Read(args) => args.options.project.clone(), - cli::MemoryCommands::Write(args) => args.project.clone(), - cli::MemoryCommands::Search(args) => args.options.project.clone(), - }, - Commands::Dump(cmd) => match cmd { - cli::DumpCommands::Imports(opts) => opts.project.clone(), - cli::DumpCommands::Exports(opts) => opts.project.clone(), - cli::DumpCommands::Functions(opts) => opts.project.clone(), - cli::DumpCommands::Strings(opts) => opts.project.clone(), - }, - Commands::XRef(cmd) => match cmd { - cli::XRefCommands::To(args) => args.options.project.clone(), - cli::XRefCommands::From(args) => args.options.project.clone(), - cli::XRefCommands::List(args) => args.options.project.clone(), - }, - Commands::Stats(args) => args.options.project.clone(), - Commands::Disasm(args) => args.options.project.clone(), - Commands::Find(cmd) => match cmd { - cli::FindCommands::String(args) => args.options.project.clone(), - cli::FindCommands::Bytes(args) => args.options.project.clone(), - cli::FindCommands::Function(args) => args.options.project.clone(), - cli::FindCommands::Calls(args) => args.options.project.clone(), - cli::FindCommands::Crypto(opts) => opts.project.clone(), - cli::FindCommands::Interesting(opts) => opts.project.clone(), - }, - Commands::Graph(cmd) => match cmd { - cli::GraphCommands::Calls(opts) => opts.project.clone(), - cli::GraphCommands::Callers(args) => args.options.project.clone(), - cli::GraphCommands::Callees(args) => args.options.project.clone(), - cli::GraphCommands::Export(args) => args.options.project.clone(), - }, - Commands::Comment(cmd) => match cmd { - cli::CommentCommands::List(opts) => opts.project.clone(), - cli::CommentCommands::Get(args) => args.options.project.clone(), - cli::CommentCommands::Set(args) => args.project.clone(), - cli::CommentCommands::Delete(args) => args.options.project.clone(), - }, - Commands::Symbol(cmd) => match cmd { - cli::SymbolCommands::List(opts) => opts.project.clone(), - cli::SymbolCommands::Get(args) => args.options.project.clone(), - cli::SymbolCommands::Create(args) => args.project.clone(), - cli::SymbolCommands::Delete(args) => args.options.project.clone(), - cli::SymbolCommands::Rename(args) => args.project.clone(), - }, - Commands::Type(cmd) => match cmd { - cli::TypeCommands::List(opts) => opts.project.clone(), - cli::TypeCommands::Get(args) => args.options.project.clone(), - cli::TypeCommands::Create(args) => args.project.clone(), - cli::TypeCommands::Apply(args) => args.project.clone(), - }, - Commands::Patch(cmd) => match cmd { - cli::PatchCommands::Bytes(args) => args.project.clone(), - cli::PatchCommands::Nop(args) => args.project.clone(), - cli::PatchCommands::Export(args) => args.project.clone(), - }, - Commands::Script(cmd) => match cmd { - cli::ScriptCommands::Run(args) => args.project.clone(), - cli::ScriptCommands::Python(args) => args.project.clone(), - cli::ScriptCommands::Java(args) => args.project.clone(), - cli::ScriptCommands::List => None, - }, - Commands::Program(cmd) => match cmd { - cli::ProgramCommands::List(args) => args.project.clone(), - cli::ProgramCommands::Open(args) => args.project.clone(), - cli::ProgramCommands::Close(args) => args.project.clone(), - cli::ProgramCommands::Delete(args) => args.project.clone(), - cli::ProgramCommands::Info(args) => args.project.clone(), - cli::ProgramCommands::Export(args) => args.project.clone(), - }, - _ => None, - } -} - -/// Run commands with daemon check - route through daemon if required. -async fn run_with_daemon_check(cli: Cli) -> anyhow::Result<()> { - // Commands that don't require daemon can run directly - if !requires_daemon(&cli.command) { - return run(cli); - } - - let config = Config::load()?; - - // Extract project from command args, fall back to config default - let project_from_cmd = extract_project_from_command(&cli.command); - let project_path = resolve_project_path(&project_from_cmd, &config)?; - - ensure_daemon_running(&project_path).await?; - - let mut client = ipc::client::DaemonClient::connect(&project_path).await?; - info!("Connected to daemon via IPC"); - let output = - execute_via_daemon(&mut client, &cli.command, cli.json, cli.pretty).await?; - if !output.is_empty() { - println!("{}", output); - } - Ok(()) -} - -/// Ensure daemon is running for the given project path. -/// Starts the daemon if not running, and waits until it's accepting connections. -async fn ensure_daemon_running(project_path: &Path) -> anyhow::Result<()> { - let data_dir = get_data_dir()?; - - // Check if daemon is already running - if get_running_daemon_info(&data_dir, project_path)?.is_some() { - // Verify it's actually responding - if let Ok(mut client) = ipc::client::DaemonClient::connect(project_path).await { - if client.ping().await.is_ok() { - return Ok(()); - } - } - // Lock file exists but daemon not responding - clean up and restart - } - - let config = Config::load()?; - let log_file = data_dir.join("daemon.log"); - - let daemon_config = DaemonConfig { - project_path: project_path.to_path_buf(), - ghidra_install_dir: config.ghidra_install_dir.clone().or_else(|| config.get_ghidra_install_dir().ok()), - log_file, - }; - - eprintln!("Starting daemon..."); - - #[cfg(unix)] - { - daemonize_unix(daemon_config, None)?; - } - - #[cfg(windows)] - { - daemonize_windows(daemon_config, None)?; - } - - // Wait for daemon to be ready by polling for connection - let max_attempts = 30; // 30 * 200ms = 6 seconds max - for attempt in 0..max_attempts { - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - - if let Ok(mut client) = ipc::client::DaemonClient::connect(project_path).await { - if client.ping().await.is_ok() { - eprintln!("Daemon ready."); - return Ok(()); - } - } - - if attempt > 0 && attempt % 10 == 0 { - eprintln!("Still waiting for daemon to start..."); - } - } - - anyhow::bail!("Daemon failed to start within timeout. Check logs at: {}", data_dir.join("daemon.log").display()) -} - -/// Execute a command via the daemon IPC connection. -async fn execute_via_daemon( - client: &mut ipc::client::DaemonClient, - command: &Commands, - json_flag: bool, - pretty_flag: bool, -) -> anyhow::Result { - let result = match command { - Commands::Import(args) => { - let binary_path = PathBuf::from(&args.binary); - if !binary_path.exists() { - anyhow::bail!("Binary not found: {}", args.binary); - } - - let result = client - .import_binary( - &args.binary, - args.project - .as_ref() - .unwrap_or(&"quick-analysis".to_string()), - args.program.as_deref(), - ) - .await?; - - if let Some(program_name) = result.as_str() { - println!("Successfully imported as: {}", program_name); - } else if let Some(program_name) = result.get("program").and_then(|p| p.as_str()) { - println!("Successfully imported as: {}", program_name); - } - - return Ok(String::new()); - } - Commands::Analyze(args) => { - let config = Config::load()?; - let program = resolve_program(&args.program, &config)?; - let project = resolve_project(&args.project, &config, &program)?; - - println!("Analyzing {}...", program); - - client.analyze_program(&project, &program).await?; - - println!("Analysis complete!"); - - return Ok(String::new()); - } - Commands::Quick(args) => { - let project = args - .project - .clone() - .unwrap_or_else(|| "quick-analysis".to_string()); - let binary_path = PathBuf::from(&args.binary); - - println!("Quick analysis of {}...\n", args.binary); - - println!("[1/3] Importing binary..."); - let result = client.import_binary(&args.binary, &project, None).await?; - - let program_name = if let Some(name) = result.as_str() { - name.to_string() - } else if let Some(name) = result.get("program").and_then(|p| p.as_str()) { - name.to_string() - } else { - binary_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("program") - .to_string() - }; - - println!("[2/3] Running analysis..."); - client.analyze_program(&project, &program_name).await?; - - println!("[3/3] Done!\n"); - println!("Analysis complete. To query the binary, start the daemon:"); - println!( - " ghidra daemon start --project {} --program {}", - project, program_name - ); - println!("\nThen run queries like:"); - println!(" ghidra function list"); - println!(" ghidra decompile main"); - println!(" ghidra summary"); - - return Ok(String::new()); - } - Commands::Query(args) => match args.data_type.as_str() { - "functions" => { - client - .list_functions(args.limit, args.filter.clone()) - .await? - } - "strings" => client.list_strings(args.limit).await?, - "imports" => client.list_imports().await?, - "exports" => client.list_exports().await?, - "memory" => client.memory_map().await?, - other => anyhow::bail!("Query type '{}' not yet supported via daemon", other), - }, - Commands::Decompile(args) => client.decompile(args.target.clone()).await?, - Commands::Function(cmd) => { - use cli::FunctionCommands; - match cmd { - FunctionCommands::List(opts) => { - client - .list_functions(opts.limit, opts.filter.clone()) - .await? - } - FunctionCommands::Decompile(args) => client.decompile(args.target.clone()).await?, - _ => anyhow::bail!("Function subcommand not yet supported via daemon"), - } - } - Commands::Strings(cmd) => { - use cli::StringsCommands; - match cmd { - StringsCommands::List(opts) => client.list_strings(opts.limit).await?, - _ => anyhow::bail!("Strings subcommand not yet supported via daemon"), - } - } - Commands::Memory(cmd) => { - use cli::MemoryCommands; - match cmd { - MemoryCommands::Map(_) => client.memory_map().await?, - _ => anyhow::bail!("Memory subcommand not yet supported via daemon"), - } - } - Commands::Dump(cmd) => { - use cli::DumpCommands; - match cmd { - DumpCommands::Imports(_) => client.list_imports().await?, - DumpCommands::Exports(_) => client.list_exports().await?, - DumpCommands::Functions(opts) => { - client - .list_functions(opts.limit, opts.filter.clone()) - .await? - } - DumpCommands::Strings(opts) => client.list_strings(opts.limit).await?, - } - } - Commands::Summary(_) => client.program_info().await?, - Commands::XRef(cmd) => { - use cli::XRefCommands; - match cmd { - XRefCommands::To(args) => client.xrefs_to(args.address.clone()).await?, - XRefCommands::From(args) => client.xrefs_from(args.address.clone()).await?, - XRefCommands::List(_) => anyhow::bail!("XRef list not yet supported via daemon"), - } - } - Commands::Program(cmd) => { - use cli::ProgramCommands; - match cmd { - ProgramCommands::List(_) => { - client.list_programs().await? - } - ProgramCommands::Open(args) => { - let program = args.program.as_ref() - .ok_or_else(|| anyhow::anyhow!("Program name required. Use --program "))?; - client.open_program(program).await? - } - // Other program commands go through ExecuteCli - _ => { - let command_json = serde_json::to_string(command) - .map_err(|e| anyhow::anyhow!("Failed to serialize command: {}", e))?; - client.execute_cli_json(command_json).await? - } - } - } - // New commands - forward through ExecuteCli - Commands::Symbol(_) - | Commands::Type(_) - | Commands::Comment(_) - | Commands::Graph(_) - | Commands::Find(_) - | Commands::Diff(_) - | Commands::Patch(_) - | Commands::Script(_) - | Commands::Disasm(_) - | Commands::Batch(_) - | Commands::Stats(_) => { - let command_json = serde_json::to_string(command) - .map_err(|e| anyhow::anyhow!("Failed to serialize command: {}", e))?; - client.execute_cli_json(command_json).await? - } - _ => anyhow::bail!("Command not supported via daemon"), - }; - - // Determine output format based on flags and TTY detection - let format = if pretty_flag { - OutputFormat::Json - } else if json_flag { - OutputFormat::JsonCompact - } else { - auto_detect_format(atty::is(atty::Stream::Stdout)) - }; - - // Detect if result is already an array before wrapping - let values = match result { - serde_json::Value::Array(arr) => arr, - single => vec![single], - }; - - let formatter = DefaultFormatter; - formatter.format(&values, format).map_err(Into::into) -} - -/// Handle daemon management commands. -async fn handle_daemon_command(cmd: DaemonCommands) -> anyhow::Result<()> { - match cmd { - DaemonCommands::Start { - project, - program, - port, - foreground, - } => handle_daemon_start(project, program, port, foreground).await, - DaemonCommands::Stop { project } => handle_daemon_stop(project).await, - DaemonCommands::Restart { - project, - program, - port, - } => handle_daemon_restart(project, program, port).await, - DaemonCommands::Status { project } => handle_daemon_status(project).await, - DaemonCommands::Ping { project } => handle_daemon_ping(project).await, - DaemonCommands::ClearCache { project } => handle_daemon_clear_cache(project).await, - } -} - -/// Start the daemon. -async fn handle_daemon_start( - project: Option, - _program: Option, - port: Option, - foreground: bool, -) -> anyhow::Result<()> { - let config = Config::load()?; - let data_dir = get_data_dir()?; - let project_path = resolve_project_path(&project, &config)?; - - // Check if daemon is already running - ensure_not_running(&data_dir, &project_path)?; - - // Create log file path - let log_file = data_dir.join("daemon.log"); - - let daemon_config = DaemonConfig { - project_path: project_path.clone(), - ghidra_install_dir: config.ghidra_install_dir.clone().or_else(|| config.get_ghidra_install_dir().ok()), - log_file, - }; - - if foreground { - // Run in foreground - run_daemon(daemon_config).await?; - } else { - // Run in background - platform-specific daemonization - println!("Starting daemon for project: {}", project_path.display()); - - #[cfg(unix)] - { - daemonize_unix(daemon_config, port)?; - } - - #[cfg(windows)] - { - daemonize_windows(daemon_config, port)?; - } - - println!("Daemon started successfully"); - println!(" Log file: {}", data_dir.join("daemon.log").display()); - println!(" Use 'ghidra daemon status' to check daemon status"); - } - - Ok(()) -} - -/// Stop the daemon. -async fn handle_daemon_stop(project: Option) -> anyhow::Result<()> { - let config = Config::load()?; - let data_dir = get_data_dir()?; - let project_path = resolve_project_path(&project, &config)?; - - if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { - println!("Stopping daemon (PID: {})...", daemon_info.pid); - - // Connect via IPC and send shutdown (using project path for socket) - let mut client = ipc::client::DaemonClient::connect(&project_path).await?; - client.shutdown().await?; - - println!("Daemon stopped successfully"); - } else { - println!("No daemon running for project: {}", project_path.display()); - } - - Ok(()) -} - -/// Restart the daemon. -async fn handle_daemon_restart( - project: Option, - program: Option, - port: Option, -) -> anyhow::Result<()> { - // Stop first - handle_daemon_stop(project.clone()).await?; - - // Wait a moment - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - - // Start again - handle_daemon_start(project, program, port, false).await -} - -/// Get daemon status. -async fn handle_daemon_status(project: Option) -> anyhow::Result<()> { - let config = Config::load()?; - let data_dir = get_data_dir()?; - let project_path = resolve_project_path(&project, &config)?; - - if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { - println!("Daemon is running:"); - println!(" PID: {}", daemon_info.pid); - println!(" Project: {}", daemon_info.project_path.display()); - println!(" Started: {}", daemon_info.started_at); - println!(" Log file: {}", daemon_info.log_file.display()); - - // Try to get detailed status from daemon via IPC - if let Ok(mut client) = ipc::client::DaemonClient::connect(&project_path).await { - if let Ok(status) = client.status().await { - if let Some(bridge_running) = status.get("bridge_running").and_then(|v| v.as_bool()) - { - println!( - " Bridge: {}", - if bridge_running { "running" } else { "stopped" } - ); - } - } - } - } else { - println!("No daemon running for project: {}", project_path.display()); - } - - Ok(()) -} - -/// Ping the daemon. -async fn handle_daemon_ping(project: Option) -> anyhow::Result<()> { - let config = Config::load()?; - let data_dir = get_data_dir()?; - let project_path = resolve_project_path(&project, &config)?; - - if let Some(_daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { - let mut client = ipc::client::DaemonClient::connect(&project_path).await?; - client.ping().await?; - println!("Daemon is responsive"); - } else { - println!("No daemon running for project: {}", project_path.display()); - } - - Ok(()) -} - -/// Clear daemon cache. -async fn handle_daemon_clear_cache(project: Option) -> anyhow::Result<()> { - let config = Config::load()?; - let data_dir = get_data_dir()?; - let project_path = resolve_project_path(&project, &config)?; - - if let Some(_daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { - // TODO: Implement cache clear via IPC - println!("Cache clear not yet implemented via IPC"); - println!("Note: Cache will naturally expire after TTL"); - } else { - println!("No daemon running for project: {}", project_path.display()); - } - - Ok(()) -} - -/// Handle the setup command - download and install Ghidra. -async fn handle_setup(args: SetupArgs) -> anyhow::Result<()> { - println!("Ghidra Setup Wizard"); - println!("===================\n"); - - // 1. Check Java - if !args.force { - if let Err(e) = ghidra::setup::check_java_requirement() { - eprintln!("⚠ Java prerequisite check failed: {}", e); - eprintln!("Ghidra requires JDK 17+. Use --force to continue anyway."); - std::process::exit(1); - } - } else { - println!("⚠ Skipping Java check (--force specified)"); - } - - // 2. Determine Install Directory - let install_base = if let Some(d) = args.dir { - PathBuf::from(d) - } else { - // Default to XDG_DATA_HOME/ghidra-cli/ghidra - dirs::data_local_dir() - .ok_or(anyhow::anyhow!("Could not determine data directory"))? - .join("ghidra-cli") - .join("ghidra") - }; - - std::fs::create_dir_all(&install_base)?; - - // 3. Install Ghidra - println!("\nInstalling to: {}", install_base.display()); - let final_path = ghidra::setup::install_ghidra(args.version, install_base).await?; - - // 4. Install PyGhidra (required for Python scripting in Ghidra 12+) - if let Err(e) = ghidra::setup::install_pyghidra(&final_path) { - println!("⚠ PyGhidra setup failed: {}", e); - println!(" Python scripting may not work. You can try running setup again."); - } - - // 5. Update Config - let mut config = Config::load()?; - config.ghidra_install_dir = Some(final_path.clone()); - config.save()?; - - println!("\n✓ Success! Ghidra installed at: {}", final_path.display()); - println!("✓ Configuration updated."); - - // 6. Verify - println!("\nVerifying installation..."); - let client = GhidraClient::new(config)?; - if client.verify_installation().is_ok() { - println!("✓ Verification passed!"); - println!("\nYou can now run: ghidra quick "); - } else { - println!("⚠ Verification failed - analyzeHeadless not found"); - println!(" The installation may be incomplete."); - } - - Ok(()) -} - -/// Daemonize by spawning a detached process (cross-platform). -/// -/// This approach spawns a new process with --foreground flag instead of forking, -/// which avoids issues with Tokio runtime inheritance after fork. -#[cfg(unix)] -fn daemonize_unix(daemon_config: DaemonConfig, port: Option) -> anyhow::Result<()> { - use std::fs::OpenOptions; - use std::process::{Command, Stdio}; - - let log_file_path = daemon_config.log_file.clone(); - - // Ensure log directory exists - if let Some(parent) = log_file_path.parent() { - std::fs::create_dir_all(parent)?; - } - - // Open log file for stdout/stderr - let log_file = OpenOptions::new() - .create(true) - .append(true) - .open(&log_file_path)?; - - let stdout = log_file.try_clone()?; - let stderr = log_file; - - // Get the current executable path - let exe_path = std::env::current_exe()?; - - // Build the command to spawn ourselves with --foreground flag - let mut cmd = Command::new(exe_path); - cmd.arg("daemon").arg("start").arg("--foreground"); - - // Add project path - cmd.arg("--project") - .arg(daemon_config.project_path.to_string_lossy().to_string()); - - // Add port if specified - if let Some(p) = port { - cmd.arg("--port").arg(p.to_string()); - } - - // Redirect stdout/stderr to log file, detach stdin - cmd.stdin(Stdio::null()); - cmd.stdout(stdout); - cmd.stderr(stderr); - - // Spawn the detached process - cmd.spawn() - .map_err(|e| anyhow::anyhow!("Failed to spawn daemon process: {}", e))?; - - Ok(()) -} - -/// Daemonize on Windows by spawning a detached process. -#[cfg(windows)] -fn daemonize_windows(daemon_config: DaemonConfig, port: Option) -> anyhow::Result<()> { - use std::process::Command; - - // Get the current executable path - let exe_path = std::env::current_exe()?; - - // Build the command to spawn ourselves with --foreground flag - let mut cmd = Command::new(exe_path); - cmd.arg("daemon").arg("start").arg("--foreground"); - - // Add project path - cmd.arg("--project") - .arg(daemon_config.project_path.to_string_lossy().to_string()); - - // Add port if specified - if let Some(p) = port { - cmd.arg("--port").arg(p.to_string()); - } - - // Windows-specific: CREATE_NO_WINDOW flag - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - const DETACHED_PROCESS: u32 = 0x00000008; - cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS); - } - - // Spawn the detached process - cmd.spawn() - .map_err(|e| anyhow::anyhow!("Failed to spawn daemon process: {}", e))?; - - Ok(()) -} - -fn handle_init() -> anyhow::Result<()> { - println!("Ghidra CLI Initialization"); - println!("========================\n"); - - let mut config = Config::default(); - - // Check if Ghidra is installed - #[cfg(target_os = "windows")] - { - if let Some(dir) = Config::detect_ghidra_windows() { - println!("Found Ghidra installation at: {}", dir.display()); - config.ghidra_install_dir = Some(dir); - } - } - - if config.ghidra_install_dir.is_none() { - println!("Ghidra installation not found automatically."); - println!("Please set GHIDRA_INSTALL_DIR environment variable or update the config file."); - println!("\nExample:"); - println!(" set GHIDRA_INSTALL_DIR=C:\\ghidra\\ghidra_11.0"); - } - - // Set default project directory - let home = dirs::home_dir().ok_or_else(|| { - GhidraError::ConfigError("Could not determine home directory".to_string()) - })?; - let project_dir = home.join(".ghidra-projects"); - config.ghidra_project_dir = Some(project_dir.clone()); - - println!("\nProject directory: {}", project_dir.display()); - - // Save config - config.save()?; - - println!( - "\nConfiguration saved to: {}", - Config::config_path()?.display() - ); - println!("\nRun 'ghidra doctor' to verify your installation."); - - Ok(()) -} - -fn handle_doctor() -> anyhow::Result<()> { - println!("Ghidra CLI Doctor"); - println!("=================\n"); - - let config = Config::load()?; - - // Check Ghidra installation - print!("Checking Ghidra installation... "); - match config.get_ghidra_install_dir() { - Ok(dir) => { - println!("✓"); - println!(" Location: {}", dir.display()); - - let client = GhidraClient::new(config.clone()); - match client { - Ok(c) => { - if c.verify_installation().is_ok() { - println!(" analyzeHeadless: ✓"); - } else { - println!(" analyzeHeadless: ✗ (not found)"); - } - } - Err(e) => { - println!(" Error: {}", e); - } - } - } - Err(e) => { - println!("✗"); - println!(" Error: {}", e); - } - } - - // Check project directory - print!("\nChecking project directory... "); - match config.get_project_dir() { - Ok(dir) => { - println!("✓"); - println!(" Location: {}", dir.display()); - println!( - " Exists: {}", - if dir.exists() { - "yes" - } else { - "no (will be created)" - } - ); - } - Err(e) => { - println!("✗"); - println!(" Error: {}", e); - } - } - - // Check config file - print!("\nConfig file... "); - match Config::config_path() { - Ok(path) => { - println!("✓"); - println!(" Location: {}", path.display()); - println!(" Exists: {}", if path.exists() { "yes" } else { "no" }); - } - Err(e) => { - println!("✗"); - println!(" Error: {}", e); - } - } - - println!("\nDone!"); - Ok(()) -} - -fn handle_version() -> anyhow::Result<()> { - println!("ghidra-cli {}", env!("CARGO_PKG_VERSION")); - println!("Rust CLI for Ghidra reverse engineering"); - Ok(()) -} - -fn handle_config_command(cmd: cli::ConfigCommands) -> anyhow::Result<()> { - use cli::ConfigCommands; - - match cmd { - ConfigCommands::List => { - let config = Config::load()?; - println!("{}", serde_yaml::to_string(&config)?); - } - ConfigCommands::Get { key } => { - let config = Config::load()?; - let yaml = serde_yaml::to_value(&config)?; - if let Some(value) = yaml.get(&key) { - println!("{}", serde_yaml::to_string(value)?); - } else { - println!("Key not found: {}", key); - } - } - ConfigCommands::Set { key, value } => { - let mut config = Config::load()?; - // Simple key-value setting (could be expanded) - match key.as_str() { - "default_output_format" => config.default_output_format = Some(value), - "timeout" => { - let timeout: u64 = value.parse().map_err(|_| { - GhidraError::ConfigError("Invalid timeout value".to_string()) - })?; - config.timeout = Some(timeout); - } - _ => { - anyhow::bail!("Unknown config key: {}", key); - } - } - config.save()?; - println!("Configuration updated"); - } - ConfigCommands::Reset => { - let config = Config::default(); - config.save()?; - println!("Configuration reset to defaults"); - } - } - - Ok(()) -} - -fn handle_set_default(args: cli::SetDefaultArgs) -> anyhow::Result<()> { - let mut config = Config::load()?; - - match args.kind.as_str() { - "program" => { - config.default_program = Some(args.value.clone()); - config.save()?; - println!("Default program set to: {}", args.value); - } - "project" => { - config.default_project = Some(args.value.clone()); - config.save()?; - println!("Default project set to: {}", args.value); - } - _ => { - anyhow::bail!(format!("Unknown default kind: {}", args.kind)); - } - } - - Ok(()) -} - -fn handle_project_command(cmd: cli::ProjectCommands) -> anyhow::Result<()> { - use cli::ProjectCommands; - - let config = Config::load()?; - let client = GhidraClient::new(config)?; - - match cmd { - ProjectCommands::Create { name } => { - client.create_project(&name)?; - println!("Project '{}' created", name); - } - ProjectCommands::List => { - let project_dir = client.get_project_dir(); - if !project_dir.exists() { - println!("No projects found"); - return Ok(()); - } - - println!("Projects:"); - for entry in std::fs::read_dir(project_dir)? { - let entry = entry?; - if entry.path().is_dir() { - if let Some(name) = entry.file_name().to_str() { - println!(" {}", name); - } - } - } - } - ProjectCommands::Delete { name } => { - let project_path = client.get_project_path(&name); - if project_path.exists() { - std::fs::remove_dir_all(&project_path)?; - println!("Project '{}' deleted", name); - } else { - println!("Project '{}' not found", name); - } - } - ProjectCommands::Info { name } => { - let project_name = name.unwrap_or_else(|| "default".to_string()); - let project_path = client.get_project_path(&project_name); - println!("Project: {}", project_name); - println!("Path: {}", project_path.display()); - println!("Exists: {}", project_path.exists()); - } - } - - Ok(()) -} - -fn resolve_program(program: &Option, config: &Config) -> Result { - program - .clone() - .or_else(|| config.get_default_program()) - .ok_or_else(|| GhidraError::Other("No program specified. Use --program or set default with 'ghidra set-default program '".to_string())) -} - -fn resolve_project(project: &Option, config: &Config, program: &str) -> Result { - Ok(project - .clone() - .or_else(|| config.get_default_project()) - .unwrap_or_else(|| format!("{}-project", program))) -} - -/// Resolve a project name to its full path on disk. -/// If the project name is already an absolute path, returns it as-is. -/// Otherwise, resolves relative to the configured project directory. -fn resolve_project_path(project: &Option, config: &Config) -> anyhow::Result { - let project_name = project - .clone() - .or_else(|| config.default_project.clone()) - .ok_or_else(|| anyhow::anyhow!("No project specified and no default project configured"))?; - - let project_dir = config.get_project_dir()?; - - if PathBuf::from(&project_name).is_absolute() { - Ok(PathBuf::from(project_name)) - } else { - Ok(project_dir.join(project_name)) - } -} +mod cli; +mod config; +mod daemon; +mod error; +mod filter; +mod format; +mod ghidra; +mod ipc; +mod query; + +use clap::Parser; +use cli::{Cli, Commands, DaemonCommands, SetupArgs}; +use config::Config; +use daemon::process::{ensure_not_running, get_data_dir, get_running_daemon_info}; +use daemon::{run as run_daemon, DaemonConfig}; +use error::{GhidraError, Result}; +use format::{auto_detect_format, DefaultFormatter, Formatter, OutputFormat}; +use ghidra::GhidraClient; +use std::path::{Path, PathBuf}; +use tracing::info; + +#[tokio::main] +async fn main() { + // Initialize logging with info level by default, can be overridden via RUST_LOG + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .init(); + + let cli = Cli::parse(); + + let result = match &cli.command { + Commands::Daemon(_) | Commands::Setup(_) => { + // Daemon and Setup commands are async + run_async(cli).await + } + _ => { + // Other commands can be sync or we check if daemon is running + run_with_daemon_check(cli).await + } + }; + + if let Err(e) = result { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> anyhow::Result<()> { + match cli.command { + // Non-daemon commands + Commands::Init => handle_init(), + Commands::Doctor => handle_doctor(), + Commands::Version => handle_version(), + Commands::Config(cmd) => handle_config_command(cmd), + Commands::SetDefault(args) => handle_set_default(args), + Commands::Project(args) => handle_project_command(args.command), + // Commands requiring daemon are handled by run_with_daemon_check + Commands::Import(_) + | Commands::Analyze(_) + | Commands::Quick(_) + | Commands::Query(_) + | Commands::Summary(_) + | Commands::Function(_) + | Commands::Strings(_) + | Commands::Memory(_) + | Commands::Dump(_) + | Commands::Decompile(_) + | Commands::XRef(_) => { + unreachable!("Daemon-required commands should go through run_with_daemon_check") + } + _ => { + println!("Command not yet implemented"); + Ok(()) + } + } +} + +/// Run async commands (daemon management, setup). +async fn run_async(cli: Cli) -> anyhow::Result<()> { + match cli.command { + Commands::Daemon(cmd) => handle_daemon_command(cmd).await, + Commands::Setup(args) => handle_setup(args).await, + _ => unreachable!("run_async called with non-async command"), + } +} + +/// Determines if a command requires the daemon to be running. +fn requires_daemon(command: &Commands) -> bool { + matches!( + command, + Commands::Import(_) + | Commands::Analyze(_) + | Commands::Quick(_) + | Commands::Query(_) + | Commands::Decompile(_) + | Commands::Function(_) + | Commands::Strings(_) + | Commands::Memory(_) + | Commands::Dump(_) + | Commands::Summary(_) + | Commands::XRef(_) + | Commands::Symbol(_) + | Commands::Type(_) + | Commands::Comment(_) + | Commands::Graph(_) + | Commands::Find(_) + | Commands::Diff(_) + | Commands::Patch(_) + | Commands::Script(_) + | Commands::Disasm(_) + | Commands::Batch(_) + | Commands::Stats(_) + | Commands::Program(_) + ) +} + +/// Extract the project name from a command's args (if present). +fn extract_project_from_command(command: &Commands) -> Option { + match command { + Commands::Import(args) => args.project.clone(), + Commands::Analyze(args) => args.project.clone(), + Commands::Quick(args) => args.project.clone(), + Commands::Query(args) => args.project.clone(), + Commands::Summary(args) => args.options.project.clone(), + Commands::Decompile(args) => args.options.project.clone(), + Commands::Function(cmd) => match cmd { + cli::FunctionCommands::List(opts) => opts.project.clone(), + cli::FunctionCommands::Decompile(args) => args.options.project.clone(), + cli::FunctionCommands::Get(args) => args.options.project.clone(), + cli::FunctionCommands::Disasm(args) => args.options.project.clone(), + cli::FunctionCommands::Calls(args) => args.options.project.clone(), + cli::FunctionCommands::XRefs(args) => args.options.project.clone(), + cli::FunctionCommands::Rename(args) => args.project.clone(), + cli::FunctionCommands::Create(args) => args.project.clone(), + cli::FunctionCommands::Delete(args) => args.options.project.clone(), + }, + Commands::Strings(cmd) => match cmd { + cli::StringsCommands::List(opts) => opts.project.clone(), + cli::StringsCommands::Refs(args) => args.options.project.clone(), + }, + Commands::Memory(cmd) => match cmd { + cli::MemoryCommands::Map(opts) => opts.project.clone(), + cli::MemoryCommands::Read(args) => args.options.project.clone(), + cli::MemoryCommands::Write(args) => args.project.clone(), + cli::MemoryCommands::Search(args) => args.options.project.clone(), + }, + Commands::Dump(cmd) => match cmd { + cli::DumpCommands::Imports(opts) => opts.project.clone(), + cli::DumpCommands::Exports(opts) => opts.project.clone(), + cli::DumpCommands::Functions(opts) => opts.project.clone(), + cli::DumpCommands::Strings(opts) => opts.project.clone(), + }, + Commands::XRef(cmd) => match cmd { + cli::XRefCommands::To(args) => args.options.project.clone(), + cli::XRefCommands::From(args) => args.options.project.clone(), + cli::XRefCommands::List(args) => args.options.project.clone(), + }, + Commands::Stats(args) => args.options.project.clone(), + Commands::Disasm(args) => args.options.project.clone(), + Commands::Find(cmd) => match cmd { + cli::FindCommands::String(args) => args.options.project.clone(), + cli::FindCommands::Bytes(args) => args.options.project.clone(), + cli::FindCommands::Function(args) => args.options.project.clone(), + cli::FindCommands::Calls(args) => args.options.project.clone(), + cli::FindCommands::Crypto(opts) => opts.project.clone(), + cli::FindCommands::Interesting(opts) => opts.project.clone(), + }, + Commands::Graph(cmd) => match cmd { + cli::GraphCommands::Calls(opts) => opts.project.clone(), + cli::GraphCommands::Callers(args) => args.options.project.clone(), + cli::GraphCommands::Callees(args) => args.options.project.clone(), + cli::GraphCommands::Export(args) => args.options.project.clone(), + }, + Commands::Comment(cmd) => match cmd { + cli::CommentCommands::List(opts) => opts.project.clone(), + cli::CommentCommands::Get(args) => args.options.project.clone(), + cli::CommentCommands::Set(args) => args.project.clone(), + cli::CommentCommands::Delete(args) => args.options.project.clone(), + }, + Commands::Symbol(cmd) => match cmd { + cli::SymbolCommands::List(opts) => opts.project.clone(), + cli::SymbolCommands::Get(args) => args.options.project.clone(), + cli::SymbolCommands::Create(args) => args.project.clone(), + cli::SymbolCommands::Delete(args) => args.options.project.clone(), + cli::SymbolCommands::Rename(args) => args.project.clone(), + }, + Commands::Type(cmd) => match cmd { + cli::TypeCommands::List(opts) => opts.project.clone(), + cli::TypeCommands::Get(args) => args.options.project.clone(), + cli::TypeCommands::Create(args) => args.project.clone(), + cli::TypeCommands::Apply(args) => args.project.clone(), + }, + Commands::Patch(cmd) => match cmd { + cli::PatchCommands::Bytes(args) => args.project.clone(), + cli::PatchCommands::Nop(args) => args.project.clone(), + cli::PatchCommands::Export(args) => args.project.clone(), + }, + Commands::Script(cmd) => match cmd { + cli::ScriptCommands::Run(args) => args.project.clone(), + cli::ScriptCommands::Python(args) => args.project.clone(), + cli::ScriptCommands::Java(args) => args.project.clone(), + cli::ScriptCommands::List => None, + }, + Commands::Program(cmd) => match cmd { + cli::ProgramCommands::List(args) => args.project.clone(), + cli::ProgramCommands::Open(args) => args.project.clone(), + cli::ProgramCommands::Close(args) => args.project.clone(), + cli::ProgramCommands::Delete(args) => args.project.clone(), + cli::ProgramCommands::Info(args) => args.project.clone(), + cli::ProgramCommands::Export(args) => args.project.clone(), + }, + _ => None, + } +} + +/// Run commands with daemon check - route through daemon if required. +async fn run_with_daemon_check(cli: Cli) -> anyhow::Result<()> { + // Commands that don't require daemon can run directly + if !requires_daemon(&cli.command) { + return run(cli); + } + + let config = Config::load()?; + + // Extract project from command args, fall back to config default + let project_from_cmd = extract_project_from_command(&cli.command); + let project_path = resolve_project_path(&project_from_cmd, &config)?; + + ensure_daemon_running(&project_path).await?; + + let mut client = ipc::client::DaemonClient::connect(&project_path).await?; + info!("Connected to daemon via IPC"); + let output = + execute_via_daemon(&mut client, &cli.command, cli.json, cli.pretty).await?; + if !output.is_empty() { + println!("{}", output); + } + Ok(()) +} + +/// Ensure daemon is running for the given project path. +/// Starts the daemon if not running, and waits until it's accepting connections. +async fn ensure_daemon_running(project_path: &Path) -> anyhow::Result<()> { + let data_dir = get_data_dir()?; + + // Check if daemon is already running + if get_running_daemon_info(&data_dir, project_path)?.is_some() { + // Verify it's actually responding + if let Ok(mut client) = ipc::client::DaemonClient::connect(project_path).await { + if client.ping().await.is_ok() { + return Ok(()); + } + } + // Lock file exists but daemon not responding - clean up and restart + } + + let config = Config::load()?; + let log_file = data_dir.join("daemon.log"); + + let daemon_config = DaemonConfig { + project_path: project_path.to_path_buf(), + ghidra_install_dir: config.ghidra_install_dir.clone().or_else(|| config.get_ghidra_install_dir().ok()), + log_file, + }; + + eprintln!("Starting daemon..."); + + #[cfg(unix)] + { + daemonize_unix(daemon_config, None)?; + } + + #[cfg(windows)] + { + daemonize_windows(daemon_config, None)?; + } + + // Wait for daemon to be ready by polling for connection + let max_attempts = 30; // 30 * 200ms = 6 seconds max + for attempt in 0..max_attempts { + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + if let Ok(mut client) = ipc::client::DaemonClient::connect(project_path).await { + if client.ping().await.is_ok() { + eprintln!("Daemon ready."); + return Ok(()); + } + } + + if attempt > 0 && attempt % 10 == 0 { + eprintln!("Still waiting for daemon to start..."); + } + } + + anyhow::bail!("Daemon failed to start within timeout. Check logs at: {}", data_dir.join("daemon.log").display()) +} + +/// Execute a command via the daemon IPC connection. +async fn execute_via_daemon( + client: &mut ipc::client::DaemonClient, + command: &Commands, + json_flag: bool, + pretty_flag: bool, +) -> anyhow::Result { + let result = match command { + Commands::Import(args) => { + let binary_path = PathBuf::from(&args.binary); + if !binary_path.exists() { + anyhow::bail!("Binary not found: {}", args.binary); + } + + let result = client + .import_binary( + &args.binary, + args.project + .as_ref() + .unwrap_or(&"quick-analysis".to_string()), + args.program.as_deref(), + ) + .await?; + + if let Some(program_name) = result.as_str() { + println!("Successfully imported as: {}", program_name); + } else if let Some(program_name) = result.get("program").and_then(|p| p.as_str()) { + println!("Successfully imported as: {}", program_name); + } + + return Ok(String::new()); + } + Commands::Analyze(args) => { + let config = Config::load()?; + let program = resolve_program(&args.program, &config)?; + let project = resolve_project(&args.project, &config, &program)?; + + println!("Analyzing {}...", program); + + client.analyze_program(&project, &program).await?; + + println!("Analysis complete!"); + + return Ok(String::new()); + } + Commands::Quick(args) => { + let project = args + .project + .clone() + .unwrap_or_else(|| "quick-analysis".to_string()); + let binary_path = PathBuf::from(&args.binary); + + println!("Quick analysis of {}...\n", args.binary); + + println!("[1/3] Importing binary..."); + let result = client.import_binary(&args.binary, &project, None).await?; + + let program_name = if let Some(name) = result.as_str() { + name.to_string() + } else if let Some(name) = result.get("program").and_then(|p| p.as_str()) { + name.to_string() + } else { + binary_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("program") + .to_string() + }; + + println!("[2/3] Running analysis..."); + client.analyze_program(&project, &program_name).await?; + + println!("[3/3] Done!\n"); + println!("Analysis complete. To query the binary, start the daemon:"); + println!( + " ghidra daemon start --project {} --program {}", + project, program_name + ); + println!("\nThen run queries like:"); + println!(" ghidra function list"); + println!(" ghidra decompile main"); + println!(" ghidra summary"); + + return Ok(String::new()); + } + Commands::Query(args) => match args.data_type.as_str() { + "functions" => { + client + .list_functions(args.limit, args.filter.clone()) + .await? + } + "strings" => client.list_strings(args.limit).await?, + "imports" => client.list_imports().await?, + "exports" => client.list_exports().await?, + "memory" => client.memory_map().await?, + other => anyhow::bail!("Query type '{}' not yet supported via daemon", other), + }, + Commands::Decompile(args) => client.decompile(args.target.clone()).await?, + Commands::Function(cmd) => { + use cli::FunctionCommands; + match cmd { + FunctionCommands::List(opts) => { + client + .list_functions(opts.limit, opts.filter.clone()) + .await? + } + FunctionCommands::Decompile(args) => client.decompile(args.target.clone()).await?, + _ => anyhow::bail!("Function subcommand not yet supported via daemon"), + } + } + Commands::Strings(cmd) => { + use cli::StringsCommands; + match cmd { + StringsCommands::List(opts) => client.list_strings(opts.limit).await?, + _ => anyhow::bail!("Strings subcommand not yet supported via daemon"), + } + } + Commands::Memory(cmd) => { + use cli::MemoryCommands; + match cmd { + MemoryCommands::Map(_) => client.memory_map().await?, + _ => anyhow::bail!("Memory subcommand not yet supported via daemon"), + } + } + Commands::Dump(cmd) => { + use cli::DumpCommands; + match cmd { + DumpCommands::Imports(_) => client.list_imports().await?, + DumpCommands::Exports(_) => client.list_exports().await?, + DumpCommands::Functions(opts) => { + client + .list_functions(opts.limit, opts.filter.clone()) + .await? + } + DumpCommands::Strings(opts) => client.list_strings(opts.limit).await?, + } + } + Commands::Summary(_) => client.program_info().await?, + Commands::XRef(cmd) => { + use cli::XRefCommands; + match cmd { + XRefCommands::To(args) => client.xrefs_to(args.address.clone()).await?, + XRefCommands::From(args) => client.xrefs_from(args.address.clone()).await?, + XRefCommands::List(_) => anyhow::bail!("XRef list not yet supported via daemon"), + } + } + Commands::Program(cmd) => { + use cli::ProgramCommands; + match cmd { + ProgramCommands::List(_) => { + client.list_programs().await? + } + ProgramCommands::Open(args) => { + let program = args.program.as_ref() + .ok_or_else(|| anyhow::anyhow!("Program name required. Use --program "))?; + client.open_program(program).await? + } + // Other program commands go through ExecuteCli + _ => { + let command_json = serde_json::to_string(command) + .map_err(|e| anyhow::anyhow!("Failed to serialize command: {}", e))?; + client.execute_cli_json(command_json).await? + } + } + } + // New commands - forward through ExecuteCli + Commands::Symbol(_) + | Commands::Type(_) + | Commands::Comment(_) + | Commands::Graph(_) + | Commands::Find(_) + | Commands::Diff(_) + | Commands::Patch(_) + | Commands::Script(_) + | Commands::Disasm(_) + | Commands::Batch(_) + | Commands::Stats(_) => { + let command_json = serde_json::to_string(command) + .map_err(|e| anyhow::anyhow!("Failed to serialize command: {}", e))?; + client.execute_cli_json(command_json).await? + } + _ => anyhow::bail!("Command not supported via daemon"), + }; + + // Determine output format based on flags and TTY detection + let format = if pretty_flag { + OutputFormat::Json + } else if json_flag { + OutputFormat::JsonCompact + } else { + auto_detect_format(atty::is(atty::Stream::Stdout)) + }; + + // Detect if result is already an array before wrapping + let values = match result { + serde_json::Value::Array(arr) => arr, + single => vec![single], + }; + + let formatter = DefaultFormatter; + formatter.format(&values, format).map_err(Into::into) +} + +/// Handle daemon management commands. +async fn handle_daemon_command(cmd: DaemonCommands) -> anyhow::Result<()> { + match cmd { + DaemonCommands::Start { + project, + program, + port, + foreground, + } => handle_daemon_start(project, program, port, foreground).await, + DaemonCommands::Stop { project } => handle_daemon_stop(project).await, + DaemonCommands::Restart { + project, + program, + port, + } => handle_daemon_restart(project, program, port).await, + DaemonCommands::Status { project } => handle_daemon_status(project).await, + DaemonCommands::Ping { project } => handle_daemon_ping(project).await, + DaemonCommands::ClearCache { project } => handle_daemon_clear_cache(project).await, + } +} + +/// Start the daemon. +async fn handle_daemon_start( + project: Option, + _program: Option, + port: Option, + foreground: bool, +) -> anyhow::Result<()> { + let config = Config::load()?; + let data_dir = get_data_dir()?; + let project_path = resolve_project_path(&project, &config)?; + + // Check if daemon is already running + ensure_not_running(&data_dir, &project_path)?; + + // Create log file path + let log_file = data_dir.join("daemon.log"); + + let daemon_config = DaemonConfig { + project_path: project_path.clone(), + ghidra_install_dir: config.ghidra_install_dir.clone().or_else(|| config.get_ghidra_install_dir().ok()), + log_file, + }; + + if foreground { + // Run in foreground + run_daemon(daemon_config).await?; + } else { + // Run in background - platform-specific daemonization + println!("Starting daemon for project: {}", project_path.display()); + + #[cfg(unix)] + { + daemonize_unix(daemon_config, port)?; + } + + #[cfg(windows)] + { + daemonize_windows(daemon_config, port)?; + } + + println!("Daemon started successfully"); + println!(" Log file: {}", data_dir.join("daemon.log").display()); + println!(" Use 'ghidra daemon status' to check daemon status"); + } + + Ok(()) +} + +/// Stop the daemon. +async fn handle_daemon_stop(project: Option) -> anyhow::Result<()> { + let config = Config::load()?; + let data_dir = get_data_dir()?; + let project_path = resolve_project_path(&project, &config)?; + + if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { + println!("Stopping daemon (PID: {})...", daemon_info.pid); + + // Connect via IPC and send shutdown (using project path for socket) + let mut client = ipc::client::DaemonClient::connect(&project_path).await?; + client.shutdown().await?; + + println!("Daemon stopped successfully"); + } else { + println!("No daemon running for project: {}", project_path.display()); + } + + Ok(()) +} + +/// Restart the daemon. +async fn handle_daemon_restart( + project: Option, + program: Option, + port: Option, +) -> anyhow::Result<()> { + // Stop first + handle_daemon_stop(project.clone()).await?; + + // Wait a moment + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + // Start again + handle_daemon_start(project, program, port, false).await +} + +/// Get daemon status. +async fn handle_daemon_status(project: Option) -> anyhow::Result<()> { + let config = Config::load()?; + let data_dir = get_data_dir()?; + let project_path = resolve_project_path(&project, &config)?; + + if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { + println!("Daemon is running:"); + println!(" PID: {}", daemon_info.pid); + println!(" Project: {}", daemon_info.project_path.display()); + println!(" Started: {}", daemon_info.started_at); + println!(" Log file: {}", daemon_info.log_file.display()); + + // Try to get detailed status from daemon via IPC + if let Ok(mut client) = ipc::client::DaemonClient::connect(&project_path).await { + if let Ok(status) = client.status().await { + if let Some(bridge_running) = status.get("bridge_running").and_then(|v| v.as_bool()) + { + println!( + " Bridge: {}", + if bridge_running { "running" } else { "stopped" } + ); + } + } + } + } else { + println!("No daemon running for project: {}", project_path.display()); + } + + Ok(()) +} + +/// Ping the daemon. +async fn handle_daemon_ping(project: Option) -> anyhow::Result<()> { + let config = Config::load()?; + let data_dir = get_data_dir()?; + let project_path = resolve_project_path(&project, &config)?; + + if let Some(_daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { + let mut client = ipc::client::DaemonClient::connect(&project_path).await?; + client.ping().await?; + println!("Daemon is responsive"); + } else { + println!("No daemon running for project: {}", project_path.display()); + } + + Ok(()) +} + +/// Clear daemon cache. +async fn handle_daemon_clear_cache(project: Option) -> anyhow::Result<()> { + let config = Config::load()?; + let data_dir = get_data_dir()?; + let project_path = resolve_project_path(&project, &config)?; + + if let Some(_daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { + // TODO: Implement cache clear via IPC + println!("Cache clear not yet implemented via IPC"); + println!("Note: Cache will naturally expire after TTL"); + } else { + println!("No daemon running for project: {}", project_path.display()); + } + + Ok(()) +} + +/// Handle the setup command - download and install Ghidra. +async fn handle_setup(args: SetupArgs) -> anyhow::Result<()> { + println!("Ghidra Setup Wizard"); + println!("===================\n"); + + // 1. Check Java + if !args.force { + if let Err(e) = ghidra::setup::check_java_requirement() { + eprintln!("⚠ Java prerequisite check failed: {}", e); + eprintln!("Ghidra requires JDK 17+. Use --force to continue anyway."); + std::process::exit(1); + } + } else { + println!("⚠ Skipping Java check (--force specified)"); + } + + // 2. Determine Install Directory + let install_base = if let Some(d) = args.dir { + PathBuf::from(d) + } else { + // Default to XDG_DATA_HOME/ghidra-cli/ghidra + dirs::data_local_dir() + .ok_or(anyhow::anyhow!("Could not determine data directory"))? + .join("ghidra-cli") + .join("ghidra") + }; + + std::fs::create_dir_all(&install_base)?; + + // 3. Install Ghidra + println!("\nInstalling to: {}", install_base.display()); + let final_path = ghidra::setup::install_ghidra(args.version, install_base).await?; + + // 4. Install PyGhidra (required for Python scripting in Ghidra 12+) + if let Err(e) = ghidra::setup::install_pyghidra(&final_path) { + println!("⚠ PyGhidra setup failed: {}", e); + println!(" Python scripting may not work. You can try running setup again."); + } + + // 5. Update Config + let mut config = Config::load()?; + config.ghidra_install_dir = Some(final_path.clone()); + config.save()?; + + println!("\n✓ Success! Ghidra installed at: {}", final_path.display()); + println!("✓ Configuration updated."); + + // 6. Verify + println!("\nVerifying installation..."); + let client = GhidraClient::new(config)?; + if client.verify_installation().is_ok() { + println!("✓ Verification passed!"); + println!("\nYou can now run: ghidra quick "); + } else { + println!("⚠ Verification failed - analyzeHeadless not found"); + println!(" The installation may be incomplete."); + } + + Ok(()) +} + +/// Daemonize by spawning a detached process (cross-platform). +/// +/// This approach spawns a new process with --foreground flag instead of forking, +/// which avoids issues with Tokio runtime inheritance after fork. +#[cfg(unix)] +fn daemonize_unix(daemon_config: DaemonConfig, port: Option) -> anyhow::Result<()> { + use std::fs::OpenOptions; + use std::process::{Command, Stdio}; + + let log_file_path = daemon_config.log_file.clone(); + + // Ensure log directory exists + if let Some(parent) = log_file_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Open log file for stdout/stderr + let log_file = OpenOptions::new() + .create(true) + .append(true) + .open(&log_file_path)?; + + let stdout = log_file.try_clone()?; + let stderr = log_file; + + // Get the current executable path + let exe_path = std::env::current_exe()?; + + // Build the command to spawn ourselves with --foreground flag + let mut cmd = Command::new(exe_path); + cmd.arg("daemon").arg("start").arg("--foreground"); + + // Add project path + cmd.arg("--project") + .arg(daemon_config.project_path.to_string_lossy().to_string()); + + // Add port if specified + if let Some(p) = port { + cmd.arg("--port").arg(p.to_string()); + } + + // Redirect stdout/stderr to log file, detach stdin + cmd.stdin(Stdio::null()); + cmd.stdout(stdout); + cmd.stderr(stderr); + + // Spawn the detached process + cmd.spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn daemon process: {}", e))?; + + Ok(()) +} + +/// Daemonize on Windows by spawning a detached process. +#[cfg(windows)] +fn daemonize_windows(daemon_config: DaemonConfig, port: Option) -> anyhow::Result<()> { + use std::process::Command; + + // Get the current executable path + let exe_path = std::env::current_exe()?; + + // Build the command to spawn ourselves with --foreground flag + let mut cmd = Command::new(exe_path); + cmd.arg("daemon").arg("start").arg("--foreground"); + + // Add project path + cmd.arg("--project") + .arg(daemon_config.project_path.to_string_lossy().to_string()); + + // Add port if specified + if let Some(p) = port { + cmd.arg("--port").arg(p.to_string()); + } + + // Windows-specific: CREATE_NO_WINDOW flag + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + const DETACHED_PROCESS: u32 = 0x00000008; + cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS); + } + + // Spawn the detached process + cmd.spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn daemon process: {}", e))?; + + Ok(()) +} + +fn handle_init() -> anyhow::Result<()> { + println!("Ghidra CLI Initialization"); + println!("========================\n"); + + let mut config = Config::default(); + + // Check if Ghidra is installed + #[cfg(target_os = "windows")] + { + if let Some(dir) = Config::detect_ghidra_windows() { + println!("Found Ghidra installation at: {}", dir.display()); + config.ghidra_install_dir = Some(dir); + } + } + + if config.ghidra_install_dir.is_none() { + println!("Ghidra installation not found automatically."); + println!("Please set GHIDRA_INSTALL_DIR environment variable or update the config file."); + println!("\nExample:"); + println!(" set GHIDRA_INSTALL_DIR=C:\\ghidra\\ghidra_11.0"); + } + + // Set default project directory + let home = dirs::home_dir().ok_or_else(|| { + GhidraError::ConfigError("Could not determine home directory".to_string()) + })?; + let project_dir = home.join(".ghidra-projects"); + config.ghidra_project_dir = Some(project_dir.clone()); + + println!("\nProject directory: {}", project_dir.display()); + + // Save config + config.save()?; + + println!( + "\nConfiguration saved to: {}", + Config::config_path()?.display() + ); + println!("\nRun 'ghidra doctor' to verify your installation."); + + Ok(()) +} + +fn handle_doctor() -> anyhow::Result<()> { + println!("Ghidra CLI Doctor"); + println!("=================\n"); + + let config = Config::load()?; + + // Check Ghidra installation + print!("Checking Ghidra installation... "); + match config.get_ghidra_install_dir() { + Ok(dir) => { + println!("✓"); + println!(" Location: {}", dir.display()); + + let client = GhidraClient::new(config.clone()); + match client { + Ok(c) => { + if c.verify_installation().is_ok() { + println!(" analyzeHeadless: ✓"); + } else { + println!(" analyzeHeadless: ✗ (not found)"); + } + } + Err(e) => { + println!(" Error: {}", e); + } + } + } + Err(e) => { + println!("✗"); + println!(" Error: {}", e); + } + } + + // Check project directory + print!("\nChecking project directory... "); + match config.get_project_dir() { + Ok(dir) => { + println!("✓"); + println!(" Location: {}", dir.display()); + println!( + " Exists: {}", + if dir.exists() { + "yes" + } else { + "no (will be created)" + } + ); + } + Err(e) => { + println!("✗"); + println!(" Error: {}", e); + } + } + + // Check config file + print!("\nConfig file... "); + match Config::config_path() { + Ok(path) => { + println!("✓"); + println!(" Location: {}", path.display()); + println!(" Exists: {}", if path.exists() { "yes" } else { "no" }); + } + Err(e) => { + println!("✗"); + println!(" Error: {}", e); + } + } + + println!("\nDone!"); + Ok(()) +} + +fn handle_version() -> anyhow::Result<()> { + println!("ghidra-cli {}", env!("CARGO_PKG_VERSION")); + println!("Rust CLI for Ghidra reverse engineering"); + Ok(()) +} + +fn handle_config_command(cmd: cli::ConfigCommands) -> anyhow::Result<()> { + use cli::ConfigCommands; + + match cmd { + ConfigCommands::List => { + let config = Config::load()?; + println!("{}", serde_yaml::to_string(&config)?); + } + ConfigCommands::Get { key } => { + let config = Config::load()?; + let yaml = serde_yaml::to_value(&config)?; + if let Some(value) = yaml.get(&key) { + println!("{}", serde_yaml::to_string(value)?); + } else { + println!("Key not found: {}", key); + } + } + ConfigCommands::Set { key, value } => { + let mut config = Config::load()?; + // Simple key-value setting (could be expanded) + match key.as_str() { + "default_output_format" => config.default_output_format = Some(value), + "timeout" => { + let timeout: u64 = value.parse().map_err(|_| { + GhidraError::ConfigError("Invalid timeout value".to_string()) + })?; + config.timeout = Some(timeout); + } + _ => { + anyhow::bail!("Unknown config key: {}", key); + } + } + config.save()?; + println!("Configuration updated"); + } + ConfigCommands::Reset => { + let config = Config::default(); + config.save()?; + println!("Configuration reset to defaults"); + } + } + + Ok(()) +} + +fn handle_set_default(args: cli::SetDefaultArgs) -> anyhow::Result<()> { + let mut config = Config::load()?; + + match args.kind.as_str() { + "program" => { + config.default_program = Some(args.value.clone()); + config.save()?; + println!("Default program set to: {}", args.value); + } + "project" => { + config.default_project = Some(args.value.clone()); + config.save()?; + println!("Default project set to: {}", args.value); + } + _ => { + anyhow::bail!(format!("Unknown default kind: {}", args.kind)); + } + } + + Ok(()) +} + +fn handle_project_command(cmd: cli::ProjectCommands) -> anyhow::Result<()> { + use cli::ProjectCommands; + + let config = Config::load()?; + let client = GhidraClient::new(config)?; + + match cmd { + ProjectCommands::Create { name } => { + client.create_project(&name)?; + println!("Project '{}' created", name); + } + ProjectCommands::List => { + let project_dir = client.get_project_dir(); + if !project_dir.exists() { + println!("No projects found"); + return Ok(()); + } + + println!("Projects:"); + for entry in std::fs::read_dir(project_dir)? { + let entry = entry?; + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + println!(" {}", name); + } + } + } + } + ProjectCommands::Delete { name } => { + let project_path = client.get_project_path(&name); + if project_path.exists() { + std::fs::remove_dir_all(&project_path)?; + println!("Project '{}' deleted", name); + } else { + println!("Project '{}' not found", name); + } + } + ProjectCommands::Info { name } => { + let project_name = name.unwrap_or_else(|| "default".to_string()); + let project_path = client.get_project_path(&project_name); + println!("Project: {}", project_name); + println!("Path: {}", project_path.display()); + println!("Exists: {}", project_path.exists()); + } + } + + Ok(()) +} + +fn resolve_program(program: &Option, config: &Config) -> Result { + program + .clone() + .or_else(|| config.get_default_program()) + .ok_or_else(|| GhidraError::Other("No program specified. Use --program or set default with 'ghidra set-default program '".to_string())) +} + +fn resolve_project(project: &Option, config: &Config, program: &str) -> Result { + Ok(project + .clone() + .or_else(|| config.get_default_project()) + .unwrap_or_else(|| format!("{}-project", program))) +} + +/// Resolve a project name to its full path on disk. +/// If the project name is already an absolute path, returns it as-is. +/// Otherwise, resolves relative to the configured project directory. +fn resolve_project_path(project: &Option, config: &Config) -> anyhow::Result { + let project_name = project + .clone() + .or_else(|| config.default_project.clone()) + .ok_or_else(|| anyhow::anyhow!("No project specified and no default project configured"))?; + + let project_dir = config.get_project_dir()?; + + if PathBuf::from(&project_name).is_absolute() { + Ok(PathBuf::from(project_name)) + } else { + Ok(project_dir.join(project_name)) + } +}