1use std::collections::BTreeMap;
2use std::process::{Command, Stdio};
3
4use crate::error::{AppError, Result};
5use crate::models::{
6 MSFHostCheckResult, MSFHostInfo, MSFHostsResult, MSFLoginResult, MSFModule, MSFWorkspaceInfo,
7 MSFWorkspacesResult,
8};
9use crate::msgpack::{decode, encode, MsgValue};
10
11#[derive(Debug, Clone)]
12pub struct MsfRpcClient {
13 host: String,
14 port: u16,
15 ssl: bool,
16 username: String,
17 password: String,
18 token: Option<String>,
19}
20
21impl MsfRpcClient {
22 pub fn new(host: &str, port: u16, username: &str, password: &str, ssl: bool) -> Self {
23 Self {
24 host: host.to_string(),
25 port,
26 ssl,
27 username: username.to_string(),
28 password: password.to_string(),
29 token: None,
30 }
31 }
32
33 pub fn login(&mut self) -> Result<MSFLoginResult> {
34 let url = build_url(&self.host, self.port, self.ssl);
35 let payload = encode(&msg_array(vec![
37 msg_str("auth.login"),
38 msg_str(&self.username),
39 msg_str(&self.password),
40 ]));
41
42 match send_request(&url, &payload, 10) {
43 Ok(val) => {
44 let success = val.get("result").and_then(MsgValue::as_str) == Some("success");
45 let token = val
46 .get("token")
47 .and_then(MsgValue::as_str)
48 .unwrap_or("")
49 .to_string();
50
51 if success && !token.is_empty() {
52 self.token = Some(token.clone());
53 Ok(MSFLoginResult {
54 success: true,
55 token,
56 error: String::new(),
57 })
58 } else {
59 let error_msg = val
60 .get("error_message")
61 .or_else(|| val.get("error_string"))
62 .and_then(MsgValue::as_str)
63 .unwrap_or("Authentication failed");
64 Ok(MSFLoginResult {
65 success: false,
66 token: String::new(),
67 error: error_msg.to_string(),
68 })
69 }
70 }
71 Err(error) => Ok(MSFLoginResult {
72 success: false,
73 token: String::new(),
74 error,
75 }),
76 }
77 }
78
79 pub fn logout(&mut self) {
80 if let Some(token) = self.token.clone() {
81 let url = build_url(&self.host, self.port, self.ssl);
82 let payload = encode(&msg_array(vec![msg_str("auth.logout"), msg_str(&token)]));
84 let _ = send_request(&url, &payload, 5);
85 }
86 self.token = None;
87 }
88
89 pub fn is_authenticated(&self) -> bool {
90 self.token.is_some()
91 }
92
93 pub fn get_workspaces(&self) -> Result<MSFWorkspacesResult> {
94 let token = match self.token.as_ref() {
95 Some(token) => token.clone(),
96 None => {
97 return Ok(MSFWorkspacesResult {
98 workspaces: vec![],
99 error: "Not authenticated".to_string(),
100 });
101 }
102 };
103
104 let url = build_url(&self.host, self.port, self.ssl);
105 let payload = encode(&msg_array(vec![msg_str("db.workspaces"), msg_str(&token)]));
107
108 match send_request(&url, &payload, 15) {
109 Ok(val) => Ok(MSFWorkspacesResult {
110 workspaces: extract_workspaces(&val),
111 error: String::new(),
112 }),
113 Err(error) => Ok(MSFWorkspacesResult {
114 workspaces: vec![],
115 error,
116 }),
117 }
118 }
119
120 pub fn get_hosts(&self, workspace: Option<&str>) -> Result<MSFHostsResult> {
121 let token = match self.token.as_ref() {
122 Some(token) => token.clone(),
123 None => {
124 return Ok(MSFHostsResult {
125 hosts: vec![],
126 error: "Not authenticated".to_string(),
127 });
128 }
129 };
130
131 let url = build_url(&self.host, self.port, self.ssl);
132 let mut params = vec![msg_str("db.hosts"), msg_str(&token)];
136 if let Some(workspace) = workspace {
137 params.push(MsgValue::Map(BTreeMap::from([(
138 "workspace".to_string(),
139 msg_str(workspace),
140 )])));
141 } else {
142 params.push(MsgValue::Map(BTreeMap::new()));
143 }
144 let payload = encode(&msg_array(params));
145
146 match send_request(&url, &payload, 15) {
147 Ok(val) => Ok(MSFHostsResult {
148 hosts: extract_hosts(&val),
149 error: String::new(),
150 }),
151 Err(error) => Ok(MSFHostsResult {
152 hosts: vec![],
153 error,
154 }),
155 }
156 }
157
158 pub fn check_host_exists(
159 &self,
160 address: &str,
161 workspace: Option<&str>,
162 ) -> Result<MSFHostCheckResult> {
163 let hosts = self.get_hosts(workspace)?;
164 Ok(MSFHostCheckResult {
165 exists: hosts.hosts.iter().any(|host| host.address == address),
166 error: hosts.error,
167 })
168 }
169
170 pub fn rpc_call(
176 &self,
177 method: &str,
178 params: &[MsgValue],
179 timeout_secs: u64,
180 ) -> Result<MsgValue> {
181 let token = self
182 .token
183 .clone()
184 .ok_or_else(|| crate::error::AppError::Message("not authenticated".to_string()))?;
185 let url = build_url(&self.host, self.port, self.ssl);
186 let mut args = vec![msg_str(method), msg_str(&token)];
187 args.extend_from_slice(params);
188 let payload = encode(&msg_array(args));
189 send_request(&url, &payload, timeout_secs).map_err(crate::error::AppError::Network)
190 }
191
192 pub fn module_exists(&self, module_type: &str, name: &str) -> Result<bool> {
198 match self.module_info(module_type, name) {
199 Ok(_) => Ok(true),
200 Err(AppError::RpcModuleNotFound(_)) => Ok(false),
201 Err(error) => Err(error),
202 }
203 }
204
205 pub fn module_info(&self, module_type: &str, name: &str) -> Result<MsgValue> {
215 let leaf = strip_type_prefix(module_type, name);
216 let modern = self.rpc_call("module.info", &[msg_str(module_type), msg_str(&leaf)], 15)?;
217 match response_error(&modern) {
218 None => Ok(modern),
219 Some(message) => {
220 if is_missing_module(&modern) {
221 return Err(AppError::RpcModuleNotFound(message));
222 }
223 if looks_like_legacy_signature_error(&modern) {
226 let legacy = self.rpc_call(
227 "module.info",
228 &[msg_str(&full_name(module_type, name))],
229 15,
230 )?;
231 if let Some(legacy_message) = response_error(&legacy) {
232 return Err(classify_lookup(&legacy_message));
233 }
234 return Ok(legacy);
235 }
236 Err(AppError::Rpc(message))
237 }
238 }
239 }
240
241 pub fn module_options(&self, module_type: &str, name: &str) -> Result<MsgValue> {
245 let leaf = strip_type_prefix(module_type, name);
246 let modern = self.rpc_call(
247 "module.options",
248 &[msg_str(module_type), msg_str(&leaf)],
249 15,
250 )?;
251 match response_error(&modern) {
252 None => Ok(modern),
253 Some(message) => {
254 if is_missing_module(&modern) {
255 return Err(AppError::RpcModuleNotFound(message));
256 }
257 if looks_like_legacy_signature_error(&modern) {
258 let legacy = self.rpc_call(
259 "module.options",
260 &[msg_str(&full_name(module_type, name))],
261 15,
262 )?;
263 if let Some(legacy_message) = response_error(&legacy) {
264 return Err(classify_lookup(&legacy_message));
265 }
266 return Ok(legacy);
267 }
268 Err(AppError::Rpc(message))
269 }
270 }
271 }
272
273 pub fn list_modules_for_type(&self, module_type: &str) -> Result<Vec<MSFModule>> {
281 let method = match module_type {
282 "exploit" => "module.exploits",
283 "auxiliary" => "module.auxiliary",
284 "post" => "module.post",
285 other => {
286 return Err(AppError::Message(format!(
287 "cannot list modules of unknown type '{other}'"
288 )));
289 }
290 };
291 let val = self.rpc_call(method, &[], 15)?;
292 if let Some(message) = response_error(&val) {
293 return Err(classify_lookup(&message));
294 }
295 Ok(extract_module_list(&val, module_type))
296 }
297
298 pub fn list_modules(&self) -> Result<Vec<MSFModule>> {
302 let mut modules = Vec::new();
303 let mut errors = Vec::new();
304 for module_type in ["exploit", "auxiliary", "post"] {
305 match self.list_modules_for_type(module_type) {
306 Ok(mut found) => modules.append(&mut found),
307 Err(error) => errors.push(error),
308 }
309 }
310 if modules.is_empty() && !errors.is_empty() {
311 return Err(errors.remove(0));
312 }
313 modules.sort_by(|a, b| a.name.cmp(&b.name));
314 Ok(modules)
315 }
316
317 pub fn module_execute(
320 &self,
321 module_type: &str,
322 name: &str,
323 options: &BTreeMap<String, String>,
324 payload: Option<&str>,
325 ) -> Result<MsgValue> {
326 let mut opts = BTreeMap::new();
327 for (key, value) in options {
328 opts.insert(key.clone(), msg_str(value));
329 }
330 if let Some(payload) = payload {
331 opts.insert("PAYLOAD".to_string(), msg_str(payload));
332 }
333 self.rpc_call(
334 "module.execute",
335 &[
336 msg_str(module_type),
337 msg_str(&strip_type_prefix(module_type, name)),
338 MsgValue::Map(opts),
339 ],
340 30,
341 )
342 }
343
344 pub fn job_list(&self) -> Result<MsgValue> {
346 self.rpc_call("job.list", &[], 15)
347 }
348
349 pub fn job_info(&self, job_id: &str) -> Result<MsgValue> {
351 self.rpc_call("job.info", &[msg_str(job_id)], 15)
352 }
353
354 pub fn job_stop(&self, job_id: &str) -> Result<MsgValue> {
356 self.rpc_call("job.stop", &[msg_str(job_id)], 15)
357 }
358
359 pub fn session_list(&self) -> Result<MsgValue> {
361 self.rpc_call("session.list", &[], 15)
362 }
363
364 pub fn db_loots(&self, workspace: &str) -> Result<MsgValue> {
366 let mut opts = BTreeMap::new();
367 if !workspace.is_empty() {
368 opts.insert("workspace".to_string(), msg_str(workspace));
369 }
370 self.rpc_call("db.loots", &[MsgValue::Map(opts)], 15)
371 }
372}
373
374fn msg_str(value: &str) -> MsgValue {
376 MsgValue::String(value.to_string())
377}
378
379fn msg_array(values: Vec<MsgValue>) -> MsgValue {
380 MsgValue::Array(values)
381}
382
383fn build_url(host: &str, port: u16, ssl: bool) -> String {
384 let scheme = if ssl { "https" } else { "http" };
385 format!("{}://{}:{}/api/", scheme, host, port)
386}
387
388fn strip_type_prefix(module_type: &str, name: &str) -> String {
392 for prefix in [module_type, "exploit", "auxiliary", "post"] {
393 if let Some(rest) = name.strip_prefix(&format!("{prefix}/")) {
394 return rest.to_string();
395 }
396 }
397 name.to_string()
398}
399
400fn full_name(module_type: &str, name: &str) -> String {
402 if ["exploit", "auxiliary", "post"]
403 .iter()
404 .any(|p| name.starts_with(&format!("{}/", p)))
405 {
406 return name.to_string();
407 }
408 match module_type {
409 "exploit" | "auxiliary" | "post" => format!("{module_type}/{name}"),
410 _ => name.to_string(),
411 }
412}
413
414fn response_error(val: &MsgValue) -> Option<String> {
420 match val.get("error") {
421 None => None,
422 Some(MsgValue::Bool(false)) => None,
423 Some(MsgValue::String(value)) if value == "success" => None,
424 Some(MsgValue::String(value)) if value == "true" => Some(error_message(val)),
425 Some(MsgValue::UInt(0)) | Some(MsgValue::Int(0)) => None,
426 Some(_) => Some(error_message(val)),
427 }
428}
429
430fn error_message(val: &MsgValue) -> String {
431 val.get("error_message")
432 .or_else(|| val.get("error_string"))
433 .and_then(MsgValue::as_str)
434 .unwrap_or("MSF-RPC command failed")
435 .to_string()
436}
437
438fn classify_lookup(message: &str) -> AppError {
441 let lower = message.to_lowercase();
442 if lower.contains("not found")
443 || lower.contains("does not exist")
444 || lower.contains("missing")
445 || lower.contains("invalid")
446 {
447 AppError::RpcModuleNotFound(message.to_string())
448 } else {
449 AppError::Rpc(message.to_string())
450 }
451}
452
453fn is_missing_module(err: &MsgValue) -> bool {
457 let message = error_message(err);
458 let bar = err
459 .get("error_backtrace")
460 .and_then(MsgValue::as_array)
461 .is_some_and(|frames| {
462 frames
463 .iter()
464 .filter_map(MsgValue::as_str)
465 .any(|frame| frame.contains("_find_module"))
466 });
467 let lower = message.to_lowercase();
468 bar || lower.contains("not found")
469 || lower.contains("does not exist")
470 || lower.contains("invalid module")
471 || lower.contains("module not found")
472 || lower.contains("missing module")
473}
474
475fn looks_like_legacy_signature_error(err: &MsgValue) -> bool {
479 let message = err
480 .get("error_string")
481 .and_then(MsgValue::as_str)
482 .unwrap_or("");
483 let lower = message.to_lowercase();
484 lower.contains("wrong number of arguments") || lower.contains("invalid message format")
485}
486
487fn extract_module_list(val: &MsgValue, module_type: &str) -> Vec<MSFModule> {
492 let mut names: Vec<String> = Vec::new();
493 match val.get("modules") {
494 Some(MsgValue::Array(items)) => {
495 for item in items {
496 if let Some(name) = item.as_str() {
497 names.push(name.to_string());
498 }
499 }
500 }
501 Some(MsgValue::Map(items)) => {
502 names.extend(items.keys().cloned());
503 }
504 _ => {
505 if let MsgValue::Map(items) = val {
507 names.extend(items.keys().cloned());
508 }
509 }
510 }
511 names.sort();
512 names.dedup();
513 names
514 .into_iter()
515 .map(|name| MSFModule {
516 name: full_name(module_type, &name),
517 cve: None,
518 rank: String::new(),
519 disclosure_date: "unknown".to_string(),
520 platforms: Vec::new(),
521 required_options: Vec::new(),
522 })
523 .collect()
524}
525
526fn send_request(
527 url: &str,
528 payload: &[u8],
529 timeout_secs: u64,
530) -> std::result::Result<MsgValue, String> {
531 let mut child = Command::new("curl")
532 .arg("-sS")
533 .arg("--max-time")
534 .arg(timeout_secs.to_string())
535 .arg("-H")
536 .arg("Content-Type: binary/message-pack")
537 .arg("-H")
538 .arg("Accept: binary/message-pack")
539 .arg("-i")
540 .arg("--data-binary")
541 .arg("@-")
542 .arg(url)
543 .stdin(Stdio::piped())
544 .stdout(Stdio::piped())
545 .stderr(Stdio::piped())
546 .spawn()
547 .map_err(|e| format!("failed to spawn curl: {e}"))?;
548
549 if let Some(stdin) = child.stdin.as_mut() {
550 use std::io::Write;
551 stdin
552 .write_all(payload)
553 .map_err(|e| format!("failed to write request body: {e}"))?;
554 }
555
556 let output = child
557 .wait_with_output()
558 .map_err(|e| format!("curl wait failed: {e}"))?;
559
560 let stdout = &output.stdout;
561 if stdout.is_empty() {
562 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
563 if !stderr.is_empty() {
564 return Err(stderr);
565 }
566 return Err("empty response from server".to_string());
567 }
568
569 let body = if let Some(pos) = find_header_body_boundary(stdout) {
571 &stdout[pos..]
572 } else {
573 &stdout[..]
574 };
575
576 if body.is_empty() {
577 return Err("empty response body from server".to_string());
578 }
579
580 decode(body).map_err(|e| {
581 let dump_len = body.len().min(512);
582 let hex_dump: Vec<String> = body[..dump_len]
583 .iter()
584 .map(|b| format!("{:02x}", b))
585 .collect();
586 let utf8_dump = String::from_utf8_lossy(&body[..dump_len]);
587 format!(
588 "{}\n response body hex ({} bytes): {}\n response body utf8: {}",
589 e,
590 dump_len,
591 hex_dump.join(" "),
592 utf8_dump
593 )
594 })
595}
596
597fn find_header_body_boundary(data: &[u8]) -> Option<usize> {
598 data.windows(4)
599 .position(|w| w == b"\r\n\r\n")
600 .map(|p| p + 4)
601}
602
603fn extract_workspaces(val: &MsgValue) -> Vec<MSFWorkspaceInfo> {
604 let mut workspaces = Vec::new();
605 if let Some(map) = val.as_map() {
606 if let Some(MsgValue::Array(ws_arr)) = map.get("workspaces") {
607 for ws in ws_arr {
608 if let Some(ws_map) = ws.as_map() {
609 let name = ws_map
610 .get("name")
611 .and_then(MsgValue::as_str)
612 .unwrap_or("")
613 .to_string();
614 let scope = ws_map
615 .get("scope")
616 .and_then(MsgValue::as_str)
617 .unwrap_or("")
618 .to_string();
619 let host_count = ws_map
620 .get("hosts_count")
621 .or_else(|| ws_map.get("host_count"))
622 .and_then(MsgValue::as_u64)
623 .unwrap_or(0);
624
625 workspaces.push(MSFWorkspaceInfo {
626 name,
627 scope,
628 host_count,
629 });
630 }
631 }
632 }
633 }
634 workspaces
635}
636
637fn extract_hosts(val: &MsgValue) -> Vec<MSFHostInfo> {
638 let mut hosts = Vec::new();
639 if let Some(map) = val.as_map() {
640 if let Some(MsgValue::Array(host_arr)) = map.get("hosts") {
641 for host in host_arr {
642 if let Some(host_map) = host.as_map() {
643 let address = host_map
644 .get("address")
645 .and_then(MsgValue::as_str)
646 .unwrap_or("")
647 .to_string();
648 let os_name = host_map
649 .get("os_name")
650 .and_then(MsgValue::as_str)
651 .unwrap_or("")
652 .to_string();
653 let os_flavor = host_map
654 .get("os_flavor")
655 .and_then(MsgValue::as_str)
656 .unwrap_or("")
657 .to_string();
658 let state = host_map
659 .get("state")
660 .and_then(MsgValue::as_str)
661 .unwrap_or("")
662 .to_string();
663 let notes_count = host_map
664 .get("notes_count")
665 .and_then(MsgValue::as_u64)
666 .unwrap_or(0);
667
668 hosts.push(MSFHostInfo {
669 address,
670 os_name,
671 os_flavor,
672 state,
673 notes_count,
674 });
675 }
676 }
677 }
678 }
679 hosts
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 fn map_of(pairs: &[(&str, MsgValue)]) -> MsgValue {
687 MsgValue::Map(
688 pairs
689 .iter()
690 .map(|(k, v)| (k.to_string(), v.clone()))
691 .collect(),
692 )
693 }
694
695 #[test]
696 fn strips_type_prefix_from_various_forms() {
697 assert_eq!(
698 strip_type_prefix("auxiliary", "auxiliary/scanner/http/robots_txt"),
699 "scanner/http/robots_txt"
700 );
701 assert_eq!(
702 strip_type_prefix("exploit", "exploit/windows/smb/ms17_010_eternalblue"),
703 "windows/smb/ms17_010_eternalblue"
704 );
705 assert_eq!(
707 strip_type_prefix("auxiliary", "scanner/http/robots_txt"),
708 "scanner/http/robots_txt"
709 );
710 assert_eq!(
712 strip_type_prefix("post", "auxiliary/scanner/http/robots_txt"),
713 "scanner/http/robots_txt"
714 );
715 }
716
717 #[test]
718 fn rebuilds_full_name() {
719 assert_eq!(
720 full_name("auxiliary", "scanner/http/robots_txt"),
721 "auxiliary/scanner/http/robots_txt"
722 );
723 assert_eq!(
725 full_name("exploit", "exploit/windows/smb/ms17_010_eternalblue"),
726 "exploit/windows/smb/ms17_010_eternalblue"
727 );
728 assert_eq!(full_name("payload", "linux/x64/shell"), "linux/x64/shell");
730 }
731
732 #[test]
733 fn extracts_module_list_from_array_form() {
734 let val = map_of(&[(
735 "modules",
736 MsgValue::Array(vec![
737 MsgValue::String("scanner/http/robots_txt".to_string()),
738 MsgValue::String("scanner/http/apache_version".to_string()),
739 ]),
740 )]);
741 let modules = extract_module_list(&val, "auxiliary");
742 let names: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect();
743 assert_eq!(
744 names,
745 vec![
746 "auxiliary/scanner/http/apache_version",
747 "auxiliary/scanner/http/robots_txt"
748 ]
749 );
750 }
751
752 #[test]
753 fn extracts_module_list_from_map_form() {
754 let val = map_of(&[(
755 "modules",
756 MsgValue::Map(BTreeMap::from([
757 ("scanner/http/robots_txt".to_string(), msg_str("desc")),
758 ("scanner/ssh/ssh_version".to_string(), msg_str("desc")),
759 ])),
760 )]);
761 let modules = extract_module_list(&val, "auxiliary");
762 assert!(modules
763 .iter()
764 .any(|m| m.name == "auxiliary/scanner/http/robots_txt"));
765 assert!(modules
766 .iter()
767 .any(|m| m.name == "auxiliary/scanner/ssh/ssh_version"));
768 }
769
770 #[test]
771 fn extracts_module_names_without_modules_key() {
772 let val = MsgValue::Map(BTreeMap::from([(
774 "scanner/http/robots_txt".to_string(),
775 msg_str("description"),
776 )]));
777 let modules = extract_module_list(&val, "auxiliary");
778 assert_eq!(modules.len(), 1);
779 assert_eq!(modules[0].name, "auxiliary/scanner/http/robots_txt");
780 }
781
782 #[test]
783 fn response_error_detects_success_and_failure() {
784 assert!(response_error(&map_of(&[("result", msg_str("success"))])).is_none());
786 assert!(response_error(&map_of(&[("error", MsgValue::Bool(false))])).is_none());
788 assert!(response_error(&map_of(&[("error", msg_str("success"))])).is_none());
790 let err = map_of(&[
792 ("error", MsgValue::Bool(true)),
793 ("error_message", msg_str("Module not found")),
794 ]);
795 assert_eq!(response_error(&err).as_deref(), Some("Module not found"));
796 assert!(response_error(&map_of(&[("error", MsgValue::Int(0))])).is_none());
798 assert!(response_error(&map_of(&[("error", MsgValue::Int(1))])).is_some());
799 }
800
801 #[test]
802 fn classifies_missing_vs_other_rpc_failures() {
803 assert!(matches!(
804 classify_lookup("Module not found"),
805 AppError::RpcModuleNotFound(_)
806 ));
807 assert!(matches!(
808 classify_lookup("The referenced module does not exist"),
809 AppError::RpcModuleNotFound(_)
810 ));
811 assert!(matches!(
812 classify_lookup("Missing module name"),
813 AppError::RpcModuleNotFound(_)
814 ));
815 assert!(matches!(
816 classify_lookup("Permission denied"),
817 AppError::Rpc(_)
818 ));
819 }
820
821 #[test]
822 fn is_missing_module_detects_backtrace_and_message() {
823 let err = map_of(&[
826 ("error", MsgValue::Bool(true)),
827 ("error_string", msg_str("Msf::RPC::Exception")),
828 (
829 "error_backtrace",
830 MsgValue::Array(vec![
831 msg_str("lib/msf/core/rpc/v10/rpc_module.rb:743:in 'Msf::RPC::RPC_Module#_find_module'"),
832 msg_str("lib/msf/core/rpc/v10/rpc_module.rb:218:in 'Msf::RPC::RPC_Module#rpc_info'"),
833 ]),
834 ),
835 ("error_message", msg_str("Invalid Module")),
836 ("error_code", MsgValue::Int(500)),
837 ]);
838 assert!(is_missing_module(&err));
839
840 let msg_only = map_of(&[
842 ("error", MsgValue::Bool(true)),
843 ("error_message", msg_str("Module not found")),
844 ]);
845 assert!(is_missing_module(&msg_only));
846
847 let arg_err = map_of(&[
850 ("error", MsgValue::Bool(true)),
851 (
852 "error_string",
853 msg_str("wrong number of arguments (given 1, expected 2)"),
854 ),
855 (
856 "error_message",
857 msg_str("wrong number of arguments (given 1, expected 2)"),
858 ),
859 ]);
860 assert!(!is_missing_module(&arg_err));
861 assert!(looks_like_legacy_signature_error(&arg_err));
862
863 assert!(!looks_like_legacy_signature_error(&msg_only));
866 }
867
868 #[test]
869 fn legacy_fallback_only_triggers_on_argument_shape_errors() {
870 let decision = |err: &MsgValue| {
874 if is_missing_module(err) {
875 return "not-found".to_string();
876 }
877 if looks_like_legacy_signature_error(err) {
878 return "legacy-retry".to_string();
879 }
880 "rpc-failure".to_string()
881 };
882
883 let modern_missing = map_of(&[
884 ("error", MsgValue::Bool(true)),
885 ("error_message", msg_str("Invalid Module")),
886 ]);
887 assert_eq!(decision(&modern_missing), "not-found");
888
889 let modern_arg_error = map_of(&[
890 ("error", MsgValue::Bool(true)),
891 (
892 "error_string",
893 msg_str("wrong number of arguments (given 2, expected 1)"),
894 ),
895 ]);
896 assert_eq!(decision(&modern_arg_error), "legacy-retry");
897
898 let other = map_of(&[
899 ("error", MsgValue::Bool(true)),
900 ("error_string", msg_str("Permission denied")),
901 ]);
902 assert_eq!(decision(&other), "rpc-failure");
903 }
904
905 #[test]
906 fn module_existence_decision_uses_classification() {
907 let decision = |msg: Option<&str>| match msg {
910 None => Ok(true),
911 Some(m) if matches!(classify_lookup(m), AppError::RpcModuleNotFound(_)) => Ok(false),
912 Some(_) => Err(AppError::Rpc(String::new())),
913 };
914 assert_eq!(decision(None).unwrap(), true);
915 assert_eq!(decision(Some("Module not found")).unwrap(), false);
916 assert!(decision(Some("Permission denied")).is_err());
917 }
918}