ghidra setup command

This commit is contained in:
Alexander Kiselev
2026-01-20 12:04:00 -08:00
parent 8344c2b4ed
commit bd4291795b
7 changed files with 1547 additions and 17 deletions
Generated
+1128 -8
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -71,6 +71,12 @@ strsim = "0.11"
dunce = "1.0" # Windows path handling
atty = "0.2" # TTY detection
# Setup command dependencies
reqwest = { version = "0.11", features = ["json", "stream", "rustls-tls"] }
zip = "0.6"
futures-util = "0.3"
indicatif = "0.17"
# Daemonization (Unix only)
[target.'cfg(unix)'.dependencies]
daemonize = "0.5"
+82
View File
@@ -1 +1,83 @@
# Setup Command Implementation Notes
## Codebase Analysis
### Current Structure
- **Cargo.toml**: Already has tokio, clap, serde, dirs, anyhow dependencies
- **src/cli.rs**: Commands enum on line 20-127, need to add Setup variant
- **src/main.rs**:
- `run()` sync function handles most commands (line 49-72)
- `run_async()` handles daemon commands (line 74-80)
- `run_with_daemon_check()` routes commands through daemon if running (line 82-125)
- **src/ghidra/mod.rs**: GhidraClient with `verify_installation()` - can reuse for verification
- **src/config.rs**: Config struct with `save()` method and `ghidra_install_dir` field
### Key Insights
1. Setup command should be treated as async like daemon commands (uses reqwest for HTTP)
2. Need to route `Commands::Setup` through `run_async()` rather than sync `run()`
3. Can reuse existing `Config::save()` to persist ghidra_install_dir after installation
4. Can reuse `GhidraClient::verify_installation()` to verify the installation
### Dependencies Added
```toml
reqwest = { version = "0.11", features = ["json", "stream", "rustls-tls"] }
zip = "0.6"
futures-util = "0.3"
indicatif = "0.17"
```
## Implementation Progress
### Phase 1: Dependencies ✅
Added reqwest, zip, futures-util, and indicatif to Cargo.toml
### Phase 2: CLI Definition ✅
- Added `Setup(SetupArgs)` variant to Commands enum
- Added `SetupArgs` struct with version, dir, and force fields
### Phase 3: Setup Module ✅
Created `src/ghidra/setup.rs` with:
- `check_java_requirement()` - runs `java -version` and checks for JDK 17+
- `resolve_version_url()` - queries GitHub API for release URL
- `download_file()` - streams download with indicatif progress bar
- `extract_zip()` - extracts with progress bar, handles Unix permissions
- `install_ghidra()` - orchestrates the full installation flow
### Phase 4: Main Integration ✅
- Updated imports to include SetupArgs
- Modified main() to route Setup through run_async()
- Updated run_async() to handle Commands::Setup
- Added handle_setup() async function
## Testing Notes
### Build Verification
```
cargo build # SUCCESS - only lint warnings
```
### Help Output
```
$ ghidra setup --help
Download and setup Ghidra automatically
Usage: ghidra setup [OPTIONS]
Options:
--version <VERSION> Specific Ghidra version to install (e.g., "11.0"). Defaults to latest
-d, --dir <DIR> Installation directory. Defaults to standard data directory
--force Skip Java check
-v, --verbose Enable verbose output
-q, --quiet Suppress non-essential output
-h, --help Print help
```
### Tests
All existing tests pass (cargo test).
## Files Modified
- `Cargo.toml` - Added 4 new dependencies
- `src/cli.rs` - Added SetupArgs struct and Setup variant
- `src/ghidra/mod.rs` - Added `pub mod setup;`
- `src/ghidra/setup.rs` - NEW FILE - 230 lines
- `src/main.rs` - Updated routing and added handle_setup function
+18
View File
@@ -124,6 +124,9 @@ pub enum Commands {
/// Daemon management commands
#[command(subcommand)]
Daemon(DaemonCommands),
/// Download and setup Ghidra automatically
Setup(SetupArgs),
}
#[derive(Args, Clone, Serialize, Deserialize, Debug)]
@@ -783,3 +786,18 @@ pub enum DaemonCommands {
},
}
/// Arguments for the setup command
#[derive(Args, Clone, Serialize, Deserialize, Debug)]
pub struct SetupArgs {
/// Specific Ghidra version to install (e.g., "11.0"). Defaults to latest.
#[arg(long)]
pub version: Option<String>,
/// Installation directory. Defaults to standard data directory.
#[arg(long, short = 'd')]
pub dir: Option<String>,
/// Skip Java check
#[arg(long)]
pub force: bool,
}
+1
View File
@@ -1,6 +1,7 @@
pub mod headless;
pub mod data;
pub mod scripts;
pub mod setup;
use std::path::{Path, PathBuf};
use std::process::Command;
+244
View File
@@ -0,0 +1,244 @@
use std::fs::File;
use std::io::{Read, Write, Seek};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use serde::Deserialize;
/// GitHub release asset information
#[derive(Deserialize, Debug)]
struct GithubAsset {
name: String,
browser_download_url: String,
size: u64,
}
/// GitHub release information
#[derive(Deserialize, Debug)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
/// Check if Java is installed and meets the minimum version requirement (JDK 17+).
pub fn check_java_requirement() -> Result<()> {
use std::process::Command;
let output = Command::new("java")
.arg("-version")
.output()
.context("Failed to execute 'java -version'. Is Java installed and in PATH?")?;
// Java outputs version info to stderr
let version_output = String::from_utf8_lossy(&output.stderr);
// Look for version pattern like "17.0.x" or "21.0.x" in the output
// Java version string format is usually: 'java version "17.0.1"' or 'openjdk version "17.0.1"'
if version_output.is_empty() {
return Err(anyhow!("Could not determine Java version"));
}
// Extract version number
let version_regex = regex::Regex::new(r#"version "(\d+)"#)?;
if let Some(captures) = version_regex.captures(&version_output) {
if let Some(major_version) = captures.get(1) {
let major: u32 = major_version.as_str().parse().unwrap_or(0);
if major >= 17 {
println!("✓ Java {} detected", major);
return Ok(());
} else {
return Err(anyhow!(
"Java {} detected, but Ghidra requires JDK 17 or higher",
major
));
}
}
}
// Fallback: if we got output but couldn't parse, warn but continue
println!("⚠ Could not parse Java version, but Java appears installed");
println!(" Output: {}", version_output.lines().next().unwrap_or(""));
Ok(())
}
/// Resolve the download URL for a Ghidra release.
/// If version is None, fetches the latest release.
pub async fn resolve_version_url(version: Option<String>) -> Result<(String, String, String)> {
let client = reqwest::Client::builder()
.user_agent("ghidra-cli")
.build()?;
let release: GithubRelease = if let Some(ver) = version {
// Fetch specific version
let url = format!(
"https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/tags/Ghidra_{}",
ver
);
println!("Fetching release info for Ghidra {}...", ver);
client
.get(&url)
.send()
.await?
.error_for_status()
.context(format!("Could not find Ghidra version {}", ver))?
.json()
.await?
} else {
// Fetch latest release
let url = "https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest";
println!("Fetching latest Ghidra release info...");
client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?
};
println!("Found release: {}", release.tag_name);
// Find the zip file in assets
let zip_asset = release
.assets
.iter()
.find(|a| a.name.ends_with(".zip") && !a.name.contains("src"))
.ok_or_else(|| anyhow!("No zip distribution found in release assets"))?;
Ok((
zip_asset.browser_download_url.clone(),
zip_asset.name.clone(),
release.tag_name,
))
}
/// Download a file with progress bar.
pub async fn download_file(url: &str, path: &Path) -> Result<()> {
let client = reqwest::Client::builder()
.user_agent("ghidra-cli")
.build()?;
let res = client
.get(url)
.send()
.await?
.error_for_status()
.context("Download request failed")?;
let total_size = res.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(ProgressStyle::default_bar()
.template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
.progress_chars("#>-"));
pb.set_message(format!("Downloading {}", path.file_name().unwrap_or_default().to_string_lossy()));
let mut file = File::create(path)?;
let mut stream = res.bytes_stream();
while let Some(item) = stream.next().await {
let chunk = item.context("Error reading download stream")?;
file.write_all(&chunk)?;
pb.inc(chunk.len() as u64);
}
pb.finish_with_message("Download complete");
Ok(())
}
/// Extract a zip file to the target directory.
/// Returns the path to the extracted Ghidra directory.
pub fn extract_zip(zip_path: &Path, target_dir: &Path) -> Result<PathBuf> {
println!("Extracting...");
let file = File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let total_files = archive.len();
let pb = ProgressBar::new(total_files as u64);
pb.set_style(ProgressStyle::default_bar()
.template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len}")?
.progress_chars("#>-"));
pb.set_message("Extracting files");
// Track the root directory from the archive
let mut root_dir: Option<PathBuf> = None;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let outpath = match file.enclosed_name() {
Some(path) => target_dir.join(path),
None => continue,
};
// Capture the root directory (first path component)
if root_dir.is_none() {
if let Some(first_component) = file.enclosed_name().and_then(|p| p.components().next()) {
root_dir = Some(target_dir.join(first_component.as_os_str()));
}
}
if file.name().ends_with('/') {
std::fs::create_dir_all(&outpath)?;
} else {
if let Some(p) = outpath.parent() {
if !p.exists() {
std::fs::create_dir_all(p)?;
}
}
let mut outfile = File::create(&outpath)?;
std::io::copy(&mut file, &mut outfile)?;
}
// Set permissions on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode)).ok();
}
}
pb.inc(1);
}
pb.finish_with_message("Extraction complete");
root_dir.ok_or_else(|| anyhow!("Could not determine extracted directory"))
}
/// Install Ghidra to the specified directory.
/// Returns the path to the installed Ghidra directory.
pub async fn install_ghidra(version: Option<String>, target_dir: PathBuf) -> Result<PathBuf> {
// Resolve version and get download URL
let (download_url, filename, tag) = resolve_version_url(version).await?;
println!("Installing Ghidra {} to: {}", tag, target_dir.display());
// Download the zip file
let zip_path = target_dir.join(&filename);
download_file(&download_url, &zip_path).await?;
// Extract the zip
let install_path = extract_zip(&zip_path, &target_dir)?;
// Cleanup zip file
if let Err(e) = std::fs::remove_file(&zip_path) {
println!("⚠ Could not remove zip file: {}", e);
}
Ok(install_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_java_fails_gracefully() {
// This just ensures the function doesn't panic
// It may succeed or fail depending on the system
let _ = check_java_requirement();
}
}
+68 -9
View File
@@ -8,7 +8,7 @@ mod ghidra;
mod query;
use clap::Parser;
use cli::{Cli, Commands, DaemonCommands, QueryArgs, QueryOptions};
use cli::{Cli, Commands, DaemonCommands, QueryArgs, QueryOptions, SetupArgs};
use config::Config;
use daemon::process::{get_data_dir, get_running_daemon_info, ensure_not_running};
use daemon::rpc as daemon_rpc;
@@ -32,12 +32,15 @@ async fn main() {
let cli = Cli::parse();
let result = if let Commands::Daemon(_) = &cli.command {
// Daemon commands are async
run_async(cli).await
} else {
// Other commands can be sync or we check if daemon is running
run_with_daemon_check(cli).await
let result = match &cli.command {
Commands::Daemon(_) | Commands::Setup(_) => {
// Daemon and Setup commands are async
run_async(cli).await
}
_ => {
// Other commands can be sync or we check if daemon is running
run_with_daemon_check(cli).await
}
};
if let Err(e) = result {
@@ -71,11 +74,12 @@ fn run(cli: Cli) -> anyhow::Result<()> {
}
}
/// Run async commands (daemon management).
/// Run async commands (daemon management, setup).
async fn run_async(cli: Cli) -> anyhow::Result<()> {
match cli.command {
Commands::Daemon(cmd) => handle_daemon_command(cmd).await,
_ => unreachable!("run_async called with non-daemon command"),
Commands::Setup(args) => handle_setup(args).await,
_ => unreachable!("run_async called with non-async command"),
}
}
@@ -327,6 +331,61 @@ async fn handle_daemon_clear_cache(project: Option<String>) -> anyhow::Result<()
Ok(())
}
/// Handle the setup command - download and install Ghidra.
async fn handle_setup(args: SetupArgs) -> anyhow::Result<()> {
println!("Ghidra Setup Wizard");
println!("===================\n");
// 1. Check Java
if !args.force {
if let Err(e) = ghidra::setup::check_java_requirement() {
eprintln!("⚠ Java prerequisite check failed: {}", e);
eprintln!("Ghidra requires JDK 17+. Use --force to continue anyway.");
std::process::exit(1);
}
} else {
println!("⚠ Skipping Java check (--force specified)");
}
// 2. Determine Install Directory
let install_base = if let Some(d) = args.dir {
PathBuf::from(d)
} else {
// Default to XDG_DATA_HOME/ghidra-cli/ghidra
dirs::data_local_dir()
.ok_or(anyhow::anyhow!("Could not determine data directory"))?
.join("ghidra-cli")
.join("ghidra")
};
std::fs::create_dir_all(&install_base)?;
// 3. Install
println!("\nInstalling to: {}", install_base.display());
let final_path = ghidra::setup::install_ghidra(args.version, install_base).await?;
// 4. Update Config
let mut config = Config::load()?;
config.ghidra_install_dir = Some(final_path.clone());
config.save()?;
println!("\n✓ Success! Ghidra installed at: {}", final_path.display());
println!("✓ Configuration updated.");
// 5. Verify
println!("\nVerifying installation...");
let client = GhidraClient::new(config)?;
if client.verify_installation().is_ok() {
println!("✓ Verification passed!");
println!("\nYou can now run: ghidra quick <binary>");
} else {
println!("⚠ Verification failed - analyzeHeadless not found");
println!(" The installation may be incomplete.");
}
Ok(())
}
/// Daemonize on Unix systems using fork and detach.
#[cfg(unix)]
fn daemonize_unix(daemon_config: DaemonConfig) -> anyhow::Result<()> {