Skip to main content

ploit_malper/engine/
msfrpc.rs

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        // Array format: [method, ...params]
36        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            // Array format: [method, token]
83            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        // Array format: [method, token]
106        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        // Array format: [method, token, options_hash]
133        // The RPC dispatcher extracts the token from args for auth,
134        // so we need the workspace filter as the next parameter
135        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    /// Generic authenticated RPC call: `[method, token, ...params]`.
171    ///
172    /// Used by the exploitation layer for module discovery, execution and job
173    /// monitoring. Returns the raw msgpack response so the caller can map it
174    /// to strongly typed models.
175    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    /// Verify that a module exists on the connected Metasploit instance.
193    /// `module_type` is `exploit`, `auxiliary` or `post`.
194    ///
195    /// Distinguishes three outcomes: the module exists (`Ok(true)`), it does
196    /// not exist (`Ok(false)`), or the lookup itself failed (`Err`).
197    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    /// Fetch full module metadata via `module.info`.
206    ///
207    /// The RPC `module.info` handler accepts both `[ModuleType, ModuleName]`
208    /// (modern signature) and a single full `ModuleName` (legacy signature).
209    /// We send the modern form first and transparently fall back to the legacy
210    /// form so the same code works across framework releases.
211    pub fn module_info(&self, module_type: &str, name: &str) -> Result<MsgValue> {
212        let leaf = strip_type_prefix(module_type, name);
213        let modern = self.rpc_call("module.info", &[msg_str(module_type), msg_str(&leaf)], 15)?;
214        if let Some(message) = response_error(&modern) {
215            // A module.info error usually means "not found"; retry once with
216            // the legacy single-name signature in case this is an old server.
217            let legacy = self.rpc_call(
218                "module.info",
219                &[msg_str(&full_name(module_type, name))],
220                15,
221            )?;
222            if let Some(legacy_message) = response_error(&legacy) {
223                return Err(classify_lookup(&legacy_message));
224            }
225            let _ = message;
226            return Ok(legacy);
227        }
228        Ok(modern)
229    }
230
231    /// Fetch the datastore options for a module via `module.options`.
232    /// Modern servers accept `[ModuleType, ModuleName]`; legacy servers accept
233    /// a single module name, so we mirror the `module.info` fallback.
234    pub fn module_options(&self, module_type: &str, name: &str) -> Result<MsgValue> {
235        let leaf = strip_type_prefix(module_type, name);
236        let modern = self.rpc_call("module.options", &[msg_str(module_type), msg_str(&leaf)], 15)?;
237        if response_error(&modern).is_none() {
238            return Ok(modern);
239        }
240        let legacy = self.rpc_call(
241            "module.options",
242            &[msg_str(&full_name(module_type, name))],
243            15,
244        )?;
245        if let Some(message) = response_error(&legacy) {
246            return Err(classify_lookup(&message));
247        }
248        Ok(legacy)
249    }
250
251    /// List every module of the given RPC type currently loaded by the
252    /// instance. Returns full refnames including the type prefix, e.g.
253    /// `auxiliary/scanner/http/robots_txt`.
254    ///
255    /// RPC listing methods take no arguments; passing a module name to them is
256    /// ignored (or rejected by stricter servers) and would never be a valid
257    /// existence probe.
258    pub fn list_modules_for_type(&self, module_type: &str) -> Result<Vec<MSFModule>> {
259        let method = match module_type {
260            "exploit" => "module.exploits",
261            "auxiliary" => "module.auxiliary",
262            "post" => "module.post",
263            other => {
264                return Err(AppError::Message(format!(
265                    "cannot list modules of unknown type '{other}'"
266                )));
267            }
268        };
269        let val = self.rpc_call(method, &[], 15)?;
270        if let Some(message) = response_error(&val) {
271            return Err(classify_lookup(&message));
272        }
273        Ok(extract_module_list(&val, module_type))
274    }
275
276    /// Load the union of all exploit, auxiliary and post modules known to the
277    /// connected instance. Used by the process pipeline so suggestions are
278    /// grounded in what the instance can actually run.
279    pub fn list_modules(&self) -> Result<Vec<MSFModule>> {
280        let mut modules = Vec::new();
281        let mut errors = Vec::new();
282        for module_type in ["exploit", "auxiliary", "post"] {
283            match self.list_modules_for_type(module_type) {
284                Ok(mut found) => modules.append(&mut found),
285                Err(error) => errors.push(error),
286            }
287        }
288        if modules.is_empty() && !errors.is_empty() {
289            return Err(errors.remove(0));
290        }
291        modules.sort_by(|a, b| a.name.cmp(&b.name));
292        Ok(modules)
293    }
294
295    /// Execute a module. Returns a map containing `job_id` on success or an
296    /// error payload on failure.
297    pub fn module_execute(
298        &self,
299        module_type: &str,
300        name: &str,
301        options: &BTreeMap<String, String>,
302        payload: Option<&str>,
303    ) -> Result<MsgValue> {
304        let mut opts = BTreeMap::new();
305        for (key, value) in options {
306            opts.insert(key.clone(), msg_str(value));
307        }
308        if let Some(payload) = payload {
309            opts.insert("PAYLOAD".to_string(), msg_str(payload));
310        }
311        self.rpc_call(
312            "module.execute",
313            &[
314                msg_str(module_type),
315                msg_str(&strip_type_prefix(module_type, name)),
316                MsgValue::Map(opts),
317            ],
318            30,
319        )
320    }
321
322    /// List all running jobs. Response map: `{ "jobs": { "0": {...}, ... } }`.
323    pub fn job_list(&self) -> Result<MsgValue> {
324        self.rpc_call("job.list", &[], 15)
325    }
326
327    /// Fetch status of a single job by id.
328    pub fn job_info(&self, job_id: &str) -> Result<MsgValue> {
329        self.rpc_call("job.info", &[msg_str(job_id)], 15)
330    }
331
332    /// Stop a running job by id.
333    pub fn job_stop(&self, job_id: &str) -> Result<MsgValue> {
334        self.rpc_call("job.stop", &[msg_str(job_id)], 15)
335    }
336
337    /// List current sessions. Response map: `{ "sessions": { "1": {...}, ... } }`.
338    pub fn session_list(&self) -> Result<MsgValue> {
339        self.rpc_call("session.list", &[], 15)
340    }
341
342    /// List loot records for a workspace.
343    pub fn db_loots(&self, workspace: &str) -> Result<MsgValue> {
344        let mut opts = BTreeMap::new();
345        if !workspace.is_empty() {
346            opts.insert("workspace".to_string(), msg_str(workspace));
347        }
348        self.rpc_call("db.loots", &[MsgValue::Map(opts)], 15)
349    }
350}
351
352/// Convenience constructors for msgpack values used across the RPC layer.
353fn msg_str(value: &str) -> MsgValue {
354    MsgValue::String(value.to_string())
355}
356
357fn msg_array(values: Vec<MsgValue>) -> MsgValue {
358    MsgValue::Array(values)
359}
360
361fn build_url(host: &str, port: u16, ssl: bool) -> String {
362    let scheme = if ssl { "https" } else { "http" };
363    format!("{}://{}:{}/api/", scheme, host, port)
364}
365
366/// Strip a leading `exploit/`, `auxiliary/` or `post/` type prefix from a
367/// module name. RPC module handlers accept the bare leaf name (or, per the
368/// docs, the prefixed form) but the listing methods always return the leaf.
369fn strip_type_prefix(module_type: &str, name: &str) -> String {
370    for prefix in [module_type, "exploit", "auxiliary", "post"] {
371        if let Some(rest) = name.strip_prefix(&format!("{prefix}/")) {
372            return rest.to_string();
373        }
374    }
375    name.to_string()
376}
377
378/// Rebuild the full refname, e.g. `auxiliary/scanner/http/robots_txt`.
379fn full_name(module_type: &str, name: &str) -> String {
380    if ["exploit", "auxiliary", "post"]
381        .iter()
382        .any(|p| name.starts_with(&format!("{}/", p)))
383    {
384        return name.to_string();
385    }
386    match module_type {
387        "exploit" | "auxiliary" | "post" => format!("{module_type}/{name}"),
388        _ => name.to_string(),
389    }
390}
391
392/// Inspect an RPC response map for an error payload.
393///
394/// Metasploit returns `{ "error" => true, "error_message" => "..." }` (or
395/// `error_string`) on failure and omits the key (or sets it to `false` /
396/// `"success"`) on success. Returns the human-readable message for errors.
397fn response_error(val: &MsgValue) -> Option<String> {
398    match val.get("error") {
399        None => None,
400        Some(MsgValue::Bool(false)) => None,
401        Some(MsgValue::String(value)) if value == "success" => None,
402        Some(MsgValue::String(value)) if value == "true" => Some(error_message(val)),
403        Some(MsgValue::UInt(0)) | Some(MsgValue::Int(0)) => None,
404        Some(_) => Some(error_message(val)),
405    }
406}
407
408fn error_message(val: &MsgValue) -> String {
409    val.get("error_message")
410        .or_else(|| val.get("error_string"))
411        .and_then(MsgValue::as_str)
412        .unwrap_or("MSF-RPC command failed")
413        .to_string()
414}
415
416/// Map a module lookup failure message to the error type that callers use to
417/// distinguish "module really is missing" from any other RPC failure.
418fn classify_lookup(message: &str) -> AppError {
419    let lower = message.to_lowercase();
420    if lower.contains("not found")
421        || lower.contains("does not exist")
422        || lower.contains("missing")
423        || lower.contains("invalid")
424    {
425        AppError::RpcModuleNotFound(message.to_string())
426    } else {
427        AppError::Rpc(message.to_string())
428    }
429}
430
431/// Parse a `module.exploits` / `module.auxiliary` / `module.post` listing
432/// response. Newer servers return `{ "modules" => [ "scanner/http/...", ... ] }`
433/// (an array); some versions return a name -> description hash. Both are
434/// handled so the loader works across releases.
435fn extract_module_list(val: &MsgValue, module_type: &str) -> Vec<MSFModule> {
436    let mut names: Vec<String> = Vec::new();
437    match val.get("modules") {
438        Some(MsgValue::Array(items)) => {
439            for item in items {
440                if let Some(name) = item.as_str() {
441                    names.push(name.to_string());
442                }
443            }
444        }
445        Some(MsgValue::Map(items)) => {
446            names.extend(items.keys().cloned());
447        }
448        _ => {
449            // Some legacy servers return the map directly with no "modules" key.
450            if let MsgValue::Map(items) = val {
451                names.extend(items.keys().cloned());
452            }
453        }
454    }
455    names.sort();
456    names.dedup();
457    names
458        .into_iter()
459        .map(|name| MSFModule {
460            name: full_name(module_type, &name),
461            cve: None,
462            rank: String::new(),
463            disclosure_date: "unknown".to_string(),
464            platforms: Vec::new(),
465            required_options: Vec::new(),
466        })
467        .collect()
468}
469
470fn send_request(
471    url: &str,
472    payload: &[u8],
473    timeout_secs: u64,
474) -> std::result::Result<MsgValue, String> {
475    let mut child = Command::new("curl")
476        .arg("-sS")
477        .arg("--max-time")
478        .arg(timeout_secs.to_string())
479        .arg("-H")
480        .arg("Content-Type: binary/message-pack")
481        .arg("-H")
482        .arg("Accept: binary/message-pack")
483        .arg("-i")
484        .arg("--data-binary")
485        .arg("@-")
486        .arg(url)
487        .stdin(Stdio::piped())
488        .stdout(Stdio::piped())
489        .stderr(Stdio::piped())
490        .spawn()
491        .map_err(|e| format!("failed to spawn curl: {e}"))?;
492
493    if let Some(stdin) = child.stdin.as_mut() {
494        use std::io::Write;
495        stdin
496            .write_all(payload)
497            .map_err(|e| format!("failed to write request body: {e}"))?;
498    }
499
500    let output = child
501        .wait_with_output()
502        .map_err(|e| format!("curl wait failed: {e}"))?;
503
504    let stdout = &output.stdout;
505    if stdout.is_empty() {
506        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
507        if !stderr.is_empty() {
508            return Err(stderr);
509        }
510        return Err("empty response from server".to_string());
511    }
512
513    // Split headers from body (curl -i includes headers in stdout)
514    let body = if let Some(pos) = find_header_body_boundary(stdout) {
515        &stdout[pos..]
516    } else {
517        &stdout[..]
518    };
519
520    if body.is_empty() {
521        return Err("empty response body from server".to_string());
522    }
523
524    decode(body).map_err(|e| {
525        let dump_len = body.len().min(512);
526        let hex_dump: Vec<String> = body[..dump_len]
527            .iter()
528            .map(|b| format!("{:02x}", b))
529            .collect();
530        let utf8_dump = String::from_utf8_lossy(&body[..dump_len]);
531        format!(
532            "{}\n  response body hex ({} bytes): {}\n  response body utf8: {}",
533            e,
534            dump_len,
535            hex_dump.join(" "),
536            utf8_dump
537        )
538    })
539}
540
541fn find_header_body_boundary(data: &[u8]) -> Option<usize> {
542    data.windows(4)
543        .position(|w| w == b"\r\n\r\n")
544        .map(|p| p + 4)
545}
546
547fn extract_workspaces(val: &MsgValue) -> Vec<MSFWorkspaceInfo> {
548    let mut workspaces = Vec::new();
549    if let Some(map) = val.as_map() {
550        if let Some(MsgValue::Array(ws_arr)) = map.get("workspaces") {
551            for ws in ws_arr {
552                if let Some(ws_map) = ws.as_map() {
553                    let name = ws_map
554                        .get("name")
555                        .and_then(MsgValue::as_str)
556                        .unwrap_or("")
557                        .to_string();
558                    let scope = ws_map
559                        .get("scope")
560                        .and_then(MsgValue::as_str)
561                        .unwrap_or("")
562                        .to_string();
563                    let host_count = ws_map
564                        .get("hosts_count")
565                        .or_else(|| ws_map.get("host_count"))
566                        .and_then(MsgValue::as_u64)
567                        .unwrap_or(0);
568
569                    workspaces.push(MSFWorkspaceInfo {
570                        name,
571                        scope,
572                        host_count,
573                    });
574                }
575            }
576        }
577    }
578    workspaces
579}
580
581fn extract_hosts(val: &MsgValue) -> Vec<MSFHostInfo> {
582    let mut hosts = Vec::new();
583    if let Some(map) = val.as_map() {
584        if let Some(MsgValue::Array(host_arr)) = map.get("hosts") {
585            for host in host_arr {
586                if let Some(host_map) = host.as_map() {
587                    let address = host_map
588                        .get("address")
589                        .and_then(MsgValue::as_str)
590                        .unwrap_or("")
591                        .to_string();
592                    let os_name = host_map
593                        .get("os_name")
594                        .and_then(MsgValue::as_str)
595                        .unwrap_or("")
596                        .to_string();
597                    let os_flavor = host_map
598                        .get("os_flavor")
599                        .and_then(MsgValue::as_str)
600                        .unwrap_or("")
601                        .to_string();
602                    let state = host_map
603                        .get("state")
604                        .and_then(MsgValue::as_str)
605                        .unwrap_or("")
606                        .to_string();
607                    let notes_count = host_map
608                        .get("notes_count")
609                        .and_then(MsgValue::as_u64)
610                        .unwrap_or(0);
611
612                    hosts.push(MSFHostInfo {
613                        address,
614                        os_name,
615                        os_flavor,
616                        state,
617                        notes_count,
618                    });
619                }
620            }
621        }
622    }
623    hosts
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    fn map_of(pairs: &[(&str, MsgValue)]) -> MsgValue {
631        MsgValue::Map(
632            pairs
633                .iter()
634                .map(|(k, v)| (k.to_string(), v.clone()))
635                .collect(),
636        )
637    }
638
639    #[test]
640    fn strips_type_prefix_from_various_forms() {
641        assert_eq!(
642            strip_type_prefix("auxiliary", "auxiliary/scanner/http/robots_txt"),
643            "scanner/http/robots_txt"
644        );
645        assert_eq!(
646            strip_type_prefix("exploit", "exploit/windows/smb/ms17_010_eternalblue"),
647            "windows/smb/ms17_010_eternalblue"
648        );
649        // Already leaf-only: unchanged.
650        assert_eq!(strip_type_prefix("auxiliary", "scanner/http/robots_txt"), "scanner/http/robots_txt");
651        // Wrong module_type but explicit prefix present: still stripped.
652        assert_eq!(
653            strip_type_prefix("post", "auxiliary/scanner/http/robots_txt"),
654            "scanner/http/robots_txt"
655        );
656    }
657
658    #[test]
659    fn rebuilds_full_name() {
660        assert_eq!(
661            full_name("auxiliary", "scanner/http/robots_txt"),
662            "auxiliary/scanner/http/robots_txt"
663        );
664        // Already prefixed: left alone.
665        assert_eq!(
666            full_name("exploit", "exploit/windows/smb/ms17_010_eternalblue"),
667            "exploit/windows/smb/ms17_010_eternalblue"
668        );
669        // Unknown type: passthrough (no bogus prefix).
670        assert_eq!(full_name("payload", "linux/x64/shell"), "linux/x64/shell");
671    }
672
673    #[test]
674    fn extracts_module_list_from_array_form() {
675        let val = map_of(&[(
676            "modules",
677            MsgValue::Array(vec![
678                MsgValue::String("scanner/http/robots_txt".to_string()),
679                MsgValue::String("scanner/http/apache_version".to_string()),
680            ]),
681        )]);
682        let modules = extract_module_list(&val, "auxiliary");
683        let names: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect();
684        assert_eq!(
685            names,
686            vec![
687                "auxiliary/scanner/http/apache_version",
688                "auxiliary/scanner/http/robots_txt"
689            ]
690        );
691    }
692
693    #[test]
694    fn extracts_module_list_from_map_form() {
695        let val = map_of(&[(
696            "modules",
697            MsgValue::Map(BTreeMap::from([
698                ("scanner/http/robots_txt".to_string(), msg_str("desc")),
699                ("scanner/ssh/ssh_version".to_string(), msg_str("desc")),
700            ])),
701        )]);
702        let modules = extract_module_list(&val, "auxiliary");
703        assert!(modules.iter().any(|m| m.name == "auxiliary/scanner/http/robots_txt"));
704        assert!(modules.iter().any(|m| m.name == "auxiliary/scanner/ssh/ssh_version"));
705    }
706
707    #[test]
708    fn extracts_module_names_without_modules_key() {
709        // Some legacy servers return the name -> description hash directly.
710        let val = MsgValue::Map(BTreeMap::from([(
711            "scanner/http/robots_txt".to_string(),
712            msg_str("description"),
713        )]));
714        let modules = extract_module_list(&val, "auxiliary");
715        assert_eq!(modules.len(), 1);
716        assert_eq!(modules[0].name, "auxiliary/scanner/http/robots_txt");
717    }
718
719    #[test]
720    fn response_error_detects_success_and_failure() {
721        // No error key: success.
722        assert!(response_error(&map_of(&[("result", msg_str("success"))])).is_none());
723        // Bool false: success.
724        assert!(response_error(&map_of(&[("error", MsgValue::Bool(false))])).is_none());
725        // "error" => "success": success.
726        assert!(response_error(&map_of(&[("error", msg_str("success"))])).is_none());
727        // Bool true with message: failure.
728        let err = map_of(&[
729            ("error", MsgValue::Bool(true)),
730            ("error_message", msg_str("Module not found")),
731        ]);
732        assert_eq!(response_error(&err).as_deref(), Some("Module not found"));
733        // Int 0: success; nonzero: failure.
734        assert!(response_error(&map_of(&[("error", MsgValue::Int(0))])).is_none());
735        assert!(response_error(&map_of(&[("error", MsgValue::Int(1))])).is_some());
736    }
737
738    #[test]
739    fn classifies_missing_vs_other_rpc_failures() {
740        assert!(matches!(
741            classify_lookup("Module not found"),
742            AppError::RpcModuleNotFound(_)
743        ));
744        assert!(matches!(
745            classify_lookup("The referenced module does not exist"),
746            AppError::RpcModuleNotFound(_)
747        ));
748        assert!(matches!(
749            classify_lookup("Missing module name"),
750            AppError::RpcModuleNotFound(_)
751        ));
752        assert!(matches!(
753            classify_lookup("Permission denied"),
754            AppError::Rpc(_)
755        ));
756    }
757
758    #[test]
759    fn module_existence_decision_uses_classification() {
760        // The pure decision used by module_exists: found when module.info
761        // succeeds, missing when it reports not-found, error otherwise.
762        let decision = |msg: Option<&str>| match msg {
763            None => Ok(true),
764            Some(m) if matches!(classify_lookup(m), AppError::RpcModuleNotFound(_)) => Ok(false),
765            Some(_) => Err(AppError::Rpc(String::new())),
766        };
767        assert_eq!(decision(None).unwrap(), true);
768        assert_eq!(decision(Some("Module not found")).unwrap(), false);
769        assert!(decision(Some("Permission denied")).is_err());
770    }
771}