ghidra 0.0.3

Typed Rust bindings for an embedded Ghidra JVM
Documentation
use std::{
    fmt,
    path::{Path, PathBuf},
    time::Duration,
};

use serde::{Deserialize, Serialize};

use crate::{Error, Result, TaskMonitorOptions, TaskMonitorReport};

/// Program path inside a Ghidra project.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramPath {
    folder: String,
    name: String,
}

/// Loader argument passed to Ghidra during import.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoaderArgument {
    pub name: String,
    pub value: String,
}

/// Options for opening or importing a binary into a project.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramLoadOptions {
    pub binary: PathBuf,
    pub path: ProgramPath,
    pub loader: Option<String>,
    pub language: Option<String>,
    pub compiler: Option<String>,
    pub loader_args: Vec<LoaderArgument>,
    pub monitor: TaskMonitorOptions,
}

/// Options for opening an already-imported program.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramOpenOptions {
    pub path: ProgramPath,
}

/// Result metadata from opening or importing a program.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProgramLoadInfo {
    pub opened_existing: bool,
    pub project_path: String,
    pub folder: String,
    pub name: String,
    #[serde(default)]
    pub loader: Option<String>,
    pub language: String,
    pub compiler: String,
    pub monitor: TaskMonitorReport,
}

/// Analysis mode to apply before extraction or API reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnalysisMode {
    Skip,
    IfNeeded,
    Force,
}

/// Analysis options for a program.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnalysisOptions {
    pub mode: AnalysisMode,
    pub monitor: TaskMonitorOptions,
}

/// Result metadata from a Ghidra analysis request.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnalysisReport {
    pub mode: AnalysisMode,
    pub analyzed: bool,
    pub already_analyzed: bool,
    pub message_log: String,
    pub monitor: TaskMonitorReport,
}

impl ProgramPath {
    /// Creates a root-level project path.
    pub fn root(name: impl Into<String>) -> Result<Self> {
        Self::new("/", name)
    }

    /// Creates a project path from a folder and program name.
    pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Result<Self> {
        let folder = normalize_folder(folder.into())?;
        let name = name.into();
        validate_name(&name)?;
        Ok(Self { folder, name })
    }

    /// Parses a full project path such as `/folder/program`.
    pub fn from_project_path(path: impl AsRef<str>) -> Result<Self> {
        let path = path.as_ref();
        if !path.starts_with('/') {
            return Err(Error::invalid_input("program path must start with /"));
        }
        if path.starts_with("//") {
            return Err(Error::invalid_input("program path must not start with //"));
        }
        let Some((folder, name)) = path.rsplit_once('/') else {
            return Err(Error::invalid_input(
                "program path must include a program name",
            ));
        };
        let folder = if folder.is_empty() { "/" } else { folder };
        Self::new(folder, name)
    }

    /// Returns the project folder path.
    pub fn folder(&self) -> &str {
        &self.folder
    }

    /// Returns the program name.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns the full Ghidra project path.
    pub fn as_project_path(&self) -> String {
        if self.folder == "/" {
            format!("/{}", self.name)
        } else {
            format!("{}/{}", self.folder, self.name)
        }
    }
}

impl fmt::Display for ProgramPath {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.as_project_path())
    }
}

impl ProgramLoadOptions {
    /// Creates load options using the binary file name as a root-level program path.
    pub fn new(binary: impl Into<PathBuf>) -> Result<Self> {
        let binary = binary.into();
        let name = file_name(&binary)?;
        Ok(Self {
            binary,
            path: ProgramPath::root(name)?,
            loader: None,
            language: None,
            compiler: None,
            loader_args: Vec::new(),
            monitor: TaskMonitorOptions::none(),
        })
    }

    /// Sets the target project path.
    pub fn with_path(mut self, path: ProgramPath) -> Self {
        self.path = path;
        self
    }

    /// Sets the Ghidra loader name.
    pub fn with_loader(mut self, loader: impl Into<String>) -> Self {
        self.loader = Some(loader.into());
        self
    }

    /// Sets the Ghidra language identifier.
    pub fn with_language(mut self, language: impl Into<String>) -> Self {
        self.language = Some(language.into());
        self
    }

    /// Sets the Ghidra compiler specification identifier.
    pub fn with_compiler(mut self, compiler: impl Into<String>) -> Self {
        self.compiler = Some(compiler.into());
        self
    }

    /// Adds one loader argument.
    pub fn with_loader_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.loader_args.push(LoaderArgument {
            name: name.into(),
            value: value.into(),
        });
        self
    }

    /// Sets task monitor options for import/open.
    pub fn with_monitor(mut self, monitor: TaskMonitorOptions) -> Self {
        self.monitor = monitor;
        self
    }

    /// Sets a task monitor timeout for import/open.
    pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
        self.monitor = TaskMonitorOptions::timeout(timeout)?;
        Ok(self)
    }
}

impl ProgramOpenOptions {
    /// Creates options for opening a program at `path`.
    pub fn new(path: ProgramPath) -> Self {
        Self { path }
    }
}

impl AnalysisOptions {
    /// Skips analysis.
    pub fn skip() -> Self {
        Self {
            mode: AnalysisMode::Skip,
            monitor: TaskMonitorOptions::none(),
        }
    }

    /// Runs analysis only when Ghidra considers it needed.
    pub fn if_needed() -> Self {
        Self {
            mode: AnalysisMode::IfNeeded,
            monitor: TaskMonitorOptions::none(),
        }
    }

    /// Forces analysis even if the program was previously analyzed.
    pub fn force() -> Self {
        Self {
            mode: AnalysisMode::Force,
            monitor: TaskMonitorOptions::none(),
        }
    }

    /// Sets task monitor options for analysis.
    pub fn with_monitor(mut self, monitor: TaskMonitorOptions) -> Self {
        self.monitor = monitor;
        self
    }

    /// Sets a task monitor timeout for analysis.
    pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
        self.monitor = TaskMonitorOptions::timeout(timeout)?;
        Ok(self)
    }
}

impl Default for AnalysisOptions {
    fn default() -> Self {
        Self::if_needed()
    }
}

impl AnalysisMode {
    pub(crate) fn as_bridge_str(self) -> &'static str {
        match self {
            Self::Skip => "skip",
            Self::IfNeeded => "if_needed",
            Self::Force => "force",
        }
    }
}

fn file_name(path: &Path) -> Result<String> {
    path.file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .map(str::to_owned)
        .ok_or_else(|| Error::invalid_input("binary path must have a valid file name"))
}

fn normalize_folder(folder: String) -> Result<String> {
    if folder.is_empty() {
        return Err(Error::invalid_input("program folder must not be empty"));
    }
    if !folder.starts_with('/') {
        return Err(Error::invalid_input("program folder must start with /"));
    }
    if folder.len() > 1 && folder.ends_with('/') {
        return Err(Error::invalid_input("program folder must not end with /"));
    }
    if folder == "/" {
        return Ok(folder);
    }
    if folder
        .split('/')
        .skip(1)
        .any(|part| part.is_empty() || part == "." || part == "..")
    {
        return Err(Error::invalid_input(
            "program folder must not contain empty, . or .. segments",
        ));
    }
    Ok(folder)
}

fn validate_name(name: &str) -> Result<()> {
    if name.is_empty() {
        return Err(Error::invalid_input("program name must not be empty"));
    }
    if name.contains('/') {
        return Err(Error::invalid_input("program name must not contain /"));
    }
    if name == "." || name == ".." {
        return Err(Error::invalid_input("program name must not be . or .."));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::{AnalysisMode, AnalysisOptions, ProgramLoadOptions, ProgramPath};

    #[test]
    fn program_path_builds_root_paths() {
        let path = ProgramPath::root("sample").expect("valid path");
        assert_eq!(path.folder(), "/");
        assert_eq!(path.name(), "sample");
        assert_eq!(path.as_project_path(), "/sample");
    }

    #[test]
    fn program_path_builds_nested_paths() {
        let path = ProgramPath::new("/firmware/tests", "sample").expect("valid path");
        assert_eq!(path.folder(), "/firmware/tests");
        assert_eq!(path.name(), "sample");
        assert_eq!(path.to_string(), "/firmware/tests/sample");
    }

    #[test]
    fn program_path_parses_project_paths() {
        assert_eq!(
            ProgramPath::from_project_path("/firmware/sample")
                .expect("valid path")
                .as_project_path(),
            "/firmware/sample"
        );
    }

    #[test]
    fn program_path_rejects_invalid_paths() {
        assert!(ProgramPath::root("").is_err());
        assert!(ProgramPath::new("relative", "sample").is_err());
        assert!(ProgramPath::new("/folder/", "sample").is_err());
        assert!(ProgramPath::new("/folder//nested", "sample").is_err());
        assert!(ProgramPath::new("/folder", "bad/name").is_err());
        assert!(ProgramPath::from_project_path("sample").is_err());
        assert!(ProgramPath::from_project_path("//sample").is_err());
    }

    #[test]
    fn load_options_default_to_root_program_name() {
        let options = ProgramLoadOptions::new("/tmp/sample.bin").expect("valid options");
        assert_eq!(options.path.as_project_path(), "/sample.bin");
        assert!(options.loader.is_none());
        assert!(options.language.is_none());
        assert!(options.compiler.is_none());
        assert!(options.loader_args.is_empty());
        assert!(options.monitor.timeout_duration().is_none());
    }

    #[test]
    fn analysis_options_default_to_if_needed() {
        assert_eq!(AnalysisOptions::default().mode, AnalysisMode::IfNeeded);
        assert_eq!(AnalysisOptions::skip().mode, AnalysisMode::Skip);
        assert_eq!(AnalysisOptions::force().mode, AnalysisMode::Force);
        assert!(
            AnalysisOptions::default()
                .monitor
                .timeout_duration()
                .is_none()
        );
    }

    #[test]
    fn load_and_analysis_options_accept_monitor_timeouts() {
        let load = ProgramLoadOptions::new("/tmp/sample.bin")
            .expect("valid options")
            .with_timeout(Duration::from_secs(5))
            .expect("valid timeout");
        assert_eq!(
            load.monitor.timeout_seconds().expect("valid timeout"),
            Some(5)
        );

        let analysis = AnalysisOptions::force()
            .with_timeout(Duration::from_millis(1))
            .expect("valid timeout");
        assert_eq!(
            analysis.monitor.timeout_seconds().expect("valid timeout"),
            Some(1)
        );
    }
}