1use std::collections::HashMap;
13use std::path::PathBuf;
14use std::time::Duration;
15
16pub async fn dispatch(
32 product: &str,
33 version: &str,
34 args: Vec<String>,
35 file_config: crate::config::RawConfig,
36) -> i32 {
37 let mut it = args.into_iter();
38 match it.next().as_deref() {
39 Some("serve") => match run_serve(product, it.collect(), file_config).await {
40 Ok(()) => 0,
41 Err(code) => code,
42 },
43 Some("emit-skill") => {
44 print!("{}", crate::skill_asset::emit_bytes());
50 0
51 }
52 Some("boot-smoke") => {
53 tracing_subscriber::fmt()
54 .with_writer(std::io::stderr)
55 .init();
56 boot_smoke().await;
57 0
58 }
59 Some("--version" | "-V" | "--help" | "-h") => {
60 usage(product, version);
61 0
62 }
63 _ => {
64 usage(product, version);
65 2
66 }
67 }
68}
69
70fn usage(product: &str, version: &str) {
74 eprintln!("{product} {version}");
75 eprintln!(
76 "usage: {product} serve (env: GHIDRA_INSTALL_DIR, GHIDRA_MCP_PROJECT_DIR, \
77 GHIDRA_MCP_PROJECT_NAME, GHIDRA_MCP_BOOTSTRAP_PROGRAM)"
78 );
79 eprintln!(" {product} emit-skill (writes the embedded driver skill to stdout)");
80 eprintln!(" {product} boot-smoke (dev; env: GHIDRA_INSTALL_DIR, GHIDRA_MCP_FIXTURE_*)");
81}
82
83async fn run_serve(
87 product: &str,
88 cli: Vec<String>,
89 file_config: crate::config::RawConfig,
90) -> Result<(), i32> {
91 let raw = layered_config(cli, file_config);
96 let log_path = crate::paths::instance_log_path();
97 let _guard = match crate::logging::init_file_logging(&log_path) {
98 Ok(g) => g,
99 Err(e) => {
100 eprintln!(
101 "{product} serve: cannot initialize log at {}: {e}",
102 log_path.display()
103 );
104 return Err(3);
105 }
106 };
107 tracing::info!(?log_path, product, "serve starting");
108 if let Err(e) = crate::server::serve(raw).await {
109 tracing::error!("serve terminated: {e}");
110 return Err(1);
111 }
112 Ok(())
113}
114
115pub fn layered_config(
127 cli: Vec<String>,
128 file_config: crate::config::RawConfig,
129) -> crate::config::RawConfig {
130 layer(parse_flags(&cli), file_config, env_or_none)
131}
132
133fn layer<F>(
137 flags: HashMap<String, String>,
138 file: crate::config::RawConfig,
139 get_env: F,
140) -> crate::config::RawConfig
141where
142 F: Fn(&str) -> Option<String>,
143{
144 let pick = |flag: &str, env: &str, from_file: Option<String>| {
148 flags
149 .get(flag)
150 .cloned()
151 .or_else(|| get_env(env).filter(|s| !s.is_empty()))
152 .or(from_file)
153 };
154 crate::config::RawConfig {
155 ghidra_install_dir: pick(
156 "ghidra-install-dir",
157 "GHIDRA_INSTALL_DIR",
158 file.ghidra_install_dir,
159 ),
160 project_dir: pick("project-dir", "GHIDRA_MCP_PROJECT_DIR", file.project_dir),
161 project_name: pick("project-name", "GHIDRA_MCP_PROJECT_NAME", file.project_name),
162 bootstrap_program: pick(
163 "bootstrap-program",
164 "GHIDRA_MCP_BOOTSTRAP_PROGRAM",
165 file.bootstrap_program,
166 ),
167 bootstrap_program_path: pick(
168 "bootstrap-program-path",
169 "GHIDRA_MCP_BOOTSTRAP_PROGRAM_PATH",
170 file.bootstrap_program_path,
171 ),
172 max_heap: pick("max-heap", "GHIDRA_MCP_MAX_HEAP", file.max_heap),
173 }
174}
175
176fn env_or_none(name: &str) -> Option<String> {
177 std::env::var(name).ok().filter(|s| !s.is_empty())
178}
179
180fn parse_flags(args: &[String]) -> HashMap<String, String> {
184 let mut map = HashMap::new();
185 let mut i = 0;
186 while i < args.len() {
187 if let Some(rest) = args[i].strip_prefix("--") {
188 if let Some((k, v)) = rest.split_once('=') {
189 map.insert(k.to_string(), v.to_string());
190 i += 1;
191 } else if i + 1 < args.len() {
192 map.insert(rest.to_string(), args[i + 1].clone());
193 i += 2;
194 } else {
195 i += 1;
196 }
197 } else {
198 i += 1;
199 }
200 }
201 map
202}
203
204async fn boot_smoke() {
207 let script_dir = std::env::temp_dir().join("ghidra-mcp-smoke-scripts");
208 if let Err(e) = crate::worker_asset_extract(&script_dir) {
209 eprintln!(
210 "boot-smoke: cannot extract worker script to {}: {e}",
211 script_dir.display()
212 );
213 std::process::exit(1);
214 }
215 let cfg = ghidra_worker_ctl::config::WorkerConfig {
216 ghidra_install_dir: PathBuf::from(env("GHIDRA_INSTALL_DIR")),
217 project_dir: PathBuf::from(env("GHIDRA_MCP_FIXTURE_PROJECT_DIR")),
218 project_name: env("GHIDRA_MCP_FIXTURE_PROJECT_NAME"),
219 bootstrap_program: env("GHIDRA_MCP_FIXTURE_PROGRAM"),
220 script_dir,
221 script_name: crate::worker_asset::WORKER_SCRIPT_NAME.to_string(),
222 max_heap: None,
223 boot_timeout: Duration::from_secs(120),
224 };
225 let mut w = match ghidra_worker_ctl::boot::boot_worker(&cfg).await {
226 Ok(w) => w,
227 Err(e) => {
228 eprintln!("boot-smoke: worker failed to boot: {e}");
229 eprintln!("(if it hangs on Windows, check EDR/firewall on the loopback bind)");
230 std::process::exit(1);
231 }
232 };
233 eprintln!("booted worker: {:?}", w.announce);
234 let program = env("GHIDRA_MCP_FIXTURE_PROGRAM");
235 let func = env("GHIDRA_MCP_FIXTURE_FUNCTION");
236 let attach = w
237 .conn
238 .request(&mk(
239 1,
240 "attach_program",
241 serde_json::json!({ "program_path": program }),
242 ))
243 .await
244 .unwrap();
245 eprintln!("attach: {attach:?}");
246 let dec = w
247 .conn
248 .request(&mk(
249 2,
250 "decompile_function",
251 serde_json::json!({ "name": func }),
252 ))
253 .await
254 .unwrap();
255 eprintln!("decompile: {dec:?}");
256 let _ = w.conn.send(&mk(3, "shutdown", serde_json::json!({}))).await;
257}
258
259fn env(name: &str) -> String {
260 std::env::var(name).unwrap_or_else(|_| {
263 eprintln!("boot-smoke: missing env var {name}");
264 std::process::exit(2);
265 })
266}
267
268fn mk(id: i64, method: &str, params: serde_json::Value) -> ghidra_ipc::rpc::RpcRequest {
269 ghidra_ipc::rpc::RpcRequest {
270 jsonrpc: ghidra_ipc::rpc::JsonRpcVersion,
271 id: ghidra_ipc::rpc::RpcId::Number(id),
272 method: method.to_string(),
273 params: Some(params),
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[tokio::test]
283 async fn unknown_command_exits_2() {
284 let code = dispatch(
285 "p",
286 "0",
287 vec!["bogus".into()],
288 crate::config::RawConfig::default(),
289 )
290 .await;
291 assert_eq!(code, 2);
292 let code = dispatch("p", "0", vec![], crate::config::RawConfig::default()).await;
293 assert_eq!(code, 2);
294 }
295
296 #[tokio::test]
298 async fn version_and_help_flags_exit_0() {
299 for flag in ["--version", "-V", "--help", "-h"] {
300 let code = dispatch(
301 "p",
302 "0",
303 vec![flag.into()],
304 crate::config::RawConfig::default(),
305 )
306 .await;
307 assert_eq!(code, 0, "{flag}");
308 }
309 }
310
311 #[test]
312 fn parses_both_flag_spellings() {
313 let f = parse_flags(&[
314 "--project-name".into(),
315 "proj".into(),
316 "--max-heap=4G".into(),
317 ]);
318 assert_eq!(f.get("project-name").map(String::as_str), Some("proj"));
319 assert_eq!(f.get("max-heap").map(String::as_str), Some("4G"));
320 }
321
322 #[test]
324 fn dangling_flag_is_dropped_not_panicked() {
325 let f = parse_flags(&["--project-name".into()]);
326 assert!(f.is_empty());
327 }
328
329 #[test]
331 fn positional_tokens_are_ignored() {
332 let f = parse_flags(&["stray".into(), "--max-heap=2G".into()]);
333 assert_eq!(f.len(), 1);
334 assert_eq!(f.get("max-heap").map(String::as_str), Some("2G"));
335 }
336
337 fn file_layer() -> crate::config::RawConfig {
338 crate::config::RawConfig {
339 project_name: Some("from-file".into()),
340 project_dir: Some("dir-from-file".into()),
341 ..Default::default()
342 }
343 }
344
345 #[test]
349 fn flag_beats_env_beats_file() {
350 let flags = parse_flags(&["--project-name".into(), "from-flag".into()]);
351 let env = |k: &str| match k {
352 "GHIDRA_MCP_PROJECT_NAME" => Some("from-env".to_string()),
353 "GHIDRA_MCP_PROJECT_DIR" => Some("dir-from-env".to_string()),
354 _ => None,
355 };
356 let merged = layer(flags, file_layer(), env);
357 assert_eq!(merged.project_name.as_deref(), Some("from-flag"));
358 assert_eq!(merged.project_dir.as_deref(), Some("dir-from-env"));
359 }
360
361 #[test]
365 fn file_layer_supplies_values_nothing_else_set() {
366 let merged = layer(HashMap::new(), file_layer(), |_| None);
367 assert_eq!(merged.project_name.as_deref(), Some("from-file"));
368 assert_eq!(merged.project_dir.as_deref(), Some("dir-from-file"));
369 assert_eq!(merged.bootstrap_program, None);
370 }
371
372 #[test]
376 fn empty_env_does_not_shadow_the_file() {
377 let merged = layer(HashMap::new(), file_layer(), |k| {
378 (k == "GHIDRA_MCP_PROJECT_NAME").then(String::new)
379 });
380 assert_eq!(merged.project_name.as_deref(), Some("from-file"));
381 }
382}