Skip to main content

re_ghidra_mcp_qwen/
lib.rs

1//! Qwen-Code-specific glue for the `re-ghidra-mcp-qwen` extension.
2//!
3//! The server, its 19 tools and the Ghidra worker all live in the shared
4//! `ghidra-mcp` crate. What is genuinely Qwen-Code-shaped lives here: reading
5//! `.qwen/re-ghidra-mcp-qwen.local.md`, and the preflight the `SessionStart`
6//! hook reports.
7//!
8//! Why a settings file matters more for this extension than for most: **one server
9//! is one Ghidra project.** The project directory, project name and bootstrap
10//! program are per-workspace by nature, so without a settings layer every repo
11//! needs a hand-written `mcpServers` block in `qwen-extension.json` that duplicates
12//! the extension's registration just to carry three strings.
13
14use ghidra_mcp::config::RawConfig;
15use std::path::{Path, PathBuf};
16
17// ── Settings ───────────────────────────────────────────────────────────
18
19/// The settings-file layer, in the shape the shared config resolver consumes.
20///
21/// Precedence is decided in `ghidra_mcp::cli::layered_config`, not here: CLI
22/// flag, then environment, then this. Returning a [`RawConfig`] rather than a
23/// bespoke struct is what keeps that single rule single.
24pub fn settings_from_markdown(source: &str) -> RawConfig {
25    let mut config = RawConfig::default();
26    let Some(frontmatter) = extract_frontmatter(source) else {
27        return config;
28    };
29
30    for line in frontmatter.lines() {
31        let line = line.trim();
32        if line.is_empty() || line.starts_with('#') {
33            continue;
34        }
35        let Some((key, value)) = line.split_once(':') else {
36            continue;
37        };
38        let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
39        if value.is_empty() {
40            continue;
41        }
42        let value = Some(value.to_string());
43        match key.trim() {
44            "ghidra_install_dir" => config.ghidra_install_dir = value,
45            "project_dir" => config.project_dir = value,
46            "project_name" => config.project_name = value,
47            "bootstrap_program" => config.bootstrap_program = value,
48            "bootstrap_program_path" => config.bootstrap_program_path = value,
49            "max_heap" => config.max_heap = value,
50            // An unknown key is ignored rather than fatal: a settings file
51            // written against a newer plugin must not stop the server booting.
52            _ => {}
53        }
54    }
55    config
56}
57
58/// The settings file this extension reads, relative to a project root.
59pub fn settings_path(project_dir: &Path) -> PathBuf {
60    project_dir
61        .join(".qwen")
62        .join("re-ghidra-mcp-qwen.local.md")
63}
64
65/// Load the settings layer for `project_dir`. A missing or unreadable file is
66/// not an error — it simply contributes nothing, and the environment or CLI
67/// flags supply the values instead.
68pub fn load_settings(project_dir: &Path) -> RawConfig {
69    match std::fs::read_to_string(settings_path(project_dir)) {
70        Ok(text) => settings_from_markdown(&text),
71        Err(_) => RawConfig::default(),
72    }
73}
74
75/// Extract the YAML frontmatter block from a `.local.md` file.
76///
77/// Tolerates a leading BOM and CRLF line endings: the file is hand-edited on
78/// Windows, where both are routine, and a settings file that silently stops
79/// being read because an editor added a BOM is a miserable thing to debug.
80fn extract_frontmatter(source: &str) -> Option<&str> {
81    let source = source.strip_prefix('\u{feff}').unwrap_or(source);
82    let rest = source.strip_prefix("---")?;
83    let rest = rest.strip_prefix('\r').unwrap_or(rest);
84    let rest = rest.strip_prefix('\n')?;
85    let end = rest.find("\n---")?;
86    Some(&rest[..end])
87}
88
89// ── Preflight ──────────────────────────────────────────────────────────
90
91/// What the environment looks like to the preflight, gathered by the caller.
92///
93/// The checks themselves are pure so they can be tested without a Ghidra
94/// install, a JDK, or a mutated process environment. The binary does the IO;
95/// this decides what it means.
96#[derive(Debug, Clone, Default)]
97pub struct Probe {
98    /// Whether `<ghidra_install_dir>/support/analyzeHeadless[.bat]` exists.
99    pub launcher_present: bool,
100    /// Major version reported by `java -version`, if a `java` was reachable.
101    pub java_major: Option<u32>,
102}
103
104/// Ghidra 12.1.2 declares `application.java.min=21`; anything older fails at
105/// JVM start with a message that does not mention the version.
106pub const MIN_JAVA_MAJOR: u32 = 21;
107
108/// Report what is missing, in the order a user should fix it. An empty vector
109/// means every cheap check passed — and the hook then says nothing at all.
110///
111/// Deliberately *cheap*: no JVM spawn, no Ghidra project open, no lock probe.
112/// This runs on every session start, so it may cost milliseconds, not seconds.
113pub fn preflight(cfg: &RawConfig, probe: &Probe) -> Vec<String> {
114    let mut findings = Vec::new();
115
116    match cfg.ghidra_install_dir.as_deref() {
117        None => findings.push(
118            "GHIDRA_INSTALL_DIR is not set. Point it at your Ghidra install root \
119             (the directory containing `support/`)."
120                .to_string(),
121        ),
122        Some(dir) if !probe.launcher_present => findings.push(format!(
123            "GHIDRA_INSTALL_DIR is set to `{dir}` but there is no \
124             `support/analyzeHeadless` there, so it is not a Ghidra install root."
125        )),
126        Some(_) => {}
127    }
128
129    match probe.java_major {
130        None => findings.push(
131            "No `java` on PATH. Ghidra 12.1.2 requires JDK 21 \
132             (`application.java.min=21`)."
133                .to_string(),
134        ),
135        Some(v) if v < MIN_JAVA_MAJOR => findings.push(format!(
136            "`java` on PATH reports version {v}; Ghidra 12.1.2 requires \
137             JDK {MIN_JAVA_MAJOR} or newer."
138        )),
139        Some(_) => {}
140    }
141
142    // The three per-project values. Reported as one finding rather than three:
143    // they are always configured together, in the same place.
144    let missing: Vec<&str> = [
145        ("project_dir", cfg.project_dir.is_none()),
146        ("project_name", cfg.project_name.is_none()),
147        ("bootstrap_program", cfg.bootstrap_program.is_none()),
148    ]
149    .into_iter()
150    .filter_map(|(name, absent)| absent.then_some(name))
151    .collect();
152    if !missing.is_empty() {
153        findings.push(format!(
154            "Unset: {}. Set them in `.qwen/re-ghidra-mcp-qwen.local.md` \
155             (see the extension's examples/ directory) or as \
156             GHIDRA_MCP_PROJECT_DIR / GHIDRA_MCP_PROJECT_NAME / \
157             GHIDRA_MCP_BOOTSTRAP_PROGRAM.",
158            missing.join(", ")
159        ));
160    }
161
162    findings
163}
164
165/// Parse the major version out of `java -version` output.
166///
167/// The output goes to **stderr**, and the two shapes in the wild are
168/// `"21.0.2"` (modern) and `"1.8.0_412"` (legacy, where the major is the
169/// second component). Returning `None` for anything unrecognized keeps a
170/// surprising vendor string from being read as a version far too low.
171pub fn parse_java_major(version_output: &str) -> Option<u32> {
172    let quoted = version_output.split('"').nth(1)?;
173    let mut parts = quoted.split(['.', '_', '-']);
174    let first: u32 = parts.next()?.parse().ok()?;
175    if first == 1 {
176        parts.next()?.parse().ok()
177    } else {
178        Some(first)
179    }
180}
181
182#[cfg(test)]
183mod preflight_tests {
184    use super::*;
185
186    fn complete() -> RawConfig {
187        RawConfig {
188            ghidra_install_dir: Some("C:/ghidra".into()),
189            project_dir: Some("C:/re".into()),
190            project_name: Some("p".into()),
191            bootstrap_program: Some("add.exe".into()),
192            ..Default::default()
193        }
194    }
195
196    fn good_probe() -> Probe {
197        Probe {
198            launcher_present: true,
199            java_major: Some(21),
200        }
201    }
202
203    /// The silent path. If this ever starts reporting something, the hook goes
204    /// from invisible to noisy on every single session.
205    #[test]
206    fn fully_configured_reports_nothing() {
207        assert!(preflight(&complete(), &good_probe()).is_empty());
208    }
209
210    #[test]
211    fn install_dir_set_but_not_a_ghidra_root_is_reported() {
212        let probe = Probe {
213            launcher_present: false,
214            ..good_probe()
215        };
216        let f = preflight(&complete(), &probe);
217        assert_eq!(f.len(), 1);
218        assert!(f[0].contains("analyzeHeadless"), "{}", f[0]);
219    }
220
221    #[test]
222    fn old_java_is_reported_with_its_version() {
223        let probe = Probe {
224            java_major: Some(17),
225            ..good_probe()
226        };
227        let f = preflight(&complete(), &probe);
228        assert_eq!(f.len(), 1);
229        assert!(f[0].contains("17") && f[0].contains("21"), "{}", f[0]);
230    }
231
232    #[test]
233    fn absent_java_is_reported() {
234        let probe = Probe {
235            java_major: None,
236            ..good_probe()
237        };
238        assert!(preflight(&complete(), &probe)[0].contains("No `java` on PATH"));
239    }
240
241    /// The three per-project values collapse into one finding, naming exactly
242    /// the ones that are missing.
243    #[test]
244    fn missing_project_values_are_one_finding_naming_each() {
245        let cfg = RawConfig {
246            project_name: None,
247            bootstrap_program: None,
248            ..complete()
249        };
250        let f = preflight(&cfg, &good_probe());
251        assert_eq!(f.len(), 1);
252        assert!(f[0].contains("project_name"), "{}", f[0]);
253        assert!(f[0].contains("bootstrap_program"), "{}", f[0]);
254        assert!(!f[0].contains("project_dir,"), "{}", f[0]);
255    }
256
257    #[test]
258    fn parses_modern_and_legacy_java_version_strings() {
259        assert_eq!(
260            parse_java_major("openjdk version \"21.0.2\" 2024-01-16"),
261            Some(21)
262        );
263        assert_eq!(
264            parse_java_major("java version \"1.8.0_412\"\nJava(TM) SE Runtime"),
265            Some(8)
266        );
267        assert_eq!(
268            parse_java_major("openjdk version \"25\" 2025-09-16"),
269            Some(25)
270        );
271    }
272
273    /// Unparseable output must read as "unknown", never as a low version — the
274    /// latter would produce a confidently wrong "upgrade your JDK" message.
275    #[test]
276    fn unrecognized_version_output_is_none() {
277        assert_eq!(parse_java_major("command not found"), None);
278        assert_eq!(parse_java_major("openjdk version \"weird\""), None);
279        assert_eq!(parse_java_major(""), None);
280    }
281}
282
283#[cfg(test)]
284mod settings_tests {
285    use super::*;
286
287    const SAMPLE: &str = "---\nproject_dir: C:\\re\\projects\nproject_name: crackme\nbootstrap_program: add.exe\n---\n\n# notes\n";
288
289    #[test]
290    fn reads_the_frontmatter_keys() {
291        let c = settings_from_markdown(SAMPLE);
292        assert_eq!(c.project_dir.as_deref(), Some("C:\\re\\projects"));
293        assert_eq!(c.project_name.as_deref(), Some("crackme"));
294        assert_eq!(c.bootstrap_program.as_deref(), Some("add.exe"));
295        assert_eq!(c.max_heap, None);
296    }
297
298    #[test]
299    fn crlf_and_bom_still_parse() {
300        let crlf = format!("\u{feff}{}", SAMPLE.replace('\n', "\r\n"));
301        let c = settings_from_markdown(&crlf);
302        assert_eq!(c.project_name.as_deref(), Some("crackme"));
303    }
304
305    /// A file with no frontmatter, or an unterminated block, contributes
306    /// nothing rather than half-parsing into a confusing partial config.
307    #[test]
308    fn no_frontmatter_yields_nothing() {
309        assert!(settings_from_markdown("# just a heading\n")
310            .project_name
311            .is_none());
312        assert!(settings_from_markdown("---\nproject_name: x\n")
313            .project_name
314            .is_none());
315    }
316
317    /// A key present but blank must not resolve to an empty project name — that
318    /// would pass the "is it set?" check and then fail deep inside Ghidra.
319    #[test]
320    fn blank_value_is_not_a_value() {
321        assert!(settings_from_markdown("---\nproject_name:   \n---\n")
322            .project_name
323            .is_none());
324    }
325
326    #[test]
327    fn unknown_keys_are_ignored() {
328        let c = settings_from_markdown("---\nfuture_key: 1\nproject_name: p\n---\n");
329        assert_eq!(c.project_name.as_deref(), Some("p"));
330    }
331}