From 35a864bd2f33d24b150d43c09e47a4d6deb94446 Mon Sep 17 00:00:00 2001 From: Alexander Kiselev Date: Tue, 20 Jan 2026 16:15:02 -0800 Subject: [PATCH] refactoring to use python bridge and a persistent ghidra --- Cargo.lock | 34 +++ Cargo.toml | 3 + docs/plans/refactor-NOTES.md | 100 +++++++++ src/daemon/handler.rs | 127 ++++++++++++ src/daemon/ipc_server.rs | 160 +++++++++++++++ src/daemon/mod.rs | 122 ++++++----- src/ghidra/bridge.rs | 343 +++++++++++++++++++++++++++++++ src/ghidra/headless.rs | 53 ++--- src/ghidra/mod.rs | 2 + src/ghidra/scripts.rs | 18 ++ src/ghidra/scripts/bridge.py | 387 +++++++++++++++++++++++++++++++++++ src/ipc/client.rs | 153 ++++++++++++++ src/ipc/mod.rs | 15 ++ src/ipc/protocol.rs | 166 +++++++++++++++ src/ipc/transport.rs | 203 ++++++++++++++++++ src/main.rs | 2 + 16 files changed, 1801 insertions(+), 87 deletions(-) create mode 100644 src/daemon/handler.rs create mode 100644 src/daemon/ipc_server.rs create mode 100644 src/ghidra/bridge.rs create mode 100644 src/ghidra/scripts/bridge.py create mode 100644 src/ipc/client.rs create mode 100644 src/ipc/mod.rs create mode 100644 src/ipc/protocol.rs create mode 100644 src/ipc/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 8e629f2..2a265fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + [[package]] name = "document-features" version = "0.2.12" @@ -774,6 +780,7 @@ dependencies = [ "env_logger", "futures-util", "indicatif", + "interprocess", "lazy_static", "log", "md5", @@ -1106,6 +1113,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1697,6 +1719,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2698,6 +2726,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 3c72e62..1cb7e4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,9 @@ tokio = { version = "1.35", features = ["full"] } # RPC remoc = { version = "0.16", features = ["full"] } +# IPC (local sockets) +interprocess = { version = "2.2", features = ["tokio"] } + # Time chrono = { version = "0.4", features = ["serde"] } diff --git a/docs/plans/refactor-NOTES.md b/docs/plans/refactor-NOTES.md index 8b13789..5908f24 100644 --- a/docs/plans/refactor-NOTES.md +++ b/docs/plans/refactor-NOTES.md @@ -1 +1,101 @@ +# Refactoring Notes +## Architecture Reference: debugger-cli + +The `debugger-cli` project at `~/git/debugger-cli` provides a good pattern for daemon-based CLIs: + +### Key Patterns Used + +1. **IPC via Local Sockets** (`src/ipc/`) + - Uses `interprocess` crate for cross-platform Unix sockets / Windows named pipes + - Length-prefixed JSON messages (4-byte little-endian length + payload) + - Separate `protocol.rs`, `transport.rs`, and `client.rs` modules + +2. **Daemon Architecture** (`src/daemon/`) + - `server.rs` - Main event loop with IPC listener + - `handler.rs` - Command routing and execution + - `session.rs` - State management for debug sessions + +3. **Clean Separation** + - IPC protocol defines its own `Command` enum (not reusing CLI args) + - Handler translates protocol commands to domain operations + - Session holds the actual debug adapter connection + +--- + +## Implementation Progress + +### Phase 1: Bridge Script ✅ +- Created `src/ghidra/scripts/bridge.py` - persistent TCP server inside Ghidra +- Implements handlers: `ping`, `program_info`, `list_functions`, `decompile`, `list_strings`, `list_imports`, `list_exports`, `memory_map`, `xrefs_to`, `xrefs_from` +- Uses `---GHIDRA_CLI_START---` / `---GHIDRA_CLI_END---` markers for ready signal + +### Phase 2: Output Markers ✅ +- Updated all 8 Python scripts in `scripts.rs` with delimiters +- Updated `headless.rs` to use marker-based extraction instead of fragile brace-counting + +### Phase 3: IPC Layer ✅ +- Added `interprocess` crate to `Cargo.toml` +- Created `src/ipc/mod.rs` with: + - `protocol.rs` - Typed `Command` enum, `Request`, `Response` structures + - `transport.rs` - Cross-platform socket wrapper with length-prefixed framing + - `client.rs` - `DaemonClient` for CLI-to-daemon communication + +### Phase 4: Bridge Manager ✅ +- Created `src/ghidra/bridge.rs` with `GhidraBridge` struct +- Manages Ghidra process lifecycle (spawn, monitor, shutdown) +- TCP connection to Python bridge script +- `BridgeResponse` typed response handling +- Embeds bridge.py via `include_str!` macro + +### Phase 5: Daemon Update 🚧 +- Status: Not yet wired up +- Remaining: Refactor daemon to use new IPC layer and bridge + +### Phase 6: Typed Responses ⚙️ +- `BridgeResponse` created in `bridge.rs` +- Remaining: Update all response handling + +### Phase 7: GUI Integration (Optional) +- Status: Not started +- Future work + +--- + +## Files Created/Modified + +### New Files +- `src/ghidra/scripts/bridge.py` - Persistent Python bridge server +- `src/ghidra/bridge.rs` - Rust bridge manager +- `src/ipc/mod.rs` - IPC module root +- `src/ipc/protocol.rs` - Typed protocol definitions +- `src/ipc/transport.rs` - Socket transport layer +- `src/ipc/client.rs` - Daemon client + +### Modified Files +- `Cargo.toml` - Added `interprocess` crate +- `src/main.rs` - Added `mod ipc` +- `src/ghidra/mod.rs` - Added `mod bridge`, `#[derive(Debug)]` on `GhidraClient` +- `src/ghidra/scripts.rs` - All scripts now have output markers +- `src/ghidra/headless.rs` - Marker-based JSON extraction + +--- + +## Remaining Work + +To complete the refactoring: + +1. **Wire up bridge to daemon** - Modify `daemon/mod.rs` to hold `GhidraBridge` +2. **Route commands through bridge** - Update `daemon/queue.rs` to use bridge instead of spawning headless +3. **Switch to IPC layer** - Replace TCP RPC with local socket IPC +4. **Remove one-shot execution** - Clean up legacy headless spawning code +5. **Manual testing** - Test with actual Ghidra installation + +--- + +## Build Status + +``` +✅ cargo check - PASSED +✅ cargo test - 30 passed, 1 pre-existing failure (test_parse_hex) +``` diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs new file mode 100644 index 0000000..415702e --- /dev/null +++ b/src/daemon/handler.rs @@ -0,0 +1,127 @@ +//! Command handler for processing IPC requests. +//! +//! Translates IPC commands into Ghidra bridge operations. + +use std::sync::Arc; + +use serde_json::json; +use tokio::sync::Mutex; +use tracing::{debug, info, warn}; + +use crate::ghidra::bridge::GhidraBridge; +use crate::ipc::protocol::{Command, Response}; + +/// Handle an IPC command. +pub async fn handle_command( + bridge: &Arc>>, + id: u64, + command: Command, +) -> Response { + match handle_command_inner(bridge, command).await { + Ok(result) => Response::success(id, result), + Err(e) => Response::error(id, e.to_string()), + } +} + +async fn handle_command_inner( + bridge: &Arc>>, + command: Command, +) -> anyhow::Result { + match command { + Command::Ping => { + Ok(json!({"status": "ok"})) + } + + Command::Status => { + let bridge_guard = bridge.lock().await; + let bridge_running = bridge_guard.as_ref().map(|b| b.is_running()).unwrap_or(false); + Ok(json!({ + "bridge_running": bridge_running, + })) + } + + Command::ClearCache => { + // TODO: Implement cache clearing + Ok(json!({"cleared": true})) + } + + Command::Shutdown => { + // Shutdown is handled at a higher level + Ok(json!({"status": "shutting_down"})) + } + + Command::ListFunctions { limit, filter } => { + execute_bridge_command(bridge, "list_functions", Some(json!({ + "limit": limit, + "filter": filter, + }))).await + } + + Command::Decompile { address } => { + execute_bridge_command(bridge, "decompile", Some(json!({ + "address": address, + }))).await + } + + Command::ListStrings { limit } => { + execute_bridge_command(bridge, "list_strings", Some(json!({ + "limit": limit, + }))).await + } + + Command::ListImports => { + execute_bridge_command(bridge, "list_imports", None).await + } + + Command::ListExports => { + execute_bridge_command(bridge, "list_exports", None).await + } + + Command::MemoryMap => { + execute_bridge_command(bridge, "memory_map", None).await + } + + Command::ProgramInfo => { + execute_bridge_command(bridge, "program_info", None).await + } + + Command::XRefsTo { address } => { + execute_bridge_command(bridge, "xrefs_to", Some(json!({ + "address": address, + }))).await + } + + Command::XRefsFrom { address } => { + execute_bridge_command(bridge, "xrefs_from", Some(json!({ + "address": address, + }))).await + } + } +} + +/// Execute a command on the Ghidra bridge. +async fn execute_bridge_command( + bridge: &Arc>>, + command: &str, + args: Option, +) -> anyhow::Result { + let mut bridge_guard = bridge.lock().await; + + let bridge = bridge_guard.as_mut() + .ok_or_else(|| anyhow::anyhow!("Bridge not initialized"))?; + + if !bridge.is_running() { + anyhow::bail!("Bridge is not running"); + } + + debug!("Executing bridge command: {}", command); + + let response = bridge.send_command::(command, args)?; + + if response.status == "success" { + Ok(response.data.unwrap_or(json!({}))) + } else { + let message = response.message.unwrap_or_else(|| "Unknown error".to_string()); + anyhow::bail!("{}", message) + } +} diff --git a/src/daemon/ipc_server.rs b/src/daemon/ipc_server.rs new file mode 100644 index 0000000..73502c7 --- /dev/null +++ b/src/daemon/ipc_server.rs @@ -0,0 +1,160 @@ +//! IPC server for daemon communication. +//! +//! Uses local sockets (Unix domain sockets / Windows named pipes) with +//! the new IPC layer instead of TCP. + +use std::sync::Arc; +use std::time::Instant; + +use interprocess::local_socket::traits::tokio::Listener as ListenerTrait; +use tokio::io::BufReader; +use tokio::sync::{broadcast, Mutex}; +use tracing::{debug, error, info}; + +use crate::ghidra::bridge::GhidraBridge; +use crate::ipc::protocol::{Command, Request, Response}; +use crate::ipc::transport::{self, platform::Listener}; + +use super::handler; + +/// IPC server state +pub struct IpcServer { + /// The Ghidra bridge instance + bridge: Arc>>, + /// Shutdown signal sender + shutdown_tx: broadcast::Sender<()>, + /// Server start time + started_at: Instant, +} + +impl IpcServer { + /// Create a new IPC server. + pub fn new( + bridge: Arc>>, + shutdown_tx: broadcast::Sender<()>, + ) -> Self { + Self { + bridge, + shutdown_tx, + started_at: Instant::now(), + } + } + + /// Handle a single client connection. + async fn handle_client( + &self, + stream: transport::platform::Stream, + ) -> anyhow::Result { + let (reader, mut writer) = tokio::io::split(stream); + let mut reader = BufReader::new(reader); + + loop { + // Read request with timeout + let request_data = tokio::select! { + result = transport::recv_message(&mut reader) => { + match result { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + debug!("Client disconnected"); + return Ok(false); + } + Err(e) => { + error!("Error reading request: {}", e); + return Ok(false); + } + } + } + _ = tokio::time::sleep(tokio::time::Duration::from_secs(300)) => { + debug!("Client timeout"); + return Ok(false); + } + }; + + // Parse request + let request: Request = match serde_json::from_slice(&request_data) { + Ok(req) => req, + Err(e) => { + error!("Invalid request: {}", e); + let response = Response::error(0, format!("Invalid request: {}", e)); + let json = serde_json::to_vec(&response)?; + transport::send_message(&mut writer, &json).await?; + continue; + } + }; + + debug!("Received command: {:?}", request.command); + + // Check for shutdown command + if matches!(request.command, Command::Shutdown) { + let response = Response::ok(request.id); + let json = serde_json::to_vec(&response)?; + transport::send_message(&mut writer, &json).await?; + return Ok(true); // Signal shutdown + } + + // Handle command + let response = handler::handle_command( + &self.bridge, + request.id, + request.command, + ).await; + + // Send response + let json = serde_json::to_vec(&response)?; + transport::send_message(&mut writer, &json).await?; + } + } +} + +/// Run the IPC server. +pub async fn run_ipc_server( + bridge: Arc>>, + shutdown_tx: broadcast::Sender<()>, +) -> anyhow::Result<()> { + // Create the IPC listener + let listener = transport::create_listener().await + .map_err(|e| anyhow::anyhow!("Failed to create IPC listener: {}", e))?; + + info!("IPC server listening on {}", transport::socket_name()); + + let server = Arc::new(IpcServer::new(bridge, shutdown_tx.clone())); + let mut shutdown_rx = shutdown_tx.subscribe(); + + loop { + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok(stream) => { + info!("Accepted IPC connection"); + let server = server.clone(); + let shutdown_tx = shutdown_tx.clone(); + tokio::spawn(async move { + match server.handle_client(stream).await { + Ok(should_shutdown) if should_shutdown => { + info!("Shutdown requested via IPC"); + let _ = shutdown_tx.send(()); + } + Ok(_) => {} + Err(e) => { + error!("Connection error: {}", e); + } + } + }); + } + Err(e) => { + error!("Accept error: {}", e); + } + } + } + _ = shutdown_rx.recv() => { + info!("IPC server shutting down"); + break; + } + } + } + + // Clean up socket + transport::remove_socket().ok(); + + Ok(()) +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 771f979..697ffb6 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,23 +1,23 @@ //! Daemon core logic. //! //! The daemon is the main runtime that: -//! - Loads and maintains project state in memory -//! - Queues commands to prevent Ghidra conflicts -//! - Serves RPC requests from clients +//! - Manages a persistent Ghidra bridge process +//! - Serves commands via local socket IPC //! - Handles graceful shutdown use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, Mutex}; use tracing::{error, info, warn}; use crate::daemon::process::{write_daemon_info, remove_lock_file, DaemonInfo, get_data_dir}; -use crate::daemon::queue::CommandQueue; -use crate::daemon::state::DaemonState; +use crate::ghidra::bridge::GhidraBridge; pub mod cache; +pub mod handler; +pub mod ipc_server; pub mod process; pub mod queue; pub mod rpc; @@ -27,15 +27,17 @@ pub mod state; pub struct DaemonConfig { /// Path to the project directory pub project_path: PathBuf, - /// RPC port (None = auto-select) + /// RPC port (None = auto-select) - kept for backwards compatibility pub port: Option, /// Ghidra installation directory pub ghidra_install_dir: Option, /// Log file path pub log_file: PathBuf, + /// Program name to load + pub program_name: Option, } -/// Run the daemon. +/// Run the daemon with the new bridge architecture. pub async fn run(config: DaemonConfig) -> Result<()> { info!("Starting Ghidra daemon"); info!("Project: {}", config.project_path.display()); @@ -44,52 +46,60 @@ pub async fn run(config: DaemonConfig) -> Result<()> { let data_dir = get_data_dir() .context("Failed to get data directory")?; - // Load project state - let _state = Arc::new( - DaemonState::load(&config.project_path, config.ghidra_install_dir.as_deref()) - .context("Failed to load project state")? - ); - - info!("Project state loaded successfully"); - - // Create command queue - let queue = Arc::new(CommandQueue::new(config.project_path.clone())); - // Create shutdown channel let (shutdown_tx, _shutdown_rx) = broadcast::channel::<()>(1); - // Start RPC server - let port = self::rpc::run_server(queue.clone(), config.port, shutdown_tx.clone()).await - .context("Failed to start RPC server")?; + // Initialize the Ghidra bridge (starts as None until we have a program) + let bridge: Arc>> = Arc::new(Mutex::new(None)); - info!("RPC server listening on port {}", port); + // If we have Ghidra install dir and program name, start the bridge + if let (Some(ghidra_dir), Some(program_name)) = (&config.ghidra_install_dir, &config.program_name) { + info!("Starting Ghidra bridge for program: {}", program_name); + + let project_name = config.project_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("project") + .to_string(); + + let mut new_bridge = GhidraBridge::new( + ghidra_dir.clone(), + config.project_path.clone(), + project_name, + program_name.clone(), + ); + + if let Err(e) = new_bridge.start() { + warn!("Failed to start Ghidra bridge: {}. Will operate without persistent connection.", e); + } else { + info!("Ghidra bridge started successfully"); + let mut bridge_guard = bridge.lock().await; + *bridge_guard = Some(new_bridge); + } + } else { + info!("No program specified, bridge will be started on first command"); + } - // Write lock file - let daemon_info = DaemonInfo::new(&config.project_path, port, &config.log_file); + // Write lock file with a placeholder port (IPC doesn't use TCP ports) + let placeholder_port = config.port.unwrap_or(0); + let daemon_info = DaemonInfo::new(&config.project_path, placeholder_port, &config.log_file); write_daemon_info(&data_dir, &config.project_path, &daemon_info) .context("Failed to write lock file")?; - // Start cache cleanup task - let cache_cleanup_handle = { - let queue = queue.clone(); - let shutdown_tx = shutdown_tx.clone(); - tokio::spawn(async move { - let mut shutdown_rx = shutdown_tx.subscribe(); - loop { - tokio::select! { - _ = tokio::time::sleep(tokio::time::Duration::from_secs(300)) => { - // Cleanup cache every 5 minutes - // Note: This would need access to the cache - // For now, we'll skip this as the cache is internal to the queue - } - _ = shutdown_rx.recv() => { - info!("Cache cleanup task stopping"); - break; - } - } - } - }) - }; + // Start IPC server task + let ipc_bridge = bridge.clone(); + let ipc_shutdown_tx = shutdown_tx.clone(); + let ipc_handle = tokio::spawn(async move { + if let Err(e) = ipc_server::run_ipc_server(ipc_bridge, ipc_shutdown_tx).await { + error!("IPC server error: {}", e); + } + }); + + // Also start the legacy RPC server for backwards compatibility + let queue = Arc::new(queue::CommandQueue::new(config.project_path.clone())); + let rpc_port = rpc::run_server(queue.clone(), config.port, shutdown_tx.clone()).await + .context("Failed to start RPC server")?; + info!("Legacy RPC server listening on port {} (for backwards compatibility)", rpc_port); // Wait for shutdown signal let shutdown_reason = wait_for_shutdown(shutdown_tx.clone()).await; @@ -99,13 +109,24 @@ pub async fn run(config: DaemonConfig) -> Result<()> { // Clean up shutdown_tx.send(()).ok(); // Signal all tasks to stop - // Wait for cache cleanup to stop (with timeout) + // Stop the bridge + { + let mut bridge_guard = bridge.lock().await; + if let Some(mut b) = bridge_guard.take() { + info!("Stopping Ghidra bridge..."); + if let Err(e) = b.stop() { + error!("Error stopping bridge: {}", e); + } + } + } + + // Wait for IPC server to stop (with timeout) tokio::select! { - _ = cache_cleanup_handle => { - info!("Cache cleanup task stopped"); + _ = ipc_handle => { + info!("IPC server stopped"); } _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => { - warn!("Cache cleanup task did not stop in time"); + warn!("IPC server did not stop in time"); } } @@ -185,6 +206,7 @@ mod tests { port: Some(17700), ghidra_install_dir: None, log_file: PathBuf::from("/test/logs/daemon.log"), + program_name: None, }; assert_eq!(config.port, Some(17700)); diff --git a/src/ghidra/bridge.rs b/src/ghidra/bridge.rs new file mode 100644 index 0000000..cce48c3 --- /dev/null +++ b/src/ghidra/bridge.rs @@ -0,0 +1,343 @@ +//! 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::{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, +} + +/// 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, + /// Program name + program_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, + program_name: String, + ) -> Self { + Self { + child: None, + stream: None, + port: DEFAULT_BRIDGE_PORT, + project_name, + program_name, + ghidra_install_dir, + project_dir, + running: Arc::new(AtomicBool::new(false)), + } + } + + /// Start the bridge. + pub fn start(&mut self) -> Result<()> { + if self.running.load(Ordering::SeqCst) { + return Ok(()); + } + + info!("Starting Ghidra bridge..."); + + // Find analyzeHeadless script + let headless_script = self.find_headless_script()?; + + // Get bridge script path + let bridge_script = self.get_bridge_script_path()?; + + // Build command + let mut cmd = Command::new(&headless_script); + cmd.arg(&self.project_dir) + .arg(&self.project_name) + .arg("-process") + .arg(&self.program_name) + .arg("-noanalysis") + .arg("-scriptPath") + .arg(bridge_script.parent().unwrap()) + .arg("-postScript") + .arg("bridge.py") + .arg(self.port.to_string()) + .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; + for line in reader.lines() { + let line = line?; + debug!("Ghidra: {}", line); + + // 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 + match child.try_wait() { + Ok(Some(status)) => { + anyhow::bail!("Ghidra process exited with status: {}", status); + } + Ok(None) => { + anyhow::bail!("Ghidra bridge did not send ready signal"); + } + 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. + 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 + writeln!(stream, "{}", request_json)?; + stream.flush()?; + + // Read response + let mut reader = BufReader::new(stream.try_clone()?); + let mut response_line = String::new(); + reader.read_line(&mut response_line)?; + + 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) + } + + /// Get the embedded bridge script path, writing it to disk if needed. + 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)?; + + let script_path = scripts_dir.join("bridge.py"); + + // Always write the latest version of the script + let script_content = include_str!("scripts/bridge.py"); + std::fs::write(&script_path, script_content)?; + + Ok(script_path) + } + + /// Find the analyzeHeadless script. + fn find_headless_script(&self) -> Result { + #[cfg(unix)] + let script_name = "analyzeHeadless"; + #[cfg(windows)] + let script_name = "analyzeHeadless.bat"; + + let support_dir = self.ghidra_install_dir.join("support"); + let script_path = support_dir.join(script_name); + + if script_path.exists() { + Ok(script_path) + } else { + anyhow::bail!( + "analyzeHeadless not found at: {}", + script_path.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/ghidra/headless.rs b/src/ghidra/headless.rs index 5e646bc..fdeed66 100644 --- a/src/ghidra/headless.rs +++ b/src/ghidra/headless.rs @@ -78,43 +78,22 @@ impl<'a> HeadlessExecutor<'a> { } fn extract_json_from_output(&self, output: &str) -> Result { - // Find the JSON output in the Ghidra output - // Look for lines starting with { or [ - let lines: Vec<&str> = output.lines().collect(); - - let mut json_start = None; - let mut json_end = None; - let mut brace_count = 0; - - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - - if json_start.is_none() && (trimmed.starts_with('{') || trimmed.starts_with('[')) { - json_start = Some(i); - brace_count = trimmed.chars().filter(|&c| c == '{' || c == '[').count() as i32; - brace_count -= trimmed.chars().filter(|&c| c == '}' || c == ']').count() as i32; - - if brace_count == 0 { - json_end = Some(i); - break; - } - } else if json_start.is_some() { - brace_count += trimmed.chars().filter(|&c| c == '{' || c == '[').count() as i32; - brace_count -= trimmed.chars().filter(|&c| c == '}' || c == ']').count() as i32; - - if brace_count == 0 { - json_end = Some(i); - break; - } - } - } - - if let (Some(start), Some(end)) = (json_start, json_end) { - let json_lines = &lines[start..=end]; - Ok(json_lines.join("\n")) - } else { - Err(GhidraError::ExecutionFailed("Could not find JSON in script output".to_string())) - } + // Use marker-based extraction for reliable JSON parsing + const START_MARKER: &str = "---GHIDRA_CLI_START---"; + const END_MARKER: &str = "---GHIDRA_CLI_END---"; + + let start = output.find(START_MARKER) + .ok_or_else(|| GhidraError::ExecutionFailed( + "Missing start marker in script output".to_string() + ))? + + START_MARKER.len(); + + let end = output.find(END_MARKER) + .ok_or_else(|| GhidraError::ExecutionFailed( + "Missing end marker in script output".to_string() + ))?; + + Ok(output[start..end].trim().to_string()) } fn get_scripts_dir(&self) -> Result { diff --git a/src/ghidra/mod.rs b/src/ghidra/mod.rs index 51814f9..aaade6d 100644 --- a/src/ghidra/mod.rs +++ b/src/ghidra/mod.rs @@ -1,3 +1,4 @@ +pub mod bridge; pub mod headless; pub mod data; pub mod scripts; @@ -8,6 +9,7 @@ use std::process::Command; use crate::config::Config; use crate::error::{GhidraError, Result}; +#[derive(Debug)] pub struct GhidraClient { config: Config, install_dir: PathBuf, diff --git a/src/ghidra/scripts.rs b/src/ghidra/scripts.rs index a7ac11a..f7e6c5a 100644 --- a/src/ghidra/scripts.rs +++ b/src/ghidra/scripts.rs @@ -52,7 +52,9 @@ for func in function_manager.getFunctions(True): functions.append(func_data) +print("---GHIDRA_CLI_START---") print(json.dumps(functions, indent=2)) +print("---GHIDRA_CLI_END---") "# } @@ -97,9 +99,13 @@ if results.decompileCompleted(): "code": code } + print("---GHIDRA_CLI_START---") print(json.dumps(result, indent=2)) + print("---GHIDRA_CLI_END---") else: + print("---GHIDRA_CLI_START---") print(json.dumps({"error": "Decompilation failed"})) + print("---GHIDRA_CLI_END---") "# } @@ -140,7 +146,9 @@ while data_iterator.hasNext(): # Skip strings that cause encoding issues pass +print("---GHIDRA_CLI_START---") print(json.dumps(strings, indent=2)) +print("---GHIDRA_CLI_END---") "# } @@ -167,7 +175,9 @@ for symbol in symbol_table.getExternalSymbols(): } imports.append(import_data) +print("---GHIDRA_CLI_START---") print(json.dumps(imports, indent=2)) +print("---GHIDRA_CLI_END---") "# } @@ -189,7 +199,9 @@ for symbol in symbol_table.getSymbolIterator(): } exports.append(export_data) +print("---GHIDRA_CLI_START---") print(json.dumps(exports, indent=2)) +print("---GHIDRA_CLI_END---") "# } @@ -226,7 +238,9 @@ for block in memory.getBlocks(): block_data["permissions"] = perms blocks.append(block_data) +print("---GHIDRA_CLI_START---") print(json.dumps(blocks, indent=2)) +print("---GHIDRA_CLI_END---") "# } @@ -259,7 +273,9 @@ for instruction in listing.getInstructions(True): info["instruction_count"] = instruction_count +print("---GHIDRA_CLI_START---") print(json.dumps(info, indent=2)) +print("---GHIDRA_CLI_END---") "# } @@ -295,7 +311,9 @@ for ref in refs: } xrefs.append(xref_data) +print("---GHIDRA_CLI_START---") print(json.dumps(xrefs, indent=2)) +print("---GHIDRA_CLI_END---") "# } diff --git a/src/ghidra/scripts/bridge.py b/src/ghidra/scripts/bridge.py new file mode 100644 index 0000000..e2a98b8 --- /dev/null +++ b/src/ghidra/scripts/bridge.py @@ -0,0 +1,387 @@ +# Ghidra CLI Bridge Script +# @category Bridge +# @keybinding +# @menupath Tools.Start CLI Bridge +# @toolbar +# +# This script runs a persistent TCP server inside Ghidra to serve CLI commands. +# It keeps Ghidra loaded in memory for fast command execution. + +import socket +import json +import threading +from ghidra.util.task import ConsoleTaskMonitor +from ghidra.app.decompiler import DecompInterface + +# Default bridge port +BRIDGE_PORT = 18700 + +# --- Command Handlers --- + +def handle_ping(args): + """Health check.""" + return {"message": "pong"} + +def handle_program_info(args): + """Get current program information.""" + if currentProgram is None: + return {"error": "No program loaded"} + + info = { + "name": currentProgram.getName(), + "executable_path": currentProgram.getExecutablePath(), + "executable_format": currentProgram.getExecutableFormat(), + "compiler": currentProgram.getCompiler() if currentProgram.getCompiler() else None, + "language": str(currentProgram.getLanguage()), + "image_base": str(currentProgram.getImageBase()), + "min_address": str(currentProgram.getMinAddress()), + "max_address": str(currentProgram.getMaxAddress()) + } + + function_manager = currentProgram.getFunctionManager() + info["function_count"] = function_manager.getFunctionCount() + + return info + +def handle_list_functions(args): + """List all functions in the program.""" + if currentProgram is None: + return {"error": "No program loaded"} + + limit = args.get("limit") + name_filter = args.get("filter") + + functions = [] + function_manager = currentProgram.getFunctionManager() + count = 0 + + for func in function_manager.getFunctions(True): + if limit and count >= limit: + break + + name = func.getName() + if name_filter and name_filter.lower() not in name.lower(): + continue + + entry = func.getEntryPoint() + body = func.getBody() + + func_data = { + "name": name, + "address": str(entry), + "size": body.getNumAddresses(), + "entry_point": str(entry), + "signature": func.getPrototypeString(False, False) if func.getSignature() else None, + "calling_convention": func.getCallingConventionName(), + "comment": func.getComment() + } + + functions.append(func_data) + count += 1 + + return {"functions": functions, "count": len(functions)} + +def handle_decompile(args): + """Decompile a function at the given address.""" + if currentProgram is None: + return {"error": "No program loaded"} + + addr_str = args.get("address") + if not addr_str: + return {"error": "No address provided"} + + addr = currentProgram.getAddressFactory().getAddress(addr_str) + if addr is None: + return {"error": "Invalid address: " + addr_str} + + function_manager = currentProgram.getFunctionManager() + func = function_manager.getFunctionContaining(addr) + + if not func: + return {"error": "No function at address " + addr_str} + + decompiler = DecompInterface() + decompiler.openProgram(currentProgram) + + monitor = ConsoleTaskMonitor() + results = decompiler.decompileFunction(func, 30, monitor) + + if results.decompileCompleted(): + code = results.getDecompiledFunction().getC() + return { + "name": func.getName(), + "address": str(func.getEntryPoint()), + "signature": func.getPrototypeString(False, False), + "code": code + } + else: + return {"error": "Decompilation failed"} + +def handle_list_strings(args): + """List all strings in the program.""" + if currentProgram is None: + return {"error": "No program loaded"} + + limit = args.get("limit") + + strings = [] + listing = currentProgram.getListing() + data_iterator = listing.getDefinedData(True) + count = 0 + + while data_iterator.hasNext(): + if limit and count >= limit: + break + + data = data_iterator.next() + if data.hasStringValue(): + try: + string_val = str(data.getValue()) + string_data = { + "address": str(data.getAddress()), + "value": string_val, + "length": len(string_val) + } + strings.append(string_data) + count += 1 + except Exception: + pass + + return {"strings": strings, "count": len(strings)} + +def handle_list_imports(args): + """List all imports in the program.""" + if currentProgram is None: + return {"error": "No program loaded"} + + imports = [] + symbol_table = currentProgram.getSymbolTable() + external_manager = currentProgram.getExternalManager() + + for symbol in symbol_table.getExternalSymbols(): + external_location = external_manager.getExternalLocation(symbol) + + if external_location: + import_data = { + "name": symbol.getName(), + "address": str(symbol.getAddress()), + "library": external_location.getLibraryName() + } + imports.append(import_data) + + return {"imports": imports, "count": len(imports)} + +def handle_list_exports(args): + """List all exports in the program.""" + if currentProgram is None: + return {"error": "No program loaded"} + + exports = [] + symbol_table = currentProgram.getSymbolTable() + + for symbol in symbol_table.getSymbolIterator(): + if symbol.isExternalEntryPoint(): + export_data = { + "name": symbol.getName(), + "address": str(symbol.getAddress()) + } + exports.append(export_data) + + return {"exports": exports, "count": len(exports)} + +def handle_memory_map(args): + """Get memory map.""" + if currentProgram is None: + return {"error": "No program loaded"} + + blocks = [] + memory = currentProgram.getMemory() + + for block in memory.getBlocks(): + perms = "" + if block.isRead(): + perms += "r" + if block.isWrite(): + perms += "w" + if block.isExecute(): + perms += "x" + + block_data = { + "name": block.getName(), + "start": str(block.getStart()), + "end": str(block.getEnd()), + "size": block.getSize(), + "permissions": perms, + "is_initialized": block.isInitialized(), + "is_loaded": block.isLoaded() + } + blocks.append(block_data) + + return {"blocks": blocks, "count": len(blocks)} + +def handle_xrefs_to(args): + """Get cross-references to an address.""" + if currentProgram is None: + return {"error": "No program loaded"} + + addr_str = args.get("address") + if not addr_str: + return {"error": "No address provided"} + + addr = currentProgram.getAddressFactory().getAddress(addr_str) + if addr is None: + return {"error": "Invalid address: " + addr_str} + + xrefs = [] + refs = currentProgram.getReferenceManager().getReferencesTo(addr) + function_manager = currentProgram.getFunctionManager() + + for ref in refs: + from_addr = ref.getFromAddress() + from_func = function_manager.getFunctionContaining(from_addr) + to_func = function_manager.getFunctionContaining(addr) + + xref_data = { + "from": str(from_addr), + "to": str(addr), + "ref_type": str(ref.getReferenceType()), + "from_function": from_func.getName() if from_func else None, + "to_function": to_func.getName() if to_func else None + } + xrefs.append(xref_data) + + return {"xrefs": xrefs, "count": len(xrefs)} + +def handle_xrefs_from(args): + """Get cross-references from an address.""" + if currentProgram is None: + return {"error": "No program loaded"} + + addr_str = args.get("address") + if not addr_str: + return {"error": "No address provided"} + + addr = currentProgram.getAddressFactory().getAddress(addr_str) + if addr is None: + return {"error": "Invalid address: " + addr_str} + + xrefs = [] + refs = currentProgram.getReferenceManager().getReferencesFrom(addr) + function_manager = currentProgram.getFunctionManager() + + for ref in refs: + to_addr = ref.getToAddress() + from_func = function_manager.getFunctionContaining(addr) + to_func = function_manager.getFunctionContaining(to_addr) + + xref_data = { + "from": str(addr), + "to": str(to_addr), + "ref_type": str(ref.getReferenceType()), + "from_function": from_func.getName() if from_func else None, + "to_function": to_func.getName() if to_func else None + } + xrefs.append(xref_data) + + return {"xrefs": xrefs, "count": len(xrefs)} + +# --- Command Router --- + +COMMANDS = { + "ping": handle_ping, + "program_info": handle_program_info, + "list_functions": handle_list_functions, + "decompile": handle_decompile, + "list_strings": handle_list_strings, + "list_imports": handle_list_imports, + "list_exports": handle_list_exports, + "memory_map": handle_memory_map, + "xrefs_to": handle_xrefs_to, + "xrefs_from": handle_xrefs_from, +} + +# --- Server Logic --- + +def handle_request(line): + """Parse and handle a single JSON request.""" + try: + req = json.loads(line) + cmd = req.get("command") + args = req.get("args", {}) + + if cmd == "shutdown": + return {"status": "shutdown"}, True + + if cmd in COMMANDS: + result = COMMANDS[cmd](args) + if "error" in result: + return {"status": "error", "message": result["error"]}, False + return {"status": "success", "data": result}, False + else: + return {"status": "error", "message": "Unknown command: " + str(cmd)}, False + + except Exception as e: + return {"status": "error", "message": str(e)}, False + +def start_server(port=BRIDGE_PORT): + """Start the bridge server.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('127.0.0.1', port)) + s.listen(1) + + # Signal ready to the parent process + print("---GHIDRA_CLI_START---") + print(json.dumps({"status": "ready", "port": port})) + print("---GHIDRA_CLI_END---") + + running = True + while running: + try: + conn, addr = s.accept() + f = conn.makefile('r') + out = conn.makefile('w') + + try: + while True: + line = f.readline() + if not line: + break + + response, should_shutdown = handle_request(line.strip()) + out.write(json.dumps(response) + "\n") + out.flush() + + if should_shutdown: + running = False + break + finally: + f.close() + out.close() + conn.close() + + except Exception as e: + print("Bridge error: " + str(e)) + + s.close() + +# --- Entry Point --- + +if __name__ == "__main__" or True: # Also runs when sourced by Ghidra + # Determine port from args if provided + port = BRIDGE_PORT + if 'args' in dir() and len(args) > 0: + try: + port = int(args[0]) + except: + pass + + # If running in GUI, run in background thread to not freeze UI + if 'isRunningHeadless' in dir() and isRunningHeadless(): + start_server(port) + else: + # GUI mode - run in background thread + t = threading.Thread(target=start_server, args=(port,)) + t.daemon = True + t.start() + print("Bridge started in background on port " + str(port)) diff --git a/src/ipc/client.rs b/src/ipc/client.rs new file mode 100644 index 0000000..2f94660 --- /dev/null +++ b/src/ipc/client.rs @@ -0,0 +1,153 @@ +//! CLI-side IPC client for communicating with the daemon. + +use anyhow::{Context, Result}; +use tokio::io::{ReadHalf, WriteHalf}; + +use super::protocol::{Command, Request, Response}; +use super::transport::{self, Stream}; + +/// Client for communicating with the Ghidra daemon. +pub struct DaemonClient { + reader: ReadHalf, + writer: WriteHalf, + next_id: u64, +} + +impl DaemonClient { + /// Connect to the running daemon. + pub async fn connect() -> Result { + let stream = transport::connect().await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound + || e.kind() == std::io::ErrorKind::ConnectionRefused + { + anyhow::anyhow!("Daemon not running") + } else { + anyhow::anyhow!("Failed to connect to daemon: {}", e) + } + })?; + + let (reader, writer) = tokio::io::split(stream); + + Ok(Self { + reader, + writer, + next_id: 1, + }) + } + + /// Send a command and wait for the response. + pub async fn send_command(&mut self, command: Command) -> Result { + let id = self.next_id; + self.next_id += 1; + + let request = Request::new(id, command); + let json = serde_json::to_vec(&request).context("Failed to serialize request")?; + + transport::send_message(&mut self.writer, &json) + .await + .context("Failed to send message to daemon")?; + + let response_data = transport::recv_message(&mut self.reader) + .await + .context("Failed to receive message from daemon")?; + + let response: Response = + serde_json::from_slice(&response_data).context("Failed to parse daemon response")?; + + if response.id != id { + anyhow::bail!( + "Response ID mismatch: expected {}, got {}", + id, + response.id + ); + } + + if response.success { + Ok(response.result.unwrap_or(serde_json::json!({}))) + } else { + let error = response.error.unwrap_or_else(|| "Unknown error".to_string()); + anyhow::bail!("{}", error) + } + } + + /// Check if daemon is responding. + pub async fn ping(&mut self) -> Result { + match self.send_command(Command::Ping).await { + Ok(_) => Ok(true), + Err(e) if e.to_string().contains("not running") => Ok(false), + Err(e) => Err(e), + } + } + + /// Get daemon status. + pub async fn status(&mut self) -> Result { + self.send_command(Command::Status).await + } + + /// Shutdown the daemon. + pub async fn shutdown(&mut self) -> Result<()> { + self.send_command(Command::Shutdown).await?; + Ok(()) + } + + /// Clear the result cache. + pub async fn clear_cache(&mut self) -> Result<()> { + self.send_command(Command::ClearCache).await?; + Ok(()) + } + + /// List functions. + pub async fn list_functions( + &mut self, + limit: Option, + filter: Option, + ) -> Result { + self.send_command(Command::ListFunctions { limit, filter }) + .await + } + + /// Decompile a function. + pub async fn decompile(&mut self, address: String) -> Result { + self.send_command(Command::Decompile { address }).await + } + + /// List strings. + pub async fn list_strings(&mut self, limit: Option) -> Result { + self.send_command(Command::ListStrings { limit }).await + } + + /// List imports. + pub async fn list_imports(&mut self) -> Result { + self.send_command(Command::ListImports).await + } + + /// List exports. + pub async fn list_exports(&mut self) -> Result { + self.send_command(Command::ListExports).await + } + + /// Get memory map. + pub async fn memory_map(&mut self) -> Result { + self.send_command(Command::MemoryMap).await + } + + /// Get program info. + pub async fn program_info(&mut self) -> Result { + self.send_command(Command::ProgramInfo).await + } + + /// Get cross-references to an address. + pub async fn xrefs_to(&mut self, address: String) -> Result { + self.send_command(Command::XRefsTo { address }).await + } + + /// Get cross-references from an address. + pub async fn xrefs_from(&mut self, address: String) -> Result { + self.send_command(Command::XRefsFrom { address }).await + } +} + +/// Check if daemon is running (without establishing a full connection). +pub fn daemon_available() -> bool { + transport::socket_exists() +} diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs new file mode 100644 index 0000000..e0a967b --- /dev/null +++ b/src/ipc/mod.rs @@ -0,0 +1,15 @@ +//! IPC module for CLI-to-daemon communication. +//! +//! This module provides cross-platform IPC using local sockets: +//! - Unix domain sockets on Linux/macOS +//! - Named pipes on Windows +//! +//! Follows the pattern from debugger-cli for reliable length-prefixed +//! JSON message framing. + +pub mod client; +pub mod protocol; +pub mod transport; + +pub use client::DaemonClient; +pub use protocol::{Command, Request, Response}; diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs new file mode 100644 index 0000000..6a37deb --- /dev/null +++ b/src/ipc/protocol.rs @@ -0,0 +1,166 @@ +//! IPC protocol message types. +//! +//! Defines the request/response format for CLI ↔ daemon communication. +//! Uses a typed command enum (not wrapping CLI Commands) for clean separation. + +use serde::{Deserialize, Serialize}; + +/// IPC request from CLI to daemon. +#[derive(Debug, Serialize, Deserialize)] +pub struct Request { + /// Request ID for matching responses + pub id: u64, + /// The command to execute + pub command: Command, +} + +impl Request { + /// Create a new request with the given ID and command. + pub fn new(id: u64, command: Command) -> Self { + Self { id, command } + } +} + +/// IPC response from daemon to CLI. +#[derive(Debug, Serialize, Deserialize)] +pub struct Response { + /// Request ID this response corresponds to + pub id: u64, + /// Whether the command succeeded + pub success: bool, + /// Result data on success + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Error message on failure + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Response { + /// Create a success response. + pub fn success(id: u64, result: serde_json::Value) -> Self { + Self { + id, + success: true, + result: Some(result), + error: None, + } + } + + /// Create an error response. + pub fn error(id: u64, message: impl Into) -> Self { + Self { + id, + success: false, + result: None, + error: Some(message.into()), + } + } + + /// Create a success response with no data. + pub fn ok(id: u64) -> Self { + Self { + id, + success: true, + result: Some(serde_json::json!({})), + error: None, + } + } +} + +/// Commands that can be sent from CLI to daemon. +/// +/// These are separate from CLI Commands to decouple IPC from argument parsing. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Command { + // === Data Queries === + /// List functions in the program + ListFunctions { + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + filter: Option, + }, + + /// Decompile a function at address + Decompile { address: String }, + + /// List strings in the program + ListStrings { + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + }, + + /// List imports + ListImports, + + /// List exports + ListExports, + + /// Get memory map + MemoryMap, + + /// Get program info + ProgramInfo, + + /// Get cross-references to an address + XRefsTo { address: String }, + + /// Get cross-references from an address + XRefsFrom { address: String }, + + // === Session Management === + /// Health check + Ping, + + /// Get daemon status + Status, + + /// Clear result cache + ClearCache, + + /// Shutdown the daemon + Shutdown, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_request_serialization() { + let request = Request::new(1, Command::Ping); + let json = serde_json::to_string(&request).unwrap(); + let deserialized: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, 1); + assert!(matches!(deserialized.command, Command::Ping)); + } + + #[test] + fn test_response_success() { + let response = Response::success(1, serde_json::json!({"count": 42})); + assert!(response.success); + assert_eq!(response.id, 1); + assert!(response.result.is_some()); + } + + #[test] + fn test_response_error() { + let response = Response::error(1, "Something went wrong"); + assert!(!response.success); + assert_eq!(response.error.as_ref().unwrap(), "Something went wrong"); + } + + #[test] + fn test_command_serialization() { + let cmd = Command::ListFunctions { + limit: Some(100), + filter: Some("main".to_string()), + }; + let json = serde_json::to_string(&cmd).unwrap(); + assert!(json.contains("list_functions")); + assert!(json.contains("100")); + assert!(json.contains("main")); + } +} diff --git a/src/ipc/transport.rs b/src/ipc/transport.rs new file mode 100644 index 0000000..6a40d5b --- /dev/null +++ b/src/ipc/transport.rs @@ -0,0 +1,203 @@ +//! Cross-platform IPC transport layer. +//! +//! Abstracts Unix domain sockets (Unix/macOS) and named pipes (Windows) +//! using the interprocess crate. Uses length-prefixed message framing. + +use std::io; +use std::path::PathBuf; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// Maximum message size (10 MB) +const MAX_MESSAGE_SIZE: u32 = 10 * 1024 * 1024; + +/// Socket name for the daemon +const SOCKET_NAME: &str = "ghidra-cli.sock"; + +// Platform-specific imports and type aliases +#[cfg(unix)] +pub mod platform { + pub use interprocess::local_socket::tokio::{prelude::*, Listener, Stream}; + pub use interprocess::local_socket::{GenericFilePath, ListenerOptions}; +} + +#[cfg(windows)] +pub mod platform { + pub use interprocess::local_socket::tokio::{prelude::*, Listener, Stream}; + pub use interprocess::local_socket::{GenericNamespaced, ListenerOptions}; +} + +pub use platform::*; + +/// Get the socket directory path. +fn socket_dir() -> io::Result { + #[cfg(unix)] + { + // Use XDG runtime dir or /tmp + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + let dir = runtime_dir.join("ghidra-cli"); + Ok(dir) + } + + #[cfg(windows)] + { + // Windows named pipes don't need a directory + Ok(PathBuf::new()) + } +} + +/// Get the socket path. +pub fn socket_path() -> io::Result { + let dir = socket_dir()?; + Ok(dir.join(SOCKET_NAME)) +} + +/// Get the socket name for interprocess. +pub fn socket_name() -> String { + #[cfg(unix)] + { + socket_path() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| format!("/tmp/ghidra-cli/{}", SOCKET_NAME)) + } + + #[cfg(windows)] + { + // Windows uses named pipe namespace + format!("ghidra-cli-{}", std::process::id()) + } +} + +/// Ensure the socket directory exists. +pub fn ensure_socket_dir() -> io::Result<()> { + #[cfg(unix)] + { + let dir = socket_dir()?; + if !dir.exists() { + std::fs::create_dir_all(&dir)?; + } + Ok(()) + } + + #[cfg(windows)] + { + Ok(()) + } +} + +/// Remove the socket file if it exists. +pub fn remove_socket() -> io::Result<()> { + #[cfg(unix)] + { + let path = socket_path()?; + if path.exists() { + std::fs::remove_file(&path)?; + } + Ok(()) + } + + #[cfg(windows)] + { + Ok(()) + } +} + +/// Check if the socket exists. +pub fn socket_exists() -> bool { + #[cfg(unix)] + { + socket_path().map(|p| p.exists()).unwrap_or(false) + } + + #[cfg(windows)] + { + // On Windows, we can't easily check if a named pipe exists + // We'll rely on connection attempts instead + true + } +} + +/// Create a listener for incoming IPC connections. +pub async fn create_listener() -> io::Result { + // Ensure socket directory exists and clean up stale socket + ensure_socket_dir()?; + remove_socket()?; + + let name = socket_name(); + + #[cfg(unix)] + let listener = { + let name = name.to_fs_name::()?; + ListenerOptions::new().name(name).create_tokio()? + }; + + #[cfg(windows)] + let listener = { + let name = name.to_ns_name::()?; + ListenerOptions::new().name(name).create_tokio()? + }; + + // Set socket permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = socket_path()?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(listener) +} + +/// Connect to the daemon's IPC socket. +pub async fn connect() -> io::Result { + let name = socket_name(); + + #[cfg(unix)] + let stream = { + let name = name.to_fs_name::()?; + Stream::connect(name).await? + }; + + #[cfg(windows)] + let stream = { + let name = name.to_ns_name::()?; + Stream::connect(name).await? + }; + + Ok(stream) +} + +/// Send a length-prefixed message. +pub async fn send_message(writer: &mut W, data: &[u8]) -> io::Result<()> { + if data.len() > MAX_MESSAGE_SIZE as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Message too large", + )); + } + + let len = data.len() as u32; + writer.write_all(&len.to_le_bytes()).await?; + writer.write_all(data).await?; + writer.flush().await?; + Ok(()) +} + +/// Receive a length-prefixed message. +pub async fn recv_message(reader: &mut R) -> io::Result> { + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf).await?; + let len = u32::from_le_bytes(len_buf); + + if len > MAX_MESSAGE_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Message too large: {} bytes", len), + )); + } + + let mut data = vec![0u8; len as usize]; + reader.read_exact(&mut data).await?; + Ok(data) +} diff --git a/src/main.rs b/src/main.rs index 5e7e4da..4a816df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod error; mod filter; mod format; mod ghidra; +mod ipc; mod query; use clap::Parser; @@ -177,6 +178,7 @@ async fn handle_daemon_start(project: Option, port: Option, foregro port, ghidra_install_dir: config.ghidra_install_dir.map(PathBuf::from), log_file, + program_name: config.default_program.clone(), }; if foreground {