Skip to main content

ghidra_worker_ctl/
config.rs

1//! Worker launch configuration + launcher argv (spec §3/§4). The argv build is a pure function so
2//! it is unit-tested without a Ghidra install. Attach-only (`-process`, not `-import`) because v1
3//! attaches to already-analyzed projects (spec §8.1); writable (NOT `-readOnly`) since M2a's `rename`
4//! write tool needs DomainFile.save(); the worker `.java` blocks in its socket loop so `analyzeHeadless` never
5//! returns and the JVM stays resident (spike D1).
6
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10#[derive(Debug, Clone)]
11pub struct WorkerConfig {
12    /// Root of the Ghidra install (contains `support/analyzeHeadless`).
13    pub ghidra_install_dir: PathBuf,
14    /// Directory that holds the `<project_name>.gpr` / `.rep`.
15    pub project_dir: PathBuf,
16    pub project_name: String,
17    /// Bare program file *name* for the `-process` bootstrap filter (e.g. `add.exe`), NOT a VFS path:
18    /// Ghidra rejects a `/`-prefixed value with "invalid filename specified" (real-Ghidra e2e). The
19    /// containing folder is pinned by the project location, not this field (D1).
20    pub bootstrap_program: String,
21    /// Directory the worker `.java` was extracted to (passed via `-scriptPath`).
22    pub script_dir: PathBuf,
23    /// The worker script filename (e.g. `GhidraMcpWorker.java`).
24    pub script_name: String,
25    /// Optional JVM max-heap (`-Xmx…`) forwarded via env; None = Ghidra default.
26    pub max_heap: Option<String>,
27    /// How long the host waits for a valid worker handshake before giving up.
28    pub boot_timeout: Duration,
29}
30
31impl WorkerConfig {
32    /// Path to the headless launcher for this platform.
33    pub fn analyze_headless_path(&self) -> PathBuf {
34        #[cfg(windows)]
35        let name = "analyzeHeadless.bat";
36        #[cfg(not(windows))]
37        let name = "analyzeHeadless";
38        self.ghidra_install_dir.join("support").join(name)
39    }
40
41    /// Build the full argument vector (excluding argv[0], the launcher itself). The worker
42    /// connects back to `127.0.0.1:port` and reads the token from `token_file`.
43    pub fn build_argv(&self, port: u16, token_file: &Path) -> Vec<String> {
44        let mut argv = vec![
45            self.project_dir.to_string_lossy().into_owned(),
46            self.project_name.clone(),
47            "-process".to_string(),
48            self.bootstrap_program.clone(),
49            // NOT -readOnly: M2a adds the `rename` write tool, which needs a writable program +
50            // DomainFile.save(). A writable boot means the worker holds a write-lock on the project (the
51            // single-resident-worker model tolerates this; it drops the M0 read-only-coexist property).
52            "-noanalysis".to_string(),
53            "-scriptPath".to_string(),
54            self.script_dir.to_string_lossy().into_owned(),
55            "-postScript".to_string(),
56            self.script_name.clone(),
57            port.to_string(),
58            token_file.to_string_lossy().into_owned(),
59        ];
60        argv.shrink_to_fit();
61        argv
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use std::path::PathBuf;
69    use std::time::Duration;
70
71    fn sample() -> WorkerConfig {
72        WorkerConfig {
73            ghidra_install_dir: PathBuf::from(r"C:\ghidra"),
74            project_dir: PathBuf::from(r"C:\proj"),
75            project_name: "p".to_string(),
76            bootstrap_program: "fixture".to_string(),
77            script_dir: PathBuf::from(r"C:\scripts"),
78            script_name: "GhidraMcpWorker.java".to_string(),
79            max_heap: None,
80            boot_timeout: Duration::from_secs(60),
81        }
82    }
83
84    #[test]
85    fn argv_has_process_writable_noanalysis_and_postscript_with_args() {
86        let argv = sample().build_argv(51000, std::path::Path::new(r"C:\t\x.token"));
87        // project location + name come first (analyzeHeadless positional args)
88        assert_eq!(argv[0], r"C:\proj");
89        assert_eq!(argv[1], "p");
90        // attaches to an existing program (not -import), no re-analysis; `-process` takes a bare file
91        // name (a `/`-prefixed value is rejected by Ghidra — real-Ghidra e2e). NOT -readOnly: M2a's
92        // rename write tool needs a writable program (DomainFile.save()).
93        assert!(argv.iter().any(|a| a == "-process"));
94        assert!(argv.iter().any(|a| a == "fixture"));
95        assert!(!argv.iter().any(|a| a == "-readOnly"));
96        assert!(argv.iter().any(|a| a == "-noanalysis"));
97        // script path + the postScript with (port, tokenFile) trailing args
98        let sp = argv.iter().position(|a| a == "-scriptPath").unwrap();
99        assert_eq!(argv[sp + 1], r"C:\scripts");
100        let ps = argv.iter().position(|a| a == "-postScript").unwrap();
101        assert_eq!(argv[ps + 1], "GhidraMcpWorker.java");
102        assert_eq!(argv[ps + 2], "51000");
103        assert_eq!(argv[ps + 3], r"C:\t\x.token");
104    }
105
106    #[test]
107    fn analyze_headless_path_is_platform_launcher_under_support() {
108        let p = sample().analyze_headless_path();
109        let s = p.to_string_lossy();
110        assert!(s.contains("support"));
111        #[cfg(windows)]
112        assert!(s.ends_with("analyzeHeadless.bat"));
113        #[cfg(not(windows))]
114        assert!(s.ends_with("analyzeHeadless"));
115    }
116}