diff --git a/Cargo.lock b/Cargo.lock index d6509f6..58f6a5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,15 +459,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "daemonize" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8bfdaacb3c887a54d41bdf48d3af8873b3f5566469f8ba21b92057509f116e" -dependencies = [ - "libc", -] - [[package]] name = "deranged" version = "0.5.5" @@ -800,7 +791,6 @@ dependencies = [ "clap", "comfy-table", "csv", - "daemonize", "dirs", "dunce", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index c0de3e0..bd5ff8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,9 +80,6 @@ zip = "0.6" futures-util = "0.3" indicatif = "0.17" -# Daemonization (Unix only) -[target.'cfg(unix)'.dependencies] -daemonize = "0.5" [dev-dependencies] assert_cmd = "2.0" diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 955914a..75646c5 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -21,15 +21,12 @@ pub mod handlers; pub mod ipc_server; pub mod process; pub mod queue; -pub mod rpc; pub mod state; /// Daemon configuration. pub struct DaemonConfig { /// Path to the project directory pub project_path: PathBuf, - /// RPC port (None = auto-select) - kept for backwards compatibility - pub port: Option, /// Ghidra installation directory pub ghidra_install_dir: Option, /// Log file path @@ -81,9 +78,8 @@ pub async fn run(config: DaemonConfig) -> Result<()> { info!("No program specified, bridge will be started on first command"); } - // 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 lock file + let daemon_info = DaemonInfo::new(&config.project_path, &config.log_file); write_daemon_info(&data_dir, &config.project_path, &daemon_info) .context("Failed to write lock file")?; @@ -96,12 +92,6 @@ pub async fn run(config: DaemonConfig) -> Result<()> { } }); - // Also start the legacy RPC server for backwards compatibility - let queue = Arc::new(queue::CommandQueue::new(config.project_path.clone(), bridge.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; @@ -204,12 +194,11 @@ mod tests { fn test_daemon_config() { let config = DaemonConfig { project_path: PathBuf::from("/test/project"), - port: Some(17700), ghidra_install_dir: None, log_file: PathBuf::from("/test/logs/daemon.log"), program_name: None, }; - assert_eq!(config.port, Some(17700)); + assert_eq!(config.project_path, PathBuf::from("/test/project")); } } diff --git a/src/daemon/process.rs b/src/daemon/process.rs index 95a1f7b..e63f34e 100644 --- a/src/daemon/process.rs +++ b/src/daemon/process.rs @@ -16,8 +16,6 @@ use sysinfo::{System, Pid, ProcessRefreshKind}; pub struct DaemonInfo { /// Process ID of the daemon pub pid: u32, - /// RPC port the daemon is listening on - pub port: u16, /// Project path being managed pub project_path: PathBuf, /// Log file path @@ -28,10 +26,9 @@ pub struct DaemonInfo { impl DaemonInfo { /// Create new daemon info. - pub fn new(project_path: &Path, port: u16, log_file: &Path) -> Self { + pub fn new(project_path: &Path, log_file: &Path) -> Self { Self { pid: std::process::id(), - port, project_path: project_path.to_path_buf(), log_file: log_file.to_path_buf(), started_at: Utc::now(), @@ -132,11 +129,7 @@ pub fn get_running_daemon_info(data_dir: &Path, project_path: &Path) -> Result Result<()> { if let Some(info) = get_running_daemon_info(data_dir, project_path)? { - bail!( - "Daemon is already running (PID: {}, port: {})", - info.pid, - info.port - ); + bail!("Daemon is already running (PID: {})", info.pid); } Ok(()) } @@ -150,11 +143,9 @@ mod tests { fn test_daemon_info_creation() { let info = DaemonInfo::new( Path::new("/test/project"), - 17700, Path::new("/test/logs/daemon.log"), ); - assert_eq!(info.port, 17700); assert_eq!(info.project_path, PathBuf::from("/test/project")); } @@ -164,7 +155,7 @@ mod tests { let data_dir = temp_dir.path(); let project_path = PathBuf::from("/test/project"); - let info = DaemonInfo::new(&project_path, 17700, Path::new("/test/logs/daemon.log")); + let info = DaemonInfo::new(&project_path, Path::new("/test/logs/daemon.log")); // Write write_daemon_info(data_dir, &project_path, &info)?; @@ -173,7 +164,6 @@ mod tests { let read_info = read_daemon_info(data_dir, &project_path)?; assert!(read_info.is_some()); let read_info = read_info.unwrap(); - assert_eq!(read_info.port, 17700); assert_eq!(read_info.pid, info.pid); // Remove diff --git a/src/daemon/rpc.rs b/src/daemon/rpc.rs deleted file mode 100644 index 347887d..0000000 --- a/src/daemon/rpc.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! RPC protocol for daemon communication using JSON over TCP. -//! -//! Defines the request/response types and RPC server/client implementations. - -#![allow(dead_code)] - -use std::net::SocketAddr; -use std::sync::Arc; - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::broadcast; -use tracing::{info, error}; - -use crate::cli::Commands; -use crate::daemon::queue::CommandQueue; - -/// RPC request from client to daemon. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DaemonRequest { - /// Execute a CLI command - Execute(Commands), - /// Shutdown the daemon - Shutdown, - /// Get daemon status - Status, - /// Ping the daemon - Ping, -} - -/// RPC response from daemon to client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DaemonResponse { - /// Command executed successfully with output - Success(String), - /// Command failed with error - Error(String), - /// Daemon status information - Status(DaemonStatus), - /// Pong response - Pong, -} - -/// Daemon status information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DaemonStatus { - /// Number of queued commands - pub queue_depth: usize, - /// Number of completed commands - pub completed_commands: usize, - /// Daemon uptime in seconds - pub uptime_seconds: u64, - /// Current project path - pub project_path: String, -} - -/// RPC server implementation. -pub struct DaemonServer { - queue: Arc, - shutdown_tx: broadcast::Sender<()>, - started_at: std::time::Instant, -} - -impl DaemonServer { - /// Create a new RPC server. - pub fn new(queue: Arc, shutdown_tx: broadcast::Sender<()>) -> Self { - Self { - queue, - shutdown_tx, - started_at: std::time::Instant::now(), - } - } - - /// Handle a request and return a response. - async fn handle_request(&self, request: DaemonRequest) -> DaemonResponse { - match request { - DaemonRequest::Execute(command) => { - match self.queue.submit(command).await { - Ok(result) => DaemonResponse::Success(result), - Err(e) => DaemonResponse::Error(e.to_string()), - } - } - DaemonRequest::Shutdown => { - info!("Received shutdown request via RPC"); - let _ = self.shutdown_tx.send(()); - DaemonResponse::Success("Daemon shutting down".to_string()) - } - DaemonRequest::Status => { - let status = DaemonStatus { - queue_depth: 0, // TODO: Get actual queue depth - completed_commands: 0, // TODO: Get actual completed count - uptime_seconds: self.started_at.elapsed().as_secs(), - project_path: self.queue.project_path().to_string_lossy().to_string(), - }; - DaemonResponse::Status(status) - } - DaemonRequest::Ping => { - DaemonResponse::Pong - } - } - } -} - -/// Run the RPC server. -pub async fn run_server( - queue: Arc, - port: Option, - shutdown_tx: broadcast::Sender<()>, -) -> Result { - let addr = SocketAddr::from(([127, 0, 0, 1], port.unwrap_or(0))); - let listener = TcpListener::bind(addr).await - .context("Failed to bind TCP listener")?; - - let actual_port = listener.local_addr() - .context("Failed to get local address")? - .port(); - - info!("RPC server listening on port {}", actual_port); - - let server = Arc::new(DaemonServer::new(queue, shutdown_tx.clone())); - let mut shutdown_rx = shutdown_tx.subscribe(); - - tokio::spawn(async move { - loop { - tokio::select! { - result = listener.accept() => { - match result { - Ok((stream, addr)) => { - info!("Accepted connection from {}", addr); - let server = server.clone(); - tokio::spawn(async move { - if let Err(e) = handle_connection(stream, server).await { - error!("Connection error: {}", e); - } - }); - } - Err(e) => { - error!("Failed to accept connection: {}", e); - } - } - } - _ = shutdown_rx.recv() => { - info!("RPC server shutting down"); - break; - } - } - } - }); - - Ok(actual_port) -} - -/// Handle a single client connection using JSON over TCP. -async fn handle_connection( - stream: TcpStream, - server: Arc, -) -> Result<()> { - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader); - let mut line = String::new(); - - loop { - line.clear(); - let n = reader.read_line(&mut line).await - .context("Failed to read from stream")?; - - if n == 0 { - // Connection closed - break; - } - - // Parse request - let request: DaemonRequest = serde_json::from_str(&line) - .context("Failed to parse request")?; - - // Handle request - let response = server.handle_request(request).await; - - // Serialize and send response - let response_json = serde_json::to_string(&response) - .context("Failed to serialize response")?; - - writer.write_all(response_json.as_bytes()).await - .context("Failed to write response")?; - writer.write_all(b"\n").await - .context("Failed to write newline")?; - } - - Ok(()) -} - -/// RPC client for connecting to the daemon. -pub struct DaemonClient { - stream: TcpStream, -} - -impl DaemonClient { - /// Connect to the daemon at the given port. - pub async fn connect(port: u16) -> Result { - let addr = SocketAddr::from(([127, 0, 0, 1], port)); - let stream = TcpStream::connect(addr).await - .context("Failed to connect to daemon")?; - - Ok(Self { stream }) - } - - /// Send a request to the daemon. - pub async fn request(&mut self, request: DaemonRequest) -> Result { - // Serialize and send request - let request_json = serde_json::to_string(&request) - .context("Failed to serialize request")?; - - self.stream.write_all(request_json.as_bytes()).await - .context("Failed to write request")?; - self.stream.write_all(b"\n").await - .context("Failed to write newline")?; - - // Read response - let (reader, _) = self.stream.split(); - let mut reader = BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await - .context("Failed to read response")?; - - // Parse response - let response: DaemonResponse = serde_json::from_str(&line) - .context("Failed to parse response")?; - - Ok(response) - } - - /// Execute a command on the daemon. - pub async fn execute(&mut self, command: Commands) -> Result { - match self.request(DaemonRequest::Execute(command)).await? { - DaemonResponse::Success(output) => Ok(output), - DaemonResponse::Error(e) => Err(anyhow::anyhow!(e)), - _ => Err(anyhow::anyhow!("Unexpected response")), - } - } - - /// Get daemon status. - pub async fn status(&mut self) -> Result { - match self.request(DaemonRequest::Status).await? { - DaemonResponse::Status(status) => Ok(status), - _ => Err(anyhow::anyhow!("Unexpected response")), - } - } - - /// Shutdown the daemon. - pub async fn shutdown(&mut self) -> Result<()> { - self.request(DaemonRequest::Shutdown).await?; - Ok(()) - } - - /// Ping the daemon. - pub async fn ping(&mut self) -> Result<()> { - match self.request(DaemonRequest::Ping).await? { - DaemonResponse::Pong => Ok(()), - _ => Err(anyhow::anyhow!("Unexpected response")), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_request_serialization() { - let request = DaemonRequest::Ping; - let json = serde_json::to_string(&request).unwrap(); - let deserialized: DaemonRequest = serde_json::from_str(&json).unwrap(); - matches!(deserialized, DaemonRequest::Ping); - } -} diff --git a/src/main.rs b/src/main.rs index aac2486..c0efb29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,15 +12,12 @@ use clap::Parser; use cli::{Cli, Commands, DaemonCommands, SetupArgs}; use config::Config; use daemon::process::{get_data_dir, get_running_daemon_info, ensure_not_running}; -use daemon::rpc as daemon_rpc; use daemon::{DaemonConfig, run as run_daemon}; use error::{GhidraError, Result}; use ghidra::GhidraClient; use std::path::PathBuf; use tracing::info; -#[cfg(unix)] -use daemonize::Daemonize; #[tokio::main] async fn main() { @@ -270,7 +267,6 @@ async fn handle_daemon_start(project: Option, program: Option, p let daemon_config = DaemonConfig { project_path: project_path.clone(), - port, ghidra_install_dir: config.ghidra_install_dir.map(PathBuf::from), log_file, program_name, @@ -286,7 +282,7 @@ async fn handle_daemon_start(project: Option, program: Option, p #[cfg(unix)] { - daemonize_unix(daemon_config)?; + daemonize_unix(daemon_config, port)?; } #[cfg(windows)] @@ -309,10 +305,10 @@ async fn handle_daemon_stop(project: Option) -> anyhow::Result<()> { let project_path = resolve_project_path(&project, &config)?; if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { - println!("Stopping daemon (PID: {}, port: {})...", daemon_info.pid, daemon_info.port); + println!("Stopping daemon (PID: {})...", daemon_info.pid); - // Connect and send shutdown - let mut client = daemon_rpc::DaemonClient::connect(daemon_info.port).await?; + // Connect via IPC and send shutdown + let mut client = ipc::client::DaemonClient::connect().await?; client.shutdown().await?; println!("Daemon stopped successfully"); @@ -344,18 +340,16 @@ async fn handle_daemon_status(project: Option) -> anyhow::Result<()> { if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { println!("Daemon is running:"); println!(" PID: {}", daemon_info.pid); - println!(" Port: {}", daemon_info.port); println!(" Project: {}", daemon_info.project_path.display()); println!(" Started: {}", daemon_info.started_at); println!(" Log file: {}", daemon_info.log_file.display()); - // Try to get detailed status from daemon - if let Ok(mut client) = daemon_rpc::DaemonClient::connect(daemon_info.port).await { + // Try to get detailed status from daemon via IPC + if let Ok(mut client) = ipc::client::DaemonClient::connect().await { if let Ok(status) = client.status().await { - println!("\nDaemon status:"); - println!(" Queue depth: {}", status.queue_depth); - println!(" Completed commands: {}", status.completed_commands); - println!(" Uptime: {} seconds", status.uptime_seconds); + if let Some(bridge_running) = status.get("bridge_running").and_then(|v| v.as_bool()) { + println!(" Bridge: {}", if bridge_running { "running" } else { "stopped" }); + } } } } else { @@ -371,10 +365,10 @@ async fn handle_daemon_ping(project: Option) -> anyhow::Result<()> { let data_dir = get_data_dir()?; let project_path = resolve_project_path(&project, &config)?; - if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { - let mut client = daemon_rpc::DaemonClient::connect(daemon_info.port).await?; + if let Some(_daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { + let mut client = ipc::client::DaemonClient::connect().await?; client.ping().await?; - println!("Daemon is responsive (port: {})", daemon_info.port); + println!("Daemon is responsive"); } else { println!("No daemon running for project: {}", project_path.display()); } @@ -460,13 +454,16 @@ async fn handle_setup(args: SetupArgs) -> anyhow::Result<()> { Ok(()) } -/// Daemonize on Unix systems using fork and detach. +/// Daemonize by spawning a detached process (cross-platform). +/// +/// This approach spawns a new process with --foreground flag instead of forking, +/// which avoids issues with Tokio runtime inheritance after fork. #[cfg(unix)] -fn daemonize_unix(daemon_config: DaemonConfig) -> anyhow::Result<()> { +fn daemonize_unix(daemon_config: DaemonConfig, port: Option) -> anyhow::Result<()> { use std::fs::OpenOptions; + use std::process::{Command, Stdio}; let log_file_path = daemon_config.log_file.clone(); - let project_path = daemon_config.project_path.clone(); // Open log file for stdout/stderr let log_file = OpenOptions::new() @@ -477,27 +474,37 @@ fn daemonize_unix(daemon_config: DaemonConfig) -> anyhow::Result<()> { let stdout = log_file.try_clone()?; let stderr = log_file; - // Get PID file path - use hash of project path like lock file - let data_dir = get_data_dir()?; - let project_hash = format!("{:x}", md5::compute(project_path.to_string_lossy().as_bytes())); - let pid_file = data_dir.join(format!("daemon-{}.pid", project_hash)); + // Get the current executable path + let exe_path = std::env::current_exe()?; - // Configure daemonization - let daemonize = Daemonize::new() - .pid_file(pid_file) - .working_directory("/") - .stdout(stdout) - .stderr(stderr); + // Build the command to spawn ourselves with --foreground flag + let mut cmd = Command::new(exe_path); + cmd.arg("daemon") + .arg("start") + .arg("--foreground"); - // Fork and daemonize - daemonize.start() - .map_err(|e| anyhow::anyhow!("Failed to daemonize: {}", e))?; + // Add project path + cmd.arg("--project") + .arg(daemon_config.project_path.to_string_lossy().to_string()); - // We're now in the daemon process - initialize tokio runtime and run - let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(async { - run_daemon(daemon_config).await - })?; + // Add program if specified + if let Some(program) = &daemon_config.program_name { + cmd.arg("--program").arg(program); + } + + // Add port if specified + if let Some(p) = port { + cmd.arg("--port").arg(p.to_string()); + } + + // Redirect stdout/stderr to log file, detach stdin + cmd.stdin(Stdio::null()); + cmd.stdout(stdout); + cmd.stderr(stderr); + + // Spawn the detached process + cmd.spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn daemon process: {}", e))?; Ok(()) } diff --git a/tests/README.md b/tests/README.md index 48793e5..0631cbb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -78,7 +78,7 @@ cargo test --test project_tests --lib ### Ghidra Installation -Tests check for Ghidra availability using `skip_if_no_ghidra!()` macro. Tests skip with clear message if `ghidra doctor` fails. +Tests assume Ghidra is installed. Use `require_ghidra!()` in tests that need a fast, explicit availability check; it fails the test if `ghidra doctor` fails. ### Test Fixtures @@ -100,7 +100,7 @@ Add to appropriate file (`command_tests.rs`, `project_tests.rs`): ```rust #[test] fn test_my_command() { - skip_if_no_ghidra!(); + require_ghidra!(); Command::cargo_bin("ghidra") .unwrap() @@ -118,7 +118,7 @@ Add to `daemon_tests.rs` or `query_tests.rs`: #[test] #[serial] fn test_my_query() { - skip_if_no_ghidra!(); + require_ghidra!(); let harness = &*HARNESS; // Shared daemon instance diff --git a/tests/command_tests.rs b/tests/command_tests.rs index 147b542..6ad8d86 100644 --- a/tests/command_tests.rs +++ b/tests/command_tests.rs @@ -3,10 +3,13 @@ use assert_cmd::Command; use predicates::prelude::*; +#[macro_use] mod common; #[test] fn test_version() { + require_ghidra!(); + Command::cargo_bin("ghidra") .unwrap() .arg("version") @@ -17,6 +20,8 @@ fn test_version() { #[test] fn test_doctor() { + require_ghidra!(); + Command::cargo_bin("ghidra") .unwrap() .arg("doctor") @@ -27,6 +32,8 @@ fn test_doctor() { #[test] fn test_config_list() { + require_ghidra!(); + Command::cargo_bin("ghidra") .unwrap() .arg("config") @@ -38,6 +45,8 @@ fn test_config_list() { #[test] fn test_config_get() { + require_ghidra!(); + Command::cargo_bin("ghidra") .unwrap() .arg("config") @@ -49,6 +58,8 @@ fn test_config_get() { #[test] fn test_config_set() { + require_ghidra!(); + let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.yaml"); @@ -65,6 +76,8 @@ fn test_config_set() { #[test] fn test_config_reset() { + require_ghidra!(); + let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.yaml"); @@ -76,3 +89,56 @@ fn test_config_reset() { .assert() .success(); } + +#[test] +fn test_init() { + require_ghidra!(); + + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.yaml"); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_CONFIG", &config_path) + .arg("init") + .assert() + .success(); + + assert!(config_path.exists()); +} + +#[test] +fn test_set_default_program() { + require_ghidra!(); + + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.yaml"); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_CONFIG", &config_path) + .arg("set-default") + .arg("program") + .arg("sample_binary") + .assert() + .success() + .stdout(predicate::str::contains("Default program set")); +} + +#[test] +fn test_set_default_project() { + require_ghidra!(); + + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.yaml"); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_CONFIG", &config_path) + .arg("set-default") + .arg("project") + .arg("test-project") + .assert() + .success() + .stdout(predicate::str::contains("Default project set")); +} diff --git a/tests/common/README.md b/tests/common/README.md index 747a9d4..69f7dcc 100644 --- a/tests/common/README.md +++ b/tests/common/README.md @@ -114,20 +114,20 @@ ensure_test_project("my-project", "sample_binary"); Handles "already exists" errors gracefully. Safe to call from multiple tests. -## skip_if_no_ghidra! Macro +## require_ghidra! Macro -Tests should call this macro to skip gracefully when Ghidra unavailable: +Tests should call this macro to assert Ghidra availability up front: ```rust #[test] fn test_something() { - skip_if_no_ghidra!(); + require_ghidra!(); // Test code runs only if ghidra doctor succeeds } ``` -Runs `ghidra doctor` and returns early if fails. Prints message: "Skipping test: Ghidra not available" +Runs `ghidra doctor` and fails the test if Ghidra is unavailable, including doctor output. ## Design Decisions diff --git a/tests/common/helpers.rs b/tests/common/helpers.rs index ca1586b..4a64f0f 100644 --- a/tests/common/helpers.rs +++ b/tests/common/helpers.rs @@ -170,6 +170,7 @@ impl GhidraCommand { /// Configure for daemon connection. pub fn with_daemon(self, harness: &DaemonTestHarness) -> Self { self.env("GHIDRA_CLI_SOCKET", harness.socket_path().to_string_lossy()) + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir().to_string_lossy()) } /// Set project and program arguments. diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8c6f1b8..d6b3745 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -201,6 +201,11 @@ impl DaemonTestHarness { &self.socket_path } + /// Get data directory for this daemon instance. + pub fn data_dir(&self) -> &PathBuf { + &self.data_dir + } + /// Get project name. pub fn project(&self) -> &str { &self.project @@ -247,14 +252,20 @@ fn get_unique_data_dir() -> PathBuf { dir } -/// Skip test if Ghidra is not available. +/// Require Ghidra to be available for tests to proceed. #[macro_export] -macro_rules! skip_if_no_ghidra { +macro_rules! require_ghidra { () => { - let doctor = assert_cmd::Command::cargo_bin("ghidra").unwrap().arg("doctor").output(); - if doctor.is_err() || !doctor.unwrap().status.success() { - eprintln!("Skipping test: Ghidra not available"); - return; - } + let doctor = assert_cmd::Command::cargo_bin("ghidra") + .unwrap() + .arg("doctor") + .output() + .expect("Failed to run `ghidra doctor`"); + assert!( + doctor.status.success(), + "Ghidra is not available for tests.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&doctor.stdout), + String::from_utf8_lossy(&doctor.stderr) + ); }; } diff --git a/tests/daemon_tests.rs b/tests/daemon_tests.rs index 9177bf6..2d29410 100644 --- a/tests/daemon_tests.rs +++ b/tests/daemon_tests.rs @@ -14,6 +14,7 @@ const TEST_PROGRAM: &str = "sample_binary"; #[test] #[serial] fn test_daemon_start() { + require_ghidra!(); ensure_test_project(TEST_PROJECT, TEST_PROGRAM); @@ -22,6 +23,7 @@ fn test_daemon_start() { Command::cargo_bin("ghidra") .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) .env("GHIDRA_CLI_SOCKET", harness.socket_path()) .arg("daemon") .arg("status") @@ -36,6 +38,7 @@ fn test_daemon_start() { #[test] #[serial] fn test_daemon_status() { + require_ghidra!(); ensure_test_project(TEST_PROJECT, TEST_PROGRAM); @@ -44,6 +47,7 @@ fn test_daemon_status() { Command::cargo_bin("ghidra") .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) .env("GHIDRA_CLI_SOCKET", harness.socket_path()) .arg("daemon") .arg("status") @@ -59,6 +63,7 @@ fn test_daemon_status() { #[test] #[serial] fn test_daemon_ping() { + require_ghidra!(); ensure_test_project(TEST_PROJECT, TEST_PROGRAM); @@ -67,6 +72,7 @@ fn test_daemon_ping() { Command::cargo_bin("ghidra") .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) .env("GHIDRA_CLI_SOCKET", harness.socket_path()) .arg("daemon") .arg("ping") @@ -81,6 +87,7 @@ fn test_daemon_ping() { #[test] #[serial] fn test_daemon_clear_cache() { + require_ghidra!(); ensure_test_project(TEST_PROJECT, TEST_PROGRAM); @@ -89,6 +96,7 @@ fn test_daemon_clear_cache() { Command::cargo_bin("ghidra") .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) .env("GHIDRA_CLI_SOCKET", harness.socket_path()) .arg("daemon") .arg("clear-cache") @@ -103,6 +111,7 @@ fn test_daemon_clear_cache() { #[test] #[serial] fn test_daemon_lifecycle() { + require_ghidra!(); ensure_test_project(TEST_PROJECT, TEST_PROGRAM); @@ -111,6 +120,7 @@ fn test_daemon_lifecycle() { Command::cargo_bin("ghidra") .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) .env("GHIDRA_CLI_SOCKET", harness.socket_path()) .arg("daemon") .arg("status") @@ -122,6 +132,7 @@ fn test_daemon_lifecycle() { Command::cargo_bin("ghidra") .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) .env("GHIDRA_CLI_SOCKET", harness.socket_path()) .arg("daemon") .arg("ping") @@ -132,6 +143,7 @@ fn test_daemon_lifecycle() { Command::cargo_bin("ghidra") .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) .env("GHIDRA_CLI_SOCKET", harness.socket_path()) .arg("daemon") .arg("stop") @@ -140,3 +152,103 @@ fn test_daemon_lifecycle() { .assert() .success(); } + +#[test] +#[serial] +fn test_daemon_stop() { + require_ghidra!(); + + ensure_test_project(TEST_PROJECT, TEST_PROGRAM); + + let harness = DaemonTestHarness::new(TEST_PROJECT, TEST_PROGRAM) + .expect("Failed to start daemon"); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) + .env("GHIDRA_CLI_SOCKET", harness.socket_path()) + .arg("daemon") + .arg("stop") + .arg("--project") + .arg(TEST_PROJECT) + .assert() + .success(); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) + .env("GHIDRA_CLI_SOCKET", harness.socket_path()) + .arg("daemon") + .arg("status") + .arg("--project") + .arg(TEST_PROJECT) + .assert() + .success() + .stdout(predicate::str::contains("No daemon running")); + + drop(harness); +} + +#[test] +#[serial] +fn test_daemon_restart() { + require_ghidra!(); + + ensure_test_project(TEST_PROJECT, TEST_PROGRAM); + + let harness = DaemonTestHarness::new(TEST_PROJECT, TEST_PROGRAM) + .expect("Failed to start daemon"); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) + .env("GHIDRA_CLI_SOCKET", harness.socket_path()) + .arg("daemon") + .arg("restart") + .arg("--project") + .arg(TEST_PROJECT) + .arg("--program") + .arg(TEST_PROGRAM) + .assert() + .success(); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) + .env("GHIDRA_CLI_SOCKET", harness.socket_path()) + .arg("daemon") + .arg("stop") + .arg("--project") + .arg(TEST_PROJECT) + .assert() + .success(); + + drop(harness); +} + +#[test] +#[serial] +fn test_daemon_start_when_running() { + require_ghidra!(); + + ensure_test_project(TEST_PROJECT, TEST_PROGRAM); + + let harness = DaemonTestHarness::new(TEST_PROJECT, TEST_PROGRAM) + .expect("Failed to start daemon"); + + Command::cargo_bin("ghidra") + .unwrap() + .env("GHIDRA_CLI_DATA_DIR", harness.data_dir()) + .env("GHIDRA_CLI_SOCKET", harness.socket_path()) + .arg("daemon") + .arg("start") + .arg("--project") + .arg(TEST_PROJECT) + .arg("--program") + .arg(TEST_PROGRAM) + .assert() + .failure() + .stderr(predicate::str::contains("already running")); + + drop(harness); +} diff --git a/tests/project_tests.rs b/tests/project_tests.rs index dced1ce..2f907c6 100644 --- a/tests/project_tests.rs +++ b/tests/project_tests.rs @@ -4,6 +4,7 @@ use assert_cmd::Command; use predicates::prelude::*; use serial_test::serial; +#[macro_use] mod common; /// Generate unique project name for test isolation. @@ -14,6 +15,8 @@ fn unique_project_name(prefix: &str) -> String { #[test] fn test_project_create() { + require_ghidra!(); + let project = unique_project_name("create"); Command::cargo_bin("ghidra") @@ -37,6 +40,8 @@ fn test_project_create() { #[test] fn test_project_list() { + require_ghidra!(); + Command::cargo_bin("ghidra") .unwrap() .arg("project") @@ -47,6 +52,8 @@ fn test_project_list() { #[test] fn test_project_info() { + require_ghidra!(); + let project = unique_project_name("info"); Command::cargo_bin("ghidra") @@ -77,6 +84,8 @@ fn test_project_info() { #[test] fn test_project_lifecycle() { + require_ghidra!(); + let project = unique_project_name("lifecycle"); Command::cargo_bin("ghidra") @@ -107,6 +116,8 @@ fn test_project_lifecycle() { #[test] #[serial] fn test_import_binary() { + require_ghidra!(); + let project = unique_project_name("import"); let binary = common::fixture_binary(); @@ -135,6 +146,8 @@ fn test_import_binary() { #[test] #[serial] fn test_analyze_program() { + require_ghidra!(); + let project = unique_project_name("analyze"); let binary = common::fixture_binary(); @@ -169,3 +182,87 @@ fn test_analyze_program() { .assert() .success(); } + +#[test] +fn test_project_delete_nonexistent() { + require_ghidra!(); + + let project = unique_project_name("missing"); + + Command::cargo_bin("ghidra") + .unwrap() + .arg("project") + .arg("delete") + .arg(&project) + .assert() + .success() + .stdout(predicate::str::contains("not found")); +} + +#[test] +#[serial] +fn test_import_existing_program() { + require_ghidra!(); + + let project = unique_project_name("import-existing"); + let binary = common::fixture_binary(); + + let mut cmd = Command::cargo_bin("ghidra").unwrap(); + cmd.arg("import") + .arg(binary.to_str().unwrap()) + .arg("--project") + .arg(&project) + .arg("--program") + .arg("sample_binary") + .timeout(std::time::Duration::from_secs(300)) + .assert() + .success() + .stdout(predicate::str::contains("Successfully imported as")); + + let mut cmd = Command::cargo_bin("ghidra").unwrap(); + cmd.arg("import") + .arg(binary.to_str().unwrap()) + .arg("--project") + .arg(&project) + .arg("--program") + .arg("sample_binary") + .timeout(std::time::Duration::from_secs(300)) + .assert() + .success() + .stdout(predicate::str::contains("Successfully imported as")); + + Command::cargo_bin("ghidra") + .unwrap() + .arg("project") + .arg("delete") + .arg(&project) + .assert() + .success(); +} + +#[test] +#[serial] +fn test_quick() { + require_ghidra!(); + + let project = unique_project_name("quick"); + let binary = common::fixture_binary(); + + Command::cargo_bin("ghidra") + .unwrap() + .arg("quick") + .arg(binary.to_str().unwrap()) + .arg("--project") + .arg(&project) + .timeout(std::time::Duration::from_secs(300)) + .assert() + .success(); + + Command::cargo_bin("ghidra") + .unwrap() + .arg("project") + .arg("delete") + .arg(&project) + .assert() + .success(); +}