Skip to main content

ghidra_mcp/
worker_asset.rs

1//! Embed the worker `.java` in the binary and extract it to a per-user script dir at boot, keyed
2//! by content hash so the extracted copy always matches the compiled binary (spec §12). Only the
3//! thin-slice extraction primitive; the §6 extraction-integrity hardening (0700/0600, ownership,
4//! symlink guards) is layered in a later milestone.
5
6use sha2::{Digest, Sha256};
7use std::io;
8use std::path::{Path, PathBuf};
9
10/// The worker script's filename. Ghidra requires a GhidraScript's filename to
11/// match its public class name, so this is not a free choice — and it is a
12/// const rather than three string literals because `config.rs` hands it to the
13/// launcher while `extract_worker` writes the file. Those two disagreeing
14/// produces a worker that is on disk under one name and looked for under
15/// another, which surfaces as an unexplained boot timeout.
16pub const WORKER_SCRIPT_NAME: &str = "GhidraMcpWorker.java";
17
18/// The worker GhidraScript, embedded at compile time (version-locked to this binary).
19pub const WORKER_JAVA: &str = include_str!("../worker/GhidraMcpWorker.java");
20
21/// Extract the worker script into `script_dir`, creating the dir if needed and rewriting the file
22/// only when its content hash differs from the embedded source. Returns the script path.
23pub fn extract_worker(script_dir: &Path) -> io::Result<PathBuf> {
24    std::fs::create_dir_all(script_dir)?;
25    let path = script_dir.join(WORKER_SCRIPT_NAME);
26    let want = hash(WORKER_JAVA.as_bytes());
27    let up_to_date = match std::fs::read(&path) {
28        Ok(existing) => hash(&existing) == want,
29        Err(_) => false,
30    };
31    if !up_to_date {
32        std::fs::write(&path, WORKER_JAVA.as_bytes())?;
33    }
34    Ok(path)
35}
36
37fn hash(bytes: &[u8]) -> [u8; 32] {
38    let mut h = Sha256::new();
39    h.update(bytes);
40    h.finalize().into()
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    #[test]
48    fn embedded_source_is_the_worker_script() {
49        assert!(WORKER_JAVA.contains("class GhidraMcpWorker"));
50        assert!(WORKER_JAVA.contains("worker/init"));
51    }
52
53    #[test]
54    fn extract_writes_then_is_idempotent_by_hash() {
55        let dir = std::env::temp_dir().join(format!("ghidra-mcp-asset-{}", uuid::Uuid::new_v4()));
56        let first = extract_worker(&dir).unwrap();
57        assert!(first.exists());
58        assert_eq!(first.file_name().unwrap(), WORKER_SCRIPT_NAME);
59        let before = std::fs::metadata(&first).unwrap().modified().unwrap();
60        // Second call with identical content must NOT rewrite (same hash).
61        std::thread::sleep(std::time::Duration::from_millis(20));
62        let second = extract_worker(&dir).unwrap();
63        let after = std::fs::metadata(&second).unwrap().modified().unwrap();
64        assert_eq!(before, after, "unchanged content must not be rewritten");
65        std::fs::remove_dir_all(&dir).ok();
66    }
67
68    /// Ghidra resolves a GhidraScript by filename and then requires the public
69    /// class inside to match. A rename that touched only one of the two would
70    /// pass every other test here and fail only against a real Ghidra.
71    #[test]
72    fn script_filename_matches_the_public_class_it_contains() {
73        let stem = WORKER_SCRIPT_NAME
74            .strip_suffix(".java")
75            .expect("worker script is a .java file");
76        assert!(
77            WORKER_JAVA.contains(&format!("class {stem}")),
78            "{WORKER_SCRIPT_NAME} does not declare `class {stem}`"
79        );
80    }
81}