use anyhow::{anyhow, Context, Result};
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use serde::Deserialize;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct GithubAsset {
name: String,
browser_download_url: String,
size: u64,
}
#[derive(Deserialize, Debug)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
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?")?;
let version_output = String::from_utf8_lossy(&output.stderr);
if version_output.is_empty() {
return Err(anyhow!("Could not determine Java version"));
}
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
));
}
}
}
println!("⚠ Could not parse Java version, but Java appears installed");
println!(" Output: {}", version_output.lines().next().unwrap_or(""));
Ok(())
}
pub async fn resolve_version_url(version: Option<String>) -> Result<(String, String, String)> {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", token).parse()?,
);
}
let client = reqwest::Client::builder()
.user_agent("ghidra-cli")
.default_headers(headers)
.build()?;
let release: GithubRelease = if let Some(ver) = 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 {
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);
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,
))
}
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(())
}
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");
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,
};
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)?;
}
#[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"))
}
pub async fn install_ghidra(version: Option<String>, target_dir: PathBuf) -> Result<PathBuf> {
let (download_url, filename, tag) = resolve_version_url(version).await?;
println!("Installing Ghidra {} to: {}", tag, target_dir.display());
let zip_path = target_dir.join(&filename);
download_file(&download_url, &zip_path).await?;
let install_path = extract_zip(&zip_path, &target_dir)?;
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() {
let _ = check_java_requirement();
}
}