refactoring to use python bridge and a persistent ghidra

This commit is contained in:
Alexander Kiselev
2026-01-20 16:15:02 -08:00
parent 6b5097463c
commit 35a864bd2f
16 changed files with 1801 additions and 87 deletions
Generated
+34
View File
@@ -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"
+3
View File
@@ -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"] }
+100
View File
@@ -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<T>` 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<T>` 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)
```
+127
View File
@@ -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<Mutex<Option<GhidraBridge>>>,
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<Mutex<Option<GhidraBridge>>>,
command: Command,
) -> anyhow::Result<serde_json::Value> {
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<Mutex<Option<GhidraBridge>>>,
command: &str,
args: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
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::<serde_json::Value>(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)
}
}
+160
View File
@@ -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<Mutex<Option<GhidraBridge>>>,
/// 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<Mutex<Option<GhidraBridge>>>,
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<bool> {
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<Mutex<Option<GhidraBridge>>>,
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(())
}
+72 -50
View File
@@ -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<u16>,
/// Ghidra installation directory
pub ghidra_install_dir: Option<PathBuf>,
/// Log file path
pub log_file: PathBuf,
/// Program name to load
pub program_name: Option<String>,
}
/// 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<Mutex<Option<GhidraBridge>>> = 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));
+343
View File
@@ -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<T> {
pub status: String,
pub data: Option<T>,
#[serde(default)]
pub message: Option<String>,
}
/// Request to the bridge
#[derive(Debug, Serialize)]
struct BridgeRequest {
command: String,
#[serde(skip_serializing_if = "Option::is_none")]
args: Option<serde_json::Value>,
}
/// Manages a persistent Ghidra bridge process.
pub struct GhidraBridge {
/// Child process handle
child: Option<Child>,
/// TCP connection to the bridge
stream: Option<TcpStream>,
/// 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<AtomicBool>,
}
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<T: for<'de> Deserialize<'de>>(
&mut self,
command: &str,
args: Option<serde_json::Value>,
) -> Result<BridgeResponse<T>> {
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<T> = 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::<serde_json::Value>("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<PathBuf> {
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<PathBuf> {
#[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<Option<std::process::ExitStatus>>;
}
impl ChildExt for Child {
fn wait_timeout(&mut self, timeout: Duration) -> std::io::Result<Option<std::process::ExitStatus>> {
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::Value> = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "success");
assert!(resp.data.is_some());
}
}
+16 -37
View File
@@ -78,43 +78,22 @@ impl<'a> HeadlessExecutor<'a> {
}
fn extract_json_from_output(&self, output: &str) -> Result<String> {
// 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<std::path::PathBuf> {
+2
View File
@@ -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,
+18
View File
@@ -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---")
"#
}
+387
View File
@@ -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))
+153
View File
@@ -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<Stream>,
writer: WriteHalf<Stream>,
next_id: u64,
}
impl DaemonClient {
/// Connect to the running daemon.
pub async fn connect() -> Result<Self> {
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<serde_json::Value> {
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<bool> {
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<serde_json::Value> {
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<usize>,
filter: Option<String>,
) -> Result<serde_json::Value> {
self.send_command(Command::ListFunctions { limit, filter })
.await
}
/// Decompile a function.
pub async fn decompile(&mut self, address: String) -> Result<serde_json::Value> {
self.send_command(Command::Decompile { address }).await
}
/// List strings.
pub async fn list_strings(&mut self, limit: Option<usize>) -> Result<serde_json::Value> {
self.send_command(Command::ListStrings { limit }).await
}
/// List imports.
pub async fn list_imports(&mut self) -> Result<serde_json::Value> {
self.send_command(Command::ListImports).await
}
/// List exports.
pub async fn list_exports(&mut self) -> Result<serde_json::Value> {
self.send_command(Command::ListExports).await
}
/// Get memory map.
pub async fn memory_map(&mut self) -> Result<serde_json::Value> {
self.send_command(Command::MemoryMap).await
}
/// Get program info.
pub async fn program_info(&mut self) -> Result<serde_json::Value> {
self.send_command(Command::ProgramInfo).await
}
/// Get cross-references to an address.
pub async fn xrefs_to(&mut self, address: String) -> Result<serde_json::Value> {
self.send_command(Command::XRefsTo { address }).await
}
/// Get cross-references from an address.
pub async fn xrefs_from(&mut self, address: String) -> Result<serde_json::Value> {
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()
}
+15
View File
@@ -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};
+166
View File
@@ -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<serde_json::Value>,
/// Error message on failure
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
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<String>) -> 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<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<String>,
},
/// Decompile a function at address
Decompile { address: String },
/// List strings in the program
ListStrings {
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<usize>,
},
/// 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"));
}
}
+203
View File
@@ -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<PathBuf> {
#[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<PathBuf> {
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<Listener> {
// 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::<GenericFilePath>()?;
ListenerOptions::new().name(name).create_tokio()?
};
#[cfg(windows)]
let listener = {
let name = name.to_ns_name::<GenericNamespaced>()?;
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<Stream> {
let name = socket_name();
#[cfg(unix)]
let stream = {
let name = name.to_fs_name::<GenericFilePath>()?;
Stream::connect(name).await?
};
#[cfg(windows)]
let stream = {
let name = name.to_ns_name::<GenericNamespaced>()?;
Stream::connect(name).await?
};
Ok(stream)
}
/// Send a length-prefixed message.
pub async fn send_message<W: AsyncWriteExt + Unpin>(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<R: AsyncReadExt + Unpin>(reader: &mut R) -> io::Result<Vec<u8>> {
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)
}
+2
View File
@@ -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<String>, port: Option<u16>, foregro
port,
ghidra_install_dir: config.ghidra_install_dir.map(PathBuf::from),
log_file,
program_name: config.default_program.clone(),
};
if foreground {