Skip to main content

ploit_malper/exploit/
cli.rs

1use std::io::{self, Write};
2use std::time::Duration;
3
4use crate::config::ConfigManager;
5use crate::db::models::ExploitExecution;
6use crate::error::{AppError, Result};
7use crate::exploit::models::*;
8use crate::exploit::msf::MsfFramework;
9use crate::exploit::planner::Planner;
10use crate::exploit::{execute_step, ExploitFramework};
11use crate::frontend::{open_backend, Args};
12
13const DEFAULT_JOB_TIMEOUT_S: u64 = 300;
14
15pub fn usage() {
16    println!(
17        "Usage: ploit-malper exploit <run_id> [--dry-run] [--yes] [--module <name>] \
18         [--target <host>] [--payload <name>] [--workspace <name>] [--job-timeout <secs>] \
19         [--backend pocketbase|sqlite] [--pocketbase-url URL] [--sqlite-path PATH] [--verbose]"
20    );
21}
22
23pub fn cmd_exploit(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
24    let parsed = Args::parse(args)?;
25    if parsed.has("help") {
26        usage();
27        return Ok(());
28    }
29
30    let run_ref = parsed.positionals().first().cloned().ok_or_else(|| {
31        AppError::Message(
32            "exploit requires a run id (e.g. ploit-malper exploit <run_id>)".to_string(),
33        )
34    })?;
35
36    let opts = ExploitOptions {
37        dry_run: parsed.has("dry-run"),
38        yes: parsed.has("yes"),
39        module_filter: parsed.get("module").map(str::to_string),
40        target_filter: parsed.get("target").map(str::to_string),
41        payload_override: parsed.get("payload").map(str::to_string),
42        workspace: parsed.get("workspace").map(str::to_string),
43        backend: parsed.get("backend").map(str::to_string),
44        pocketbase_url: parsed.get("pocketbase-url").map(str::to_string),
45        sqlite_path: parsed.get("sqlite-path").map(str::to_string),
46        verbose: parsed.has("verbose"),
47    };
48    let job_timeout = parsed
49        .get("job-timeout")
50        .map(|v| v.parse::<u64>())
51        .transpose()
52        .map_err(|_| AppError::Message("--job-timeout must be a number of seconds".to_string()))?
53        .unwrap_or(DEFAULT_JOB_TIMEOUT_S);
54
55    let mut storage = open_backend(config_mgr, &parsed)?;
56    let run = Planner::resolve_run(storage.as_mut(), &run_ref)?;
57
58    println!(
59        "[+] Run: {} (target={})",
60        short(&run.stable_id, 16),
61        run.target
62    );
63
64    let plan = Planner::build_plan(
65        storage.as_mut(),
66        &run,
67        opts.module_filter.as_deref(),
68        opts.target_filter.as_deref(),
69    )?;
70
71    if opts.dry_run {
72        return dry_run(&mut storage, &run, &plan, &opts);
73    }
74
75    let mut framework = build_framework(config_mgr, &opts)?;
76    if let Err(e) = framework.connect() {
77        // Record the whole plan as skipped so the attempt is auditable.
78        println!("[!] Could not connect to the exploitation framework: {e}");
79        for step in &plan {
80            persist(&mut storage, &run.stable_id, step, false, None, None)?;
81        }
82        println!("[!] Plan recorded as skipped; nothing was executed.");
83        return Ok(());
84    }
85    println!(
86        "[+] Connected to {} (workspace '{}')",
87        framework.name(),
88        framework.workspace()
89    );
90
91    // Verify every planned module exists on the connected instance. A module
92    // that is missing is skipped (auditable); a lookup that fails for any other
93    // RPC reason is also skipped but reported distinctly so the operator can
94    // tell "module absent" apart from "RPC down".
95    let mut verified: Vec<(ExploitStep, Option<ModuleInfo>)> = Vec::new();
96    for step in &plan {
97        match framework.module_info(&step.module_type, &step.module_name) {
98            Ok(info) => {
99                verified.push((step.clone(), Some(info)));
100            }
101            Err(crate::error::AppError::RpcModuleNotFound(_)) => {
102                println!(
103                    "[!] Module {} not present on the connected instance; marking skipped.",
104                    step.module_name
105                );
106                persist(
107                    &mut storage,
108                    &run.stable_id,
109                    step,
110                    false,
111                    Some(ExploitStatus::Skipped),
112                    None,
113                )?;
114            }
115            Err(e) => {
116                println!(
117                    "[!] Could not query module {} (RPC failure, not a missing module): {e}",
118                    step.module_name
119                );
120                persist(
121                    &mut storage,
122                    &run.stable_id,
123                    step,
124                    false,
125                    Some(ExploitStatus::Skipped),
126                    None,
127                )?;
128            }
129        }
130    }
131
132    if verified.is_empty() {
133        println!("[!] No modules are available on the connected instance.");
134        return Ok(());
135    }
136
137    render_plan(&verified);
138
139    // Interactive checklist: toggle modules, Enter to run the selected set.
140    let selected: Vec<bool> = if opts.yes {
141        vec![true; verified.len()]
142    } else {
143        select_steps(&verified)?
144    };
145
146    let chosen: Vec<(ExploitStep, Option<ModuleInfo>)> = verified
147        .into_iter()
148        .zip(selected.iter())
149        .filter(|(_, sel)| **sel)
150        .map(|(step, _)| step)
151        .collect();
152
153    if chosen.is_empty() {
154        println!("[!] No modules selected; recording plan as skipped.");
155        for step in &plan {
156            persist(&mut storage, &run.stable_id, step, false, None, None)?;
157        }
158        return Ok(());
159    }
160
161    if !opts.yes
162        && !prompt_bool_with_default(
163            &format!("Run {} selected module(s) now?", chosen.len()),
164            false,
165        )?
166    {
167        println!("[!] Execution declined; recording selection as skipped.");
168        for (step, _) in &chosen {
169            persist(
170                &mut storage,
171                &run.stable_id,
172                step,
173                true,
174                Some(ExploitStatus::Skipped),
175                None,
176            )?;
177        }
178        return Ok(());
179    }
180
181    // Execute each selected step sequentially.
182    println!();
183    for (step, info) in &chosen {
184        println!(
185            "[+] Executing {} against {} ...",
186            step.module_name, step.host
187        );
188
189        let resolved = resolve_options(info.as_ref(), step, opts.yes)?;
190        if !resolved.missing.is_empty() {
191            println!(
192                "[!] Required options missing ({}); skipping {}.",
193                resolved.missing.join(", "),
194                step.module_name
195            );
196            persist(
197                &mut storage,
198                &run.stable_id,
199                step,
200                true,
201                Some(ExploitStatus::Skipped),
202                None,
203            )?;
204            continue;
205        }
206
207        let mut step_clone = step.clone();
208        step_clone.inferred_options = resolved.options;
209        let payload = opts.payload_override.as_deref();
210        match execute_step(
211            framework.as_mut(),
212            &step_clone,
213            payload,
214            Duration::from_secs(job_timeout),
215        ) {
216            Ok(outcome) => {
217                render_outcome(&outcome);
218                persist(
219                    &mut storage,
220                    &run.stable_id,
221                    &outcome.step,
222                    true,
223                    Some(outcome.status),
224                    Some(&outcome),
225                )?;
226            }
227            Err(e) => {
228                println!("[!] Execution error for {}: {e}", step.module_name);
229                persist(
230                    &mut storage,
231                    &run.stable_id,
232                    step,
233                    true,
234                    Some(ExploitStatus::Failed),
235                    None,
236                )?;
237            }
238        }
239    }
240
241    framework.disconnect();
242    println!();
243    println!("[+] Exploit session complete. Records written to the database.");
244    Ok(())
245}
246
247fn build_framework(
248    config_mgr: &ConfigManager,
249    opts: &ExploitOptions,
250) -> Result<Box<dyn ExploitFramework>> {
251    if !config_mgr.is_configured() {
252        return Err(AppError::Message(
253            "no MSF-RPC configuration found; run 'ploit-malper setup' first".to_string(),
254        ));
255    }
256    let cfg = config_mgr.get_msfrpc_config().clone();
257    let mut framework =
258        MsfFramework::new(&cfg.host, cfg.port, &cfg.username, &cfg.password, cfg.ssl);
259    framework.set_workspace(opts.workspace.as_deref().unwrap_or(&cfg.workspace));
260    Ok(Box::new(framework))
261}
262
263/// Dry-run: show the full plan with module metadata and record it as previews.
264/// Never contacts the framework.
265fn dry_run(
266    storage: &mut Box<dyn crate::db::Storage>,
267    run: &crate::db::models::ScanRun,
268    plan: &[ExploitStep],
269    opts: &ExploitOptions,
270) -> Result<()> {
271    println!();
272    println!("[+] Dry run — no modules will be executed.");
273    render_plan_dry(plan);
274    println!();
275    if let Some(payload) = &opts.payload_override {
276        println!("[+] Payload override: {payload}");
277    }
278    println!("[+] Recording {} step(s) as dry_run previews.", plan.len());
279    for step in plan {
280        persist(
281            storage,
282            &run.stable_id,
283            step,
284            false,
285            Some(ExploitStatus::DryRun),
286            None,
287        )?;
288    }
289    println!("[+] Preview recorded; nothing was executed.");
290    Ok(())
291}
292
293fn render_plan(verified: &[(ExploitStep, Option<ModuleInfo>)]) {
294    println!();
295    println!("Exploitation plan (verified against the connected instance):");
296    println!(
297        "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
298        "#", "TYPE", "RANK", "CONF", "MODULE", "MISSING", "PAYLOAD"
299    );
300    println!("{}", "-".repeat(90));
301    for (i, (step, info)) in verified.iter().enumerate() {
302        let missing = if step.missing_options.is_empty() {
303            "none".to_string()
304        } else {
305            step.missing_options.join(",")
306        };
307        println!(
308            "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
309            i + 1,
310            step.module_type,
311            step.rank,
312            step.confidence,
313            short(&step.module_name, 16),
314            short(&missing, 6),
315            short(&step.payload_compatibility, 10),
316        );
317        println!(
318            "      host={} port={} cve={} vuln={}",
319            step.host,
320            step.service_port
321                .map(|p| p.to_string())
322                .unwrap_or_else(|| "-".to_string()),
323            step.cve.as_deref().unwrap_or("-"),
324            short(&step.vulnerability, 60),
325        );
326        if !step.inferred_options.is_empty() {
327            let opts = step
328                .inferred_options
329                .iter()
330                .map(|(k, v)| format!("{}={}", k, v))
331                .collect::<Vec<_>>()
332                .join(" ");
333            println!("      options: {opts}");
334        }
335        if let Some(info) = info {
336            if !info.description.is_empty() {
337                println!("      desc: {}", short(&info.description, 80));
338            }
339            let defaults = info
340                .options
341                .iter()
342                .filter(|o| o.default.is_some())
343                .map(|o| format!("{}={}", o.name, o.default.as_deref().unwrap_or("")))
344                .collect::<Vec<_>>()
345                .join(" ");
346            if !defaults.is_empty() {
347                println!("      defaults: {defaults}");
348            }
349        }
350    }
351}
352
353fn render_plan_dry(plan: &[ExploitStep]) {
354    println!();
355    println!("Planned modules (offline metadata):");
356    println!(
357        "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
358        "#", "TYPE", "RANK", "MODULE", "CVE", "CONF", "PAYLOAD"
359    );
360    println!("{}", "-".repeat(90));
361    for (i, step) in plan.iter().enumerate() {
362        println!(
363            "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
364            i + 1,
365            step.module_type,
366            step.rank,
367            short(&step.module_name, 16),
368            short(step.cve.as_deref().unwrap_or("-"), 16),
369            step.confidence,
370            short(&step.payload_compatibility, 10),
371        );
372        println!(
373            "      target={} port={} vuln={}",
374            step.host,
375            step.service_port
376                .map(|p| p.to_string())
377                .unwrap_or_else(|| "-".to_string()),
378            short(&step.vulnerability, 60),
379        );
380        if !step.missing_options.is_empty() {
381            println!(
382                "      required options to fill: {}",
383                step.missing_options.join(", ")
384            );
385        } else {
386            println!("      required options: fully inferred");
387        }
388    }
389}
390
391fn render_outcome(outcome: &ExploitOutcome) {
392    let status = outcome.status.as_str();
393    println!("      -> {} status={}", outcome.step.module_name, status);
394    if let Some(job) = &outcome.job_id {
395        println!("         job_id={}", job);
396    }
397    if let Some(session) = &outcome.session_id {
398        println!("         session_id={}", session);
399    }
400    if !outcome.loot.is_empty() {
401        println!("         loot={}", outcome.loot.join(","));
402    }
403    if let Some(error) = &outcome.error {
404        println!("         error={}", short(error, 120));
405    }
406}
407
408fn select_steps(verified: &[(ExploitStep, Option<ModuleInfo>)]) -> Result<Vec<bool>> {
409    // Interactive TUI: a circular picker where the cursor wraps around the
410    // ring (↑/k and ↓/j), space toggles, enter advances to the next step.
411    if crate::frontend::tui::interactive() {
412        let rows: Vec<crate::frontend::tui::PickerRow> = verified
413            .iter()
414            .map(|(step, _)| crate::frontend::tui::PickerRow {
415                text: step.module_name.clone(),
416                detail: Some(format!(
417                    "rank={} conf={} host={} port={}",
418                    step.rank,
419                    step.confidence,
420                    step.host,
421                    step.service_port
422                        .map(|p| p.to_string())
423                        .unwrap_or_else(|| "-".to_string())
424                )),
425                selected: step.default_selected,
426            })
427            .collect();
428
429        let first = verified
430            .iter()
431            .position(|(s, _)| s.default_selected)
432            .unwrap_or(0);
433        return match crate::frontend::tui::pick("Select modules to exploit", rows, first)? {
434            Some(chosen) => Ok(verified
435                .iter()
436                .enumerate()
437                .map(|(i, _)| chosen.contains(&i))
438                .collect()),
439            None => Ok(verified.iter().map(|_| false).collect()),
440        };
441    }
442
443    // Non-interactive fallback: the classic checklist prompt.
444    let mut selected: Vec<bool> = verified.iter().map(|(s, _)| s.default_selected).collect();
445    loop {
446        println!();
447        println!("Module checklist — [x] = recommended, [ ] = optional:");
448        for (i, (step, _)) in verified.iter().enumerate() {
449            let mark = if selected[i] { "x" } else { " " };
450            println!(
451                "  [{}] {:<3} {}  [{}] host={}",
452                mark,
453                i + 1,
454                step.module_name,
455                step.rank,
456                step.host
457            );
458        }
459        print!("  Toggle (e.g. 1,3), 'a' all, 'n' none, or press Enter to run the selected set: ");
460        io::stdout().flush()?;
461        let mut buffer = String::new();
462        io::stdin().read_line(&mut buffer)?;
463        let input = buffer.trim().to_lowercase();
464        if input.is_empty() {
465            break;
466        }
467        match input.as_str() {
468            "a" => selected.iter_mut().for_each(|s| *s = true),
469            "n" => selected.iter_mut().for_each(|s| *s = false),
470            _ => {
471                for token in input.split([',', ' ', '\t']) {
472                    if token.is_empty() {
473                        continue;
474                    }
475                    match token.parse::<usize>() {
476                        Ok(idx) if idx >= 1 && idx <= selected.len() => {
477                            let slot = &mut selected[idx - 1];
478                            *slot = !*slot;
479                        }
480                        _ => println!("    ignoring '{}' (not a module number)", token),
481                    }
482                }
483            }
484        }
485    }
486    Ok(selected)
487}
488
489/// Resolved option map for one step plus the options that remain truly
490/// missing (required, no default, no value).
491struct ResolvedOptions {
492    /// Options to pass to `run_module`.
493    options: std::collections::BTreeMap<String, String>,
494    /// Required options with no default and no value after the pipeline.
495    missing: Vec<String>,
496}
497
498/// Option resolution policy. This is the single place that decides how a
499/// module's options become concrete values, and it applies identically to
500/// every framework behind `ExploitFramework`.
501///
502/// Canonical required/optional × default matrix:
503///
504/// |                  | no default                | has default                    |
505/// |------------------|---------------------------|--------------------------------|
506/// | required         | prompt (interactive) else missing -> skip | auto-apply default; never prompt |
507/// | optional         | omit (do not insert "")   | auto-apply default or omit     |
508///
509/// Additional rules:
510/// - `inferred_options` (planner: RHOSTS/RPORT/target) always override defaults.
511/// - Explicit CLI/payload values override inferred and defaults.
512/// - UI/feedback options (ShowProgress, ShowProgressPercent, Verbose) never
513///   block execution: apply default or omit; never hard-required.
514/// - `--yes` / non-interactive: never prompt; missing required -> skip with
515///   a clear reason (no stdin hang).
516/// - An empty prompt answer is never "missing" when a default existed — the
517///   default is applied before prompting, so nothing defaulted reaches the
518///   prompt at all.
519///
520/// Defaults are applied first (step 1); inferred/CLI values always win (they
521/// already occupy the map, so the default never overwrites them).
522fn resolve_options(
523    info: Option<&ModuleInfo>,
524    step: &ExploitStep,
525    yes: bool,
526) -> Result<ResolvedOptions> {
527    // Pure-framework UI toggles. They never gate execution: a default
528    // auto-applies and no default means "omit", not "missing".
529    const UI_OPTIONS: &[&str] = &["ShowProgress", "ShowProgressPercent", "Verbose", "VERBOSE"];
530
531    let mut options = step.inferred_options.clone();
532    let mut missing: Vec<String> = Vec::new();
533
534    // 1. Apply defaults for every option that has one, but never over an
535    //    inferred/explicit value already present.
536    if let Some(info) = info {
537        for opt in &info.options {
538            if let Some(default) = &opt.default {
539                options
540                    .entry(opt.name.clone())
541                    .or_insert_with(|| default.clone());
542            }
543        }
544    }
545
546    let required: Vec<String> = match info {
547        Some(info) => info.required_options.clone(),
548        None => step.missing_options.clone(),
549    };
550
551    let interactive = crate::frontend::tui::interactive();
552
553    // 2. Resolve the remaining required set. Defaulted/should-never-be here:
554    //    required+default are auto-filled above, inferred are present above.
555    for name in required {
556        if options.contains_key(&name) {
557            continue;
558        }
559        if UI_OPTIONS.contains(&name.as_str()) {
560            continue;
561        }
562        if yes || !interactive {
563            // Non-interactive or --yes: never prompt; mark missing.
564            missing.push(name);
565            continue;
566        }
567        let value = prompt_text(
568            &format!("Required option {} for {}", name, step.module_name),
569            "",
570        )?;
571        if value.trim().is_empty() {
572            missing.push(name);
573        } else {
574            options.insert(name, value);
575        }
576    }
577
578    Ok(ResolvedOptions { options, missing })
579}
580
581/// Persist one step as an `ExploitExecution` record. When `outcome` is given,
582/// the record reflects the finished execution; otherwise it reflects the
583/// planned/skipped state.
584fn persist(
585    storage: &mut Box<dyn crate::db::Storage>,
586    run_id: &str,
587    step: &ExploitStep,
588    selected: bool,
589    status: Option<ExploitStatus>,
590    outcome: Option<&ExploitOutcome>,
591) -> Result<()> {
592    let mut exec =
593        ExploitExecution::new(run_id, &step.asset_id, &step.finding_id, &step.module_name);
594    exec.module_type = step.module_type.clone();
595    exec.host = step.host.clone();
596    exec.options = step.inferred_options.clone();
597    exec.selected = selected;
598    match outcome {
599        Some(outcome) => {
600            exec.status = outcome.status.as_str().to_string();
601            exec.start_time = outcome.start_time.clone();
602            exec.finish_time = outcome.finish_time.clone();
603            exec.job_id = outcome.job_id.clone();
604            exec.session_id = outcome.session_id.clone();
605            exec.error = outcome.error.clone();
606            exec.loot = outcome.loot.clone();
607        }
608        None => {
609            exec.status = status
610                .unwrap_or(if selected {
611                    ExploitStatus::Planned
612                } else {
613                    ExploitStatus::Skipped
614                })
615                .as_str()
616                .to_string();
617        }
618    }
619    storage.upsert_exploit_execution(&exec)
620}
621
622fn prompt_text(prompt: &str, default: &str) -> Result<String> {
623    print!("  {} [{}]: ", prompt, default);
624    io::stdout().flush()?;
625    let mut buffer = String::new();
626    io::stdin().read_line(&mut buffer)?;
627    let value = buffer.trim();
628    Ok(if value.is_empty() {
629        default.to_string()
630    } else {
631        value.to_string()
632    })
633}
634
635fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
636    let suffix = if default { "[Y/n]" } else { "[y/N]" };
637    loop {
638        print!("  {} {}: ", prompt, suffix);
639        io::stdout().flush()?;
640        let mut buffer = String::new();
641        io::stdin().read_line(&mut buffer)?;
642        let value = buffer.trim().to_lowercase();
643        if value.is_empty() {
644            return Ok(default);
645        }
646        match value.as_str() {
647            "y" | "yes" | "true" => return Ok(true),
648            "n" | "no" | "false" => return Ok(false),
649            _ => println!("  Please answer yes or no."),
650        }
651    }
652}
653
654fn short(value: &str, width: usize) -> String {
655    let count = value.chars().count();
656    if count <= width {
657        return value.to_string();
658    }
659    if width <= 1 {
660        return "…".to_string();
661    }
662    let mut out: String = value.chars().take(width - 1).collect();
663    out.push('…');
664    out
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use std::collections::BTreeMap;
671
672    fn module_info_with(options: Vec<ModuleOption>, required: Vec<String>) -> ModuleInfo {
673        ModuleInfo {
674            name: "m".to_string(),
675            module_type: MODULE_AUXILIARY.to_string(),
676            rank: "normal".to_string(),
677            description: String::new(),
678            platforms: Vec::new(),
679            targets: Vec::new(),
680            options,
681            required_options: required,
682        }
683    }
684
685    fn option(name: &str, required: bool, default: Option<&str>) -> ModuleOption {
686        ModuleOption {
687            name: name.to_string(),
688            required,
689            default: default.map(str::to_string),
690            description: String::new(),
691        }
692    }
693
694    fn step(inferred: &[(&str, &str)]) -> ExploitStep {
695        let inferred_options: BTreeMap<String, String> = inferred
696            .iter()
697            .map(|(k, v)| (k.to_string(), v.to_string()))
698            .collect();
699        ExploitStep {
700            module_type: MODULE_AUXILIARY.to_string(),
701            module_name: "auxiliary/scanner/http/robots_txt".to_string(),
702            cve: None,
703            vulnerability: "robots".to_string(),
704            rank: "normal".to_string(),
705            asset_id: "a1".to_string(),
706            finding_id: "f1".to_string(),
707            host: "10.0.0.1".to_string(),
708            service_port: Some(80),
709            service_protocol: Some("tcp".to_string()),
710            inferred_options,
711            missing_options: Vec::new(),
712            payload_compatibility: "generic".to_string(),
713            confidence: "high".to_string(),
714            default_selected: true,
715        }
716    }
717
718    #[test]
719    fn required_with_default_auto_applies_without_prompting() {
720        // In a test runner there is no tty, so interactive() == false. The
721        // required+default options must be auto-filled (never "missing" and
722        // never sent empty).
723        let info = module_info_with(
724            vec![
725                option("ShowProgress", true, Some("false")),
726                option("THREADS", true, Some("2")),
727                option("RPORT", true, None),
728            ],
729            vec![
730                "ShowProgress".to_string(),
731                "THREADS".to_string(),
732                "RPORT".to_string(),
733            ],
734        );
735        let step = step(&[("RHOSTS", "10.0.0.1")]);
736        let resolved = resolve_options(Some(&info), &step, true).unwrap();
737
738        assert_eq!(
739            resolved.options.get("ShowProgress").map(String::as_str),
740            Some("false")
741        );
742        assert_eq!(
743            resolved.options.get("THREADS").map(String::as_str),
744            Some("2")
745        );
746        // RHOSTS inferred is preserved.
747        assert_eq!(
748            resolved.options.get("RHOSTS").map(String::as_str),
749            Some("10.0.0.1")
750        );
751        // RPORT has no default and no inferred value -> missing.
752        assert!(resolved.missing.contains(&"RPORT".to_string()));
753    }
754
755    #[test]
756    fn inferred_options_always_win_over_defaults() {
757        let info = module_info_with(
758            vec![option("RHOSTS", true, Some("default.ignored"))],
759            vec!["RHOSTS".to_string()],
760        );
761        let step = step(&[("RHOSTS", "10.0.0.9")]);
762        let resolved = resolve_options(Some(&info), &step, true).unwrap();
763
764        assert_eq!(
765            resolved.options.get("RHOSTS").map(String::as_str),
766            Some("10.0.0.9")
767        );
768        assert!(resolved.missing.is_empty());
769    }
770
771    #[test]
772    fn non_interactive_yes_marks_missing_without_prompt() {
773        // A required option with no default that cannot be filled will be
774        // reported missing under --yes; it must not hang on stdin.
775        let info = module_info_with(vec![option("LHOST", true, None)], vec!["LHOST".to_string()]);
776        let step = step(&[]);
777        let resolved = resolve_options(Some(&info), &step, true).unwrap();
778
779        assert!(resolved.missing.contains(&"LHOST".to_string()));
780        assert!(!resolved.options.contains_key("LHOST"));
781    }
782
783    #[test]
784    fn optional_options_are_omitted_not_empty() {
785        // An optional option with no default must simply be omitted from the
786        // executed map (never inserted as "").
787        let info = module_info_with(
788            vec![
789                option("OptionalOne", false, None),
790                option("OptionalNow", false, None),
791            ],
792            Vec::new(),
793        );
794        let step = step(&[]);
795        let resolved = resolve_options(Some(&info), &step, true).unwrap();
796
797        assert!(resolved.missing.is_empty());
798        assert!(!resolved.options.contains_key("OptionalOne"));
799        assert!(!resolved.options.contains_key("OptionalNow"));
800    }
801
802    #[test]
803    fn no_info_uses_step_missing_options() {
804        let mut step = step(&[]);
805        step.missing_options = vec!["RHOSTS".to_string()];
806        let resolved = resolve_options(None, &step, true).unwrap();
807        assert!(resolved.missing.contains(&"RHOSTS".to_string()));
808    }
809}