ghidra 0.0.3

Typed Rust bindings for an embedded Ghidra JVM
Documentation
use std::{
    env, fs, io,
    path::{Path, PathBuf},
    sync::{Arc, Mutex},
};

use jni::{InitArgsBuilder, JNIVersion, JavaVM};
use thiserror::Error;

static RUNTIME: Mutex<Option<Arc<RuntimeState>>> = Mutex::new(None);

/// Result type used by the embedded Ghidra runtime.
pub type Result<T> = std::result::Result<T, Error>;

/// Error returned while discovering Ghidra or starting the embedded JVM.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
    #[error("GHIDRA_INSTALL_DIR is not set and no Ghidra lastrun file was found")]
    MissingInstallDir,

    #[error("failed to canonicalize Ghidra install directory {path}: {source}")]
    CanonicalizeInstallDir {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("Ghidra install directory is missing {path}")]
    MissingInstallFile { path: PathBuf },

    #[error("failed to read Ghidra lastrun file {path}: {source}")]
    ReadLastrun {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("failed to build JVM classpath: {source}")]
    Classpath {
        #[source]
        source: env::JoinPathsError,
    },

    #[error("failed to read {path}: {source}")]
    ReadDir {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("failed to read {path}: {source}")]
    ReadFile {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("failed to build JVM args: {source}")]
    BuildJvmArgs {
        #[source]
        source: jni::JvmError,
    },

    #[error("failed to start embedded JVM: {source}")]
    StartJvm {
        #[source]
        source: jni::errors::StartJvmError,
    },
}

/// Options for starting the embedded Ghidra JVM.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LaunchOptions {
    /// Explicit Ghidra installation directory.
    ///
    /// When omitted, the runtime uses `GHIDRA_INSTALL_DIR` or Ghidra's
    /// `lastrun` config.
    pub install_dir: Option<PathBuf>,
    /// Additional classpath entries appended after Ghidra jars.
    pub extra_classpaths: Vec<PathBuf>,
    /// Whether to leave Ghidra/JVM output unsuppressed.
    pub verbose: bool,
}

/// Started embedded Ghidra JVM.
#[derive(Clone)]
pub struct GhidraRuntime {
    state: Arc<RuntimeState>,
}

struct RuntimeState {
    jvm: JavaVM,
    install_dir: PathBuf,
}

impl GhidraRuntime {
    /// Returns the resolved Ghidra installation directory.
    pub fn install_dir(&self) -> &Path {
        &self.state.install_dir
    }

    /// Runs an operation with the current thread attached to the JVM.
    pub fn with_attached<T, E>(
        &self,
        operation: impl FnOnce(&mut jni::Env<'_>) -> std::result::Result<T, E>,
    ) -> std::result::Result<T, E>
    where
        E: From<jni::errors::Error>,
    {
        self.state.jvm.attach_current_thread(operation)
    }
}

/// Starts the process-global embedded Ghidra JVM, or returns the existing runtime.
pub fn start(options: LaunchOptions) -> Result<GhidraRuntime> {
    let mut runtime = RUNTIME.lock().expect("runtime lock poisoned");
    if let Some(state) = runtime.as_ref() {
        return Ok(GhidraRuntime {
            state: Arc::clone(state),
        });
    }

    let install_dir = resolve_install_dir(options.install_dir.as_deref())?;
    let jvm = create_jvm(&install_dir, &options.extra_classpaths, options.verbose)?;
    let state = Arc::new(RuntimeState { jvm, install_dir });
    *runtime = Some(Arc::clone(&state));
    Ok(GhidraRuntime { state })
}

/// Returns whether the process-global embedded Ghidra JVM has been started.
pub fn started() -> bool {
    RUNTIME.lock().expect("runtime lock poisoned").is_some()
}

fn create_jvm(install_dir: &Path, extra_classpaths: &[PathBuf], verbose: bool) -> Result<JavaVM> {
    let mut builder = InitArgsBuilder::new()
        .version(JNIVersion::V1_8)
        .ignore_unrecognized(true)
        .option(format!(
            "-Djava.class.path={}",
            classpath(install_dir, extra_classpaths)?
        ));
    for arg in vmargs(install_dir)? {
        if !arg.trim().is_empty() {
            builder = builder.option(arg);
        }
    }
    if !verbose {
        builder = builder.option("-Djava.awt.headless=true");
    }
    let args = builder
        .build()
        .map_err(|source| Error::BuildJvmArgs { source })?;
    JavaVM::new(args).map_err(|source| Error::StartJvm { source })
}

fn resolve_install_dir(explicit: Option<&Path>) -> Result<PathBuf> {
    if let Some(path) = explicit {
        return validate_install_dir(path);
    }
    if let Some(path) = env::var_os("GHIDRA_INSTALL_DIR") {
        return validate_install_dir(Path::new(&path));
    }
    if let Some(path) = lastrun_install_dir()? {
        return validate_install_dir(&path);
    }
    Err(Error::MissingInstallDir)
}

fn validate_install_dir(path: &Path) -> Result<PathBuf> {
    let path = path
        .canonicalize()
        .map_err(|source| Error::CanonicalizeInstallDir {
            path: path.to_path_buf(),
            source,
        })?;
    let properties = path.join("Ghidra/application.properties");
    if !properties.is_file() {
        return Err(Error::MissingInstallFile { path: properties });
    }
    let utility = path.join("Ghidra/Framework/Utility/lib/Utility.jar");
    if !utility.is_file() {
        return Err(Error::MissingInstallFile { path: utility });
    }
    Ok(path)
}

fn lastrun_install_dir() -> Result<Option<PathBuf>> {
    let Some(home) = env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
    else {
        return Ok(None);
    };
    let path = home.join("ghidra/lastrun");
    if !path.is_file() {
        return Ok(None);
    }
    let value = fs::read_to_string(&path).map_err(|source| Error::ReadLastrun {
        path: path.clone(),
        source,
    })?;
    Ok(Some(PathBuf::from(value.trim())))
}

fn classpath(install_dir: &Path, extra_classpaths: &[PathBuf]) -> Result<String> {
    let mut paths = ghidra_jars(install_dir)
        .unwrap_or_else(|_| vec![install_dir.join("Ghidra/Framework/Utility/lib/Utility.jar")]);
    paths.extend(extra_classpaths.iter().cloned());
    Ok(env::join_paths(paths)
        .map_err(|source| Error::Classpath { source })?
        .to_string_lossy()
        .into_owned())
}

fn ghidra_jars(install_dir: &Path) -> Result<Vec<PathBuf>> {
    let mut jars = Vec::new();
    visit_jars(&install_dir.join("Ghidra"), &mut jars)?;
    jars.sort();
    Ok(jars)
}

fn visit_jars(path: &Path, jars: &mut Vec<PathBuf>) -> Result<()> {
    for entry in fs::read_dir(path).map_err(|source| Error::ReadDir {
        path: path.to_path_buf(),
        source,
    })? {
        let entry = entry.map_err(|source| Error::ReadDir {
            path: path.to_path_buf(),
            source,
        })?;
        let path = entry.path();
        if path.is_dir() {
            visit_jars(&path, jars)?;
        } else if path.extension().is_some_and(|ext| ext == "jar") {
            jars.push(path);
        }
    }
    Ok(())
}

fn vmargs(install_dir: &Path) -> Result<Vec<String>> {
    let properties = install_dir.join("support/launch.properties");
    let content = fs::read_to_string(&properties).map_err(|source| Error::ReadFile {
        path: properties.clone(),
        source,
    })?;
    let platform_suffix = if cfg!(target_os = "macos") {
        "MACOS"
    } else if cfg!(target_os = "windows") {
        "WINDOWS"
    } else {
        "LINUX"
    };
    let mut args = Vec::new();
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some(value) = line.strip_prefix("VMARGS=") {
            args.push(unquote(value));
        } else if let Some((prefix, value)) = line.split_once('=')
            && prefix == format!("VMARGS_{platform_suffix}")
        {
            args.push(unquote(value));
        }
    }
    Ok(args)
}

fn unquote(value: &str) -> String {
    let value = value.trim();
    value
        .strip_prefix('"')
        .and_then(|s| s.strip_suffix('"'))
        .unwrap_or(value)
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unquotes_launch_values() {
        assert_eq!(unquote("\"-Dfoo=bar\""), "-Dfoo=bar");
        assert_eq!(unquote("-Dfoo=bar"), "-Dfoo=bar");
    }

    #[test]
    fn classpath_uses_platform_separator() {
        let joined = env::join_paths(["alpha", "beta"]).expect("paths join");

        assert_eq!(
            env::split_paths(&joined).collect::<Vec<_>>(),
            vec![PathBuf::from("alpha"), PathBuf::from("beta")]
        );
    }
}