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 `[ModuleType, ModuleName]` on
208    /// all supported framework releases. A modern server answers with a rich
209    /// metadata map for known modules and an error map ― classifiable as
210    /// "module not found" or an RPC failure ― for unknown ones. Only very old
211    /// frameworks still expect the single full-name signature; when the modern
212    /// response signals an argument-shape mismatch (rather than a missing
213    /// module) we fall back to that form once.
214    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                // Not a lookup failure: probably an argument-shape mismatch on
224                // a server that only understands the legacy single-name form.
225                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    /// Fetch the datastore options for a module via `module.options`.
242    /// Mirrors the `module.info` lookup logic (modern form first, legacy
243    /// single-name fallback reserved for servers that reject the modern one).
244    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    /// List every module of the given RPC type currently loaded by the
274    /// instance. Returns full refnames including the type prefix, e.g.
275    /// `auxiliary/scanner/http/robots_txt`.
276    ///
277    /// RPC listing methods take no arguments; passing a module name to them is
278    /// ignored (or rejected by stricter servers) and would never be a valid
279    /// existence probe.
280    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    /// Load the union of all exploit, auxiliary and post modules known to the
299    /// connected instance. Used by the process pipeline so suggestions are
300    /// grounded in what the instance can actually run.
301    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    /// Execute a module. Returns a map containing `job_id` on success or an
318    /// error payload on failure.
319    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    /// List all running jobs. Response map: `{ "jobs": { "0": {...}, ... } }`.
345    pub fn job_list(&self) -> Result<MsgValue> {
346        self.rpc_call("job.list", &[], 15)
347    }
348
349    /// Fetch status of a single job by id.
350    pub fn job_info(&self, job_id: &str) -> Result<MsgValue> {
351        self.rpc_call("job.info", &[msg_str(job_id)], 15)
352    }
353
354    /// Stop a running job by id.
355    pub fn job_stop(&self, job_id: &str) -> Result<MsgValue> {
356        self.rpc_call("job.stop", &[msg_str(job_id)], 15)
357    }
358
359    /// List current sessions. Response map: `{ "sessions": { "1": {...}, ... } }`.
360    pub fn session_list(&self) -> Result<MsgValue> {
361        self.rpc_call("session.list", &[], 15)
362    }
363
364    /// List loot records for a workspace.
365    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
374/// Convenience constructors for msgpack values used across the RPC layer.
375fn 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
388/// Strip a leading `exploit/`, `auxiliary/` or `post/` type prefix from a
389/// module name. RPC module handlers accept the bare leaf name (or, per the
390/// docs, the prefixed form) but the listing methods always return the leaf.
391fn 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
400/// Rebuild the full refname, e.g. `auxiliary/scanner/http/robots_txt`.
401fn 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
414/// Inspect an RPC response map for an error payload.
415///
416/// Metasploit returns `{ "error" => true, "error_message" => "..." }` (or
417/// `error_string`) on failure and omits the key (or sets it to `false` /
418/// `"success"`) on success. Returns the human-readable message for errors.
419fn 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
438/// Map a module lookup failure message to the error type that callers use to
439/// distinguish "module really is missing" from any other RPC failure.
440fn 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
453/// True when an RPC error response is a *module lookup* failure rather than a
454/// transport or argument problem. MSF raises from `RPC_Module#_find_module`
455/// for missing modules, and its `error_message` mentions the module or "Invalid".
456fn 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
475/// True when the server rejected the modern `[ModuleType, ModuleName]` shape
476/// itself (an argument-count error), which is the only case where the legacy
477/// single-name signature may still be required.
478fn 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
487/// Parse a `module.exploits` / `module.auxiliary` / `module.post` listing
488/// response. Newer servers return `{ "modules" => [ "scanner/http/...", ... ] }`
489/// (an array); some versions return a name -> description hash. Both are
490/// handled so the loader works across releases.
491fn 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            // Some legacy servers return the map directly with no "modules" key.
506            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    // Split headers from body (curl -i includes headers in stdout)
570    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        // Already leaf-only: unchanged.
706        assert_eq!(
707            strip_type_prefix("auxiliary", "scanner/http/robots_txt"),
708            "scanner/http/robots_txt"
709        );
710        // Wrong module_type but explicit prefix present: still stripped.
711        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        // Already prefixed: left alone.
724        assert_eq!(
725            full_name("exploit", "exploit/windows/smb/ms17_010_eternalblue"),
726            "exploit/windows/smb/ms17_010_eternalblue"
727        );
728        // Unknown type: passthrough (no bogus prefix).
729        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        // Some legacy servers return the name -> description hash directly.
773        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        // No error key: success.
785        assert!(response_error(&map_of(&[("result", msg_str("success"))])).is_none());
786        // Bool false: success.
787        assert!(response_error(&map_of(&[("error", MsgValue::Bool(false))])).is_none());
788        // "error" => "success": success.
789        assert!(response_error(&map_of(&[("error", msg_str("success"))])).is_none());
790        // Bool true with message: failure.
791        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        // Int 0: success; nonzero: failure.
797        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        // The real payload MSF returns for a missing module: error_message
824        // "Invalid Module" and a backtrace rooted at _find_module.
825        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        // Same payload without the backtrace but with an explicit message.
841        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        // A genuine argument/transport failure must NOT look like a missing
848        // module.
849        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        // A modern-server module-not-found (no backtrace, but message only)
864        // is also not an argument-shape error.
865        assert!(!looks_like_legacy_signature_error(&msg_only));
866    }
867
868    #[test]
869    fn legacy_fallback_only_triggers_on_argument_shape_errors() {
870        // Simulate what a modern server returns for a missing module: the
871        // modern-form error is classified as not-found and never falls back to
872        // the legacy single-name signature.
873        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        // The pure decision used by module_exists: found when module.info
908        // succeeds, missing when it reports not-found, error otherwise.
909        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}