Skip to main content

ssh_cli/cli/
dispatch.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Subcommand dispatch (G-COMP-06b) — kept separate from clap type definitions.
3//!
4//! Sequential local handlers (CRUD, locale, completions) are justified: work ≪ SSH RTT.
5//! Multi-host I/O uses domain modules with [`crate::concurrency::map_bounded`].
6#![forbid(unsafe_code)]
7
8use super::targeting::{
9    build_exec_options, resolve_exec_target, resolve_health_target, Elevation, ExecCommonArgs,
10    ExecTargetArgs, HealthTargetArgs,
11};
12use super::{
13    command_tree_json, effective_timeout_ms, generate_completions, read_stdin_if,
14    warn_if_password_argv, CliArgs, Command, LocaleAction, OutputFormat, ScpAction, SftpAction,
15};
16use anyhow::Result;
17
18/// Runs the requested subcommand.
19///
20/// Prefer [`crate::commands::run`] from new call sites; this remains the
21/// shared implementation used by both layers.
22pub async fn dispatch(args: CliArgs) -> Result<()> {
23    dispatch_impl(args).await
24}
25
26/// Shared dispatch implementation (cli + commands layers).
27pub async fn dispatch_impl(args: CliArgs) -> Result<()> {
28    let config_override = args.config_dir.clone();
29    // Aligns `secrets.key` with `--config-dir` / isolated tests.
30    crate::secrets::set_config_dir(config_override.clone());
31    crate::secrets::set_runtime_flags(
32        args.allow_plaintext_secrets,
33        args.secrets_key_file.clone(),
34        args.use_keyring,
35    );
36    // Bounded multi-host fan-out budget (Rules Rust — paralelismo).
37    let limit = crate::concurrency::resolve_limit(args.max_concurrency.map(usize::from));
38    crate::concurrency::install_process_limit(limit);
39    crate::concurrency::install_fail_fast(args.fail_fast);
40    if let Some(n) = args.scp_file_concurrency {
41        crate::concurrency::install_scp_file_concurrency(usize::from(n));
42    }
43    // G-AUD-01: global `--json` forces JSON; conflicts with `--output-format text`.
44    let formato = super::resolve_format_from_cli(args.json, args.output_format)?;
45    // GAP-SSH-IO-003 / IO-004: centralized I/O policy.
46    crate::output::set_quiet(args.quiet);
47    crate::output::set_json_errors(formato == OutputFormat::Json);
48    let disable_sudo = args.disable_sudo;
49    let replace_host_key = args.replace_host_key;
50    // G-OS-02: global `--timeout` fills missing local timeouts (local wins).
51    let global_timeout = args.timeout;
52
53    // GAP-AUD-010 / G-AUD-08: warn only when secrets appear on argv (visible in `ps`).
54    warn_if_password_argv(&args);
55
56    // C2: rejected before any registry read or socket, but *after* the error
57    // channel knows whether the caller wants JSON — rejecting earlier produced a
58    // prose refusal on stdout-for-agents, so the one command that fails this
59    // check was also the one an agent could not parse.
60    super::guard_dry_run_supported(&args.command)?;
61
62    match args.command {
63        Command::Vps { action } => {
64            // Sequential: local TOML CRUD (work ≪ SSH RTT; no multi-host I/O)
65            // except `vps doctor --probe-ssh` which reuses health-check fan-out.
66            crate::vps::run_vps_command(action, config_override, formato).await
67        }
68        Command::Connect { name } => {
69            // Sequential: writes active marker only (no SSH fan-out).
70            crate::vps::run_connect(&name, config_override, formato).await
71        }
72        Command::Exec {
73            all,
74            hosts,
75            tags,
76            use_active,
77            target,
78            steps,
79            json,
80            auth,
81            timeout,
82            description,
83        } => {
84            let plan = resolve_exec_target(
85                ExecTargetArgs {
86                    all,
87                    hosts,
88                    tags,
89                    use_active,
90                    target,
91                },
92                config_override.as_deref(),
93            )?;
94            let opts = build_exec_options(ExecCommonArgs {
95                steps,
96                auth,
97                elevation_password: None,
98                elevation: Elevation::None,
99                timeout,
100                global_timeout,
101                description,
102                replace_host_key,
103                disable_sudo,
104                target_source: plan.source,
105            })?;
106            crate::vps::run_exec(
107                plan.selection,
108                &plan.command,
109                config_override,
110                formato,
111                json,
112                opts,
113            )
114            .await
115        }
116        Command::SudoExec {
117            all,
118            hosts,
119            tags,
120            use_active,
121            target,
122            steps,
123            json,
124            auth,
125            sudo_password,
126            sudo_password_stdin,
127            timeout,
128            description,
129        } => {
130            let plan = resolve_exec_target(
131                ExecTargetArgs {
132                    all,
133                    hosts,
134                    tags,
135                    use_active,
136                    target,
137                },
138                config_override.as_deref(),
139            )?;
140            let opts = build_exec_options(ExecCommonArgs {
141                steps,
142                auth,
143                elevation_password: read_stdin_if(sudo_password_stdin, sudo_password)?,
144                elevation: Elevation::Sudo,
145                timeout,
146                global_timeout,
147                description,
148                replace_host_key,
149                disable_sudo,
150                target_source: plan.source,
151            })?;
152            crate::vps::run_sudo_exec(
153                plan.selection,
154                &plan.command,
155                config_override,
156                formato,
157                json,
158                opts,
159            )
160            .await
161        }
162        Command::SuExec {
163            all,
164            hosts,
165            tags,
166            use_active,
167            target,
168            steps,
169            json,
170            auth,
171            su_password,
172            su_password_stdin,
173            timeout,
174            description,
175        } => {
176            let plan = resolve_exec_target(
177                ExecTargetArgs {
178                    all,
179                    hosts,
180                    tags,
181                    use_active,
182                    target,
183                },
184                config_override.as_deref(),
185            )?;
186            let opts = build_exec_options(ExecCommonArgs {
187                steps,
188                auth,
189                elevation_password: read_stdin_if(su_password_stdin, su_password)?,
190                elevation: Elevation::Su,
191                timeout,
192                global_timeout,
193                description,
194                replace_host_key,
195                disable_sudo,
196                target_source: plan.source,
197            })?;
198            crate::vps::run_su_exec(
199                plan.selection,
200                &plan.command,
201                config_override,
202                formato,
203                json,
204                opts,
205            )
206            .await
207        }
208        Command::Scp { action } => {
209            let (auth, timeout, json_local) = match &action {
210                ScpAction::Upload {
211                    auth,
212                    timeout,
213                    json,
214                    ..
215                }
216                | ScpAction::Download {
217                    auth,
218                    timeout,
219                    json,
220                    ..
221                } => (auth.clone(), *timeout, *json),
222            };
223            let key = auth.key_path_string();
224            let password = read_stdin_if(auth.password_stdin, auth.password)?;
225            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
226            // GAP-SSH-IO-007b: local --json or global --format json → JSON error envelope.
227            let json_efetivo = json_local || formato == OutputFormat::Json;
228            if json_efetivo {
229                crate::output::set_json_errors(true);
230            }
231            crate::scp::run_scp(
232                action,
233                config_override,
234                crate::scp::ScpOptions {
235                    password,
236                    key,
237                    key_passphrase,
238                    timeout: effective_timeout_ms(timeout, global_timeout)
239                        .map_err(crate::errors::SshCliError::InvalidArgument)?,
240                    replace_host_key,
241                    json: json_efetivo,
242                    use_agent: auth.use_agent,
243                    agent_socket: auth
244                        .agent_socket
245                        .as_ref()
246                        .map(|p| p.to_string_lossy().into_owned()),
247                },
248            )
249            .await
250        }
251        Command::Sftp { action } => {
252            let (auth, timeout, json_local) = match &action {
253                SftpAction::Upload {
254                    auth,
255                    timeout,
256                    json,
257                    ..
258                }
259                | SftpAction::Download {
260                    auth,
261                    timeout,
262                    json,
263                    ..
264                }
265                | SftpAction::Ls {
266                    auth,
267                    timeout,
268                    json,
269                    ..
270                }
271                | SftpAction::Mkdir {
272                    auth,
273                    timeout,
274                    json,
275                    ..
276                }
277                | SftpAction::Rmdir {
278                    auth,
279                    timeout,
280                    json,
281                    ..
282                }
283                | SftpAction::Rm {
284                    auth,
285                    timeout,
286                    json,
287                    ..
288                }
289                | SftpAction::Stat {
290                    auth,
291                    timeout,
292                    json,
293                    ..
294                }
295                | SftpAction::Rename {
296                    auth,
297                    timeout,
298                    json,
299                    ..
300                } => (auth.clone(), *timeout, *json),
301            };
302            let key = auth.key_path_string();
303            let password = read_stdin_if(auth.password_stdin, auth.password)?;
304            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
305            let json_efetivo = json_local || formato == OutputFormat::Json;
306            if json_efetivo {
307                crate::output::set_json_errors(true);
308            }
309            // A6: without `ssh-real` there is no SFTP subsystem to dispatch into. Failing
310            // with a typed error keeps the diagnostic build honest — the subcommand still
311            // parses, and the caller is told the binary was built without the stack
312            // instead of hitting a link error or a silent no-op.
313            #[cfg(not(feature = "ssh-real"))]
314            {
315                let _ = (
316                    action,
317                    config_override,
318                    password,
319                    key,
320                    key_passphrase,
321                    timeout,
322                );
323                return Err(crate::errors::SshCliError::InvalidArgument(
324                    "this binary was built without the `ssh-real` feature; sftp is unavailable"
325                        .to_string(),
326                )
327                .into());
328            }
329            #[cfg(feature = "ssh-real")]
330            crate::sftp::run_sftp(
331                action,
332                config_override,
333                crate::sftp::SftpOptions {
334                    password,
335                    key,
336                    key_passphrase,
337                    timeout: effective_timeout_ms(timeout, global_timeout)
338                        .map_err(crate::errors::SshCliError::InvalidArgument)?,
339                    replace_host_key,
340                    json: json_efetivo,
341                    use_agent: auth.use_agent,
342                    agent_socket: auth
343                        .agent_socket
344                        .as_ref()
345                        .map(|p| p.to_string_lossy().into_owned()),
346                    recursive: false, // set from action in run_sftp for upload/download
347                },
348            )
349            .await
350        }
351        Command::Tunnel {
352            vps_name,
353            local_port,
354            remote_host,
355            remote_port,
356            socks5,
357            remote_socket,
358            reverse,
359            timeout_ms,
360            auth,
361            json,
362            bind,
363            i_accept_network_exposure,
364        } => {
365            // GAP-SSH-IO-008: --json local or global format.
366            let json_efetivo = json || formato == OutputFormat::Json;
367            if json_efetivo {
368                crate::output::set_json_errors(true);
369            }
370            // GAP-SSH-CLI-005: auth parity with exec/scp (stdin + passphrase).
371            // Tunnel deadline remains explicit `--timeout-ms` (mandatory bound).
372            // Forwards: JoinSet + Semaphore (concurrency::effective_limit).
373            let key = auth.key_path_string();
374            let password = read_stdin_if(auth.password_stdin, auth.password)?;
375            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
376            // E3: `--use-agent` / `--agent-socket` were accepted by clap here and then
377            // dropped on the floor, so a host registered for agent auth simply could
378            // not open a tunnel. Forwarded now, at parity with exec/scp.
379            let mode = super::resolve_tunnel_mode(
380                socks5,
381                remote_socket,
382                reverse,
383                remote_host,
384                remote_port,
385            )?;
386            crate::tunnel::run_tunnel(crate::tunnel::TunnelRequest {
387                vps_name,
388                local_port,
389                mode,
390                config_override,
391                auth: crate::tunnel::TunnelAuth {
392                    password,
393                    key,
394                    key_passphrase,
395                    use_agent: auth.use_agent,
396                    agent_socket: auth
397                        .agent_socket
398                        .as_ref()
399                        .map(|p| p.to_string_lossy().into_owned()),
400                },
401                timeout_ms,
402                replace_host_key,
403                json: json_efetivo,
404                bind_addr: bind.to_string(),
405                accept_network_exposure: i_accept_network_exposure,
406            })
407            .await
408        }
409        Command::HealthCheck {
410            vps_name,
411            all,
412            hosts,
413            use_active,
414            json,
415            auth,
416            timeout,
417        } => {
418            // GAP-SSH-CLI-006: auth parity with exec/scp (stdin + key + passphrase).
419            let key = auth.key_path_string();
420            let password = read_stdin_if(auth.password_stdin, auth.password)?;
421            let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
422            // Target resolution lives beside the exec-family resolver: deciding
423            // *where* a command lands is one responsibility, and it had drifted into
424            // two copies with different rules — which is how the read surface kept an
425            // ambient shortcut the write surface had already removed.
426            let (selection, host_source) = resolve_health_target(
427                HealthTargetArgs {
428                    all,
429                    hosts,
430                    use_active,
431                    vps_name,
432                },
433                config_override.as_deref(),
434            )?;
435            crate::vps::run_health_check(crate::vps::HealthCheckRequest {
436                selection,
437                config_override,
438                format: formato,
439                json_local: json,
440                password_override: password,
441                timeout_override: effective_timeout_ms(timeout, global_timeout)
442                    .map_err(crate::errors::SshCliError::InvalidArgument)?,
443                key_override: key,
444                key_passphrase_override: key_passphrase,
445                replace_host_key,
446                host_source,
447            })
448            .await
449        }
450        Command::Secrets { action } => {
451            // Sequential: local crypto / key file (work ≪ SSH RTT).
452            crate::vps::run_secrets_command(action, config_override, formato).await
453        }
454        Command::Completions { shell } => {
455            // Sequential: emit shell script metadata only.
456            generate_completions(shell)
457        }
458        Command::Commands { json: _ } => {
459            // Sequential: clap tree walk (agent discovery; no I/O fan-out).
460            // Always JSON: agent discovery surface (rules checklist `mycli commands`).
461            crate::output::print_json_value(&command_tree_json())?;
462            Ok(())
463        }
464        Command::Schema { name, json } => {
465            // Sequential: embedded catalog lookup (G-E2E-02).
466            let wants_json = json || formato == OutputFormat::Json;
467            crate::cli::run_schema(name.as_deref(), wants_json).map_err(Into::into)
468        }
469        Command::Doctor {
470            json,
471            probe_ssh,
472            hosts,
473        } => {
474            // G-E2E-03: root alias → same handler as `vps doctor`.
475            crate::vps::run_vps_command(
476                crate::cli::VpsAction::Doctor {
477                    json,
478                    probe_ssh,
479                    hosts,
480                },
481                config_override,
482                formato,
483            )
484            .await
485        }
486        Command::Tls { json, action } => {
487            #[cfg(feature = "tls")]
488            {
489                crate::tls::commands::run_tls_command(action, config_override, formato, json).await
490            }
491            #[cfg(not(feature = "tls"))]
492            {
493                let _ = (action, json);
494                Err(crate::errors::SshCliError::tls_msg(
495                    "TLS feature disabled; rebuild with --features tls (default)",
496                )
497                .into())
498            }
499        }
500        Command::Locale { json, action } => {
501            // Sequential: locale preference file / diagnostics (local only).
502            run_locale_command(
503                action,
504                config_override.as_deref(),
505                formato,
506                args.lang.as_deref(),
507                json,
508            )
509        }
510    }
511}
512
513/// Implements `ssh-cli locale [show|set|clear]`.
514pub(crate) fn run_locale_command(
515    action: Option<LocaleAction>,
516    config_override: Option<&std::path::Path>,
517    formato: OutputFormat,
518    force_lang: Option<&str>,
519    json_flag: bool,
520) -> Result<()> {
521    use crate::i18n::{self, Message};
522    use crate::locale::{
523        clear_persisted_lang, current_language, lang_preference_path, negotiate_code,
524        resolve_language_detailed, write_persisted_lang,
525    };
526
527    let action = action.unwrap_or(LocaleAction::Show);
528    let override_ref = config_override;
529    let wants_json = json_flag || formato == OutputFormat::Json;
530
531    match action {
532        LocaleAction::Show => {
533            // Re-resolve for diagnostics (global already set at init; layers still useful).
534            let detailed = resolve_language_detailed(force_lang, override_ref);
535            let available: Vec<&str> = crate::i18n::Language::AVAILABLE
536                .iter()
537                .map(|l| l.bcp47())
538                .collect();
539            let pref_path = lang_preference_path(override_ref)
540                .map(|p| p.display().to_string())
541                .unwrap_or_else(|| String::from("(unavailable)"));
542
543            if wants_json {
544                let body = serde_json::json!({
545                    "resolved": detailed.language.bcp47(),
546                    "current": current_language().bcp47(),
547                    "source": detailed.source.as_str(),
548                    "available": available,
549                    "system_raw": detailed.system_raw,
550                    "persisted_raw": detailed.persisted_raw,
551                    "preference_path": pref_path,
552                    "direction": match detailed.language.direction() {
553                        crate::i18n::TextDirection::Ltr => "ltr",
554                        crate::i18n::TextDirection::Rtl => "rtl",
555                    },
556                    "script": detailed.language.script(),
557                });
558                crate::output::print_json_value(&body)?;
559            } else {
560                crate::output::write_line(&i18n::t(Message::LocaleStatusTitle))?;
561                crate::output::write_line_fmt(format_args!(
562                    "  resolved:   {} ({})",
563                    detailed.language.bcp47(),
564                    detailed.source.as_str()
565                ))?;
566                crate::output::write_line_fmt(format_args!(
567                    "  current:    {}",
568                    current_language().bcp47()
569                ))?;
570                crate::output::write_line_fmt(format_args!(
571                    "  available:  {}",
572                    available.join(", ")
573                ))?;
574                crate::output::write_line_fmt(format_args!(
575                    "  system:     {}",
576                    detailed.system_raw.as_deref().unwrap_or("(none)")
577                ))?;
578                crate::output::write_line_fmt(format_args!(
579                    "  persisted:  {}",
580                    detailed.persisted_raw.as_deref().unwrap_or("(none)")
581                ))?;
582                crate::output::write_line_fmt(format_args!("  pref_path:  {pref_path}"))?;
583            }
584            Ok(())
585        }
586        LocaleAction::Set { lang } => {
587            let language = negotiate_code(&lang)
588                .ok_or_else(|| anyhow::anyhow!("unsupported language after validation: {lang}"))?;
589            let path = write_persisted_lang(language, override_ref)?;
590            // Note: OnceLock already set for this process; preference applies next run
591            // unless --lang/env override.
592            if wants_json {
593                crate::output::print_json_value(&serde_json::json!({
594                    "ok": true,
595                    "lang": language.bcp47(),
596                    "path": path.display().to_string(),
597                    "applies": "next_invocation_unless_overridden",
598                }))?;
599            } else {
600                crate::output::print_success(&i18n::t(Message::LocalePreferenceSaved {
601                    lang: language.bcp47().to_string(),
602                    path: path.display().to_string(),
603                }));
604            }
605            Ok(())
606        }
607        LocaleAction::Clear => {
608            clear_persisted_lang(override_ref)?;
609            if wants_json {
610                crate::output::print_json_value(&serde_json::json!({
611                    "ok": true,
612                    "cleared": true,
613                }))?;
614            } else {
615                crate::output::print_success(&i18n::t(Message::LocalePreferenceCleared));
616            }
617            Ok(())
618        }
619    }
620}