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