Skip to main content

ssh_cli/cli/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! CLI argument definitions via `clap` derive and dispatcher.
5//!
6//! 1. CRUD de VPS — `vps add|list|remove|edit|show|path|doctor|export|import`
7//! 2. `connect` — writes sibling `active` file (not a TOML field)
8//! 3. One-shot execution — `exec|sudo-exec|su-exec|scp|sftp|tunnel|health-check`
9//! 4. `secrets` — primary-key status/init/reencrypt (cifragem at-rest default)
10//! 5. Completions / `commands` (agent command-tree discovery)
11//!
12//! ZERO `.env` at runtime. ZERO telemetry. One-shot cycle: start → dispatch → exit.
13
14mod commands;
15mod path_parse;
16mod schema_cmd;
17mod scp_args;
18mod sftp_args;
19mod vps_action;
20
21pub use commands::{
22    Command, LocaleAction, SecretsAction, TlsAcmeAccountAction, TlsAcmeAction, TlsAction,
23    TlsMtlsAction,
24};
25pub(crate) use path_parse::{parse_exec_target, parse_hosts_list, parse_scp_target, ScpPathPlan};
26pub use schema_cmd::run_schema;
27pub use scp_args::ScpAction;
28pub use sftp_args::SftpAction;
29pub use vps_action::VpsAction;
30
31use anyhow::Result;
32use clap::{ArgAction, Parser, ValueHint};
33use clap_complete::Shell;
34use std::path::PathBuf;
35
36/// Output format supported by the CLI.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
38pub enum OutputFormat {
39    /// Human-readable text (default).
40    #[default]
41    Text,
42    /// Structured JSON.
43    Json,
44}
45
46/// Parses `--max-*-chars` values: decimal `usize`, or `none`/`0` for unlimited.
47pub(crate) fn parse_cli_char_limit(s: &str) -> Result<usize, String> {
48    let t = s.trim();
49    if t.eq_ignore_ascii_case("none") || t == "0" {
50        return Ok(0);
51    }
52    t.parse::<usize>()
53        .map_err(|e| format!("invalid char limit '{s}': {e}"))
54}
55
56/// Shared SSH authentication overrides (flatten into exec/scp/tunnel/health-check).
57///
58/// Converted to domain strings at the command boundary (G-08/G-09/G-24).
59#[derive(Debug, Clone, Default, clap::Args)]
60#[command(next_help_heading = "Authentication")]
61pub struct SshAuthArgs {
62    /// SSH password override.
63    #[arg(long, conflicts_with = "password_stdin")]
64    pub password: Option<String>,
65    /// Reads the SSH password from stdin.
66    #[arg(long, action = ArgAction::SetTrue)]
67    pub password_stdin: bool,
68    /// Private key path override.
69    #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
70    pub key: Option<PathBuf>,
71    /// Key passphrase.
72    #[arg(long, conflicts_with = "key_passphrase_stdin")]
73    pub key_passphrase: Option<String>,
74    /// Reads the key passphrase from stdin.
75    #[arg(long, action = ArgAction::SetTrue)]
76    pub key_passphrase_stdin: bool,
77    /// Authenticate via ssh-agent (G-SSH-04). Requires `--agent-socket` on Unix.
78    #[arg(long, action = ArgAction::SetTrue)]
79    pub use_agent: bool,
80    /// Agent socket (Unix) or named pipe (Windows). CLI/XDG only — not env store.
81    #[arg(long, value_name = "PATH", value_hint = ValueHint::AnyPath)]
82    pub agent_socket: Option<PathBuf>,
83}
84impl SshAuthArgs {
85    /// Domain boundary: `PathBuf` → owned path string for VPS/SSH layers.
86    #[must_use]
87    pub fn key_path_string(&self) -> Option<String> {
88        self.key.as_ref().map(|p| p.to_string_lossy().into_owned())
89    }
90}
91
92/// Global ssh-cli arguments.
93#[derive(Debug, Parser)]
94#[command(
95    name = crate::constants::APP_NAME,
96    version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
97    about = "One-shot multi-host XDG Rust CLI for LLMs to operate servers over SSH.",
98    long_about = "ssh-cli: lightweight one-shot binary (spawn→run→exit). Multi-host XDG storage without .env. \
99Password or key auth. No telemetry.",
100    after_help = "Examples:\n  \
101ssh-cli vps add --name prod --host h.example --user deploy --key ~/.ssh/id_ed25519\n  \
102printf '%s' \"$PASS\" | ssh-cli exec prod 'hostname' --json --password-stdin\n  \
103ssh-cli scp upload prod ./a.bin /tmp/a.bin --json\n  \
104ssh-cli tunnel prod 8080 127.0.0.1 80 --timeout-ms 60000 --json\n  \
105ssh-cli vps export -o /tmp/hosts.toml",
106    propagate_version = true,
107    arg_required_else_help = true,
108    subcommand_required = true,
109    next_help_heading = "Global options"
110)]
111pub struct CliArgs {
112    /// Forces the CLI language (BCP47; must negotiate to `en` or `pt-BR`).
113    ///
114    /// Examples: `en`, `en-US`, `pt-BR`, `pt`. Invalid tags fail clap validation.
115    #[arg(
116        long,
117        global = true,
118        value_name = "LOCALE",
119        value_parser = crate::locale::parse_lang_cli_arg
120    )]
121    pub lang: Option<String>,
122
123    /// Increases log verbosity on stderr (`-v` info, `-vv` debug, `-vvv` trace).
124    ///
125    /// Always scoped to this crate (G2/G14): never a bare global `debug`/`trace`
126    /// that would enable `russh::client::encrypted` password dumps.
127    #[arg(
128        short,
129        long,
130        global = true,
131        action = ArgAction::Count,
132        conflicts_with = "quiet"
133    )]
134    pub verbose: u8,
135
136    /// Suppresses non-JSON output (quiet mode).
137    #[arg(
138        short,
139        long,
140        global = true,
141        action = ArgAction::SetTrue,
142        conflicts_with = "verbose"
143    )]
144    pub quiet: bool,
145
146    /// Configuration directory override (useful for tests).
147    #[arg(
148        long,
149        global = true,
150        value_name = "DIR",
151        value_hint = ValueHint::DirPath
152    )]
153    pub config_dir: Option<PathBuf>,
154
155    /// Disables colored output.
156    #[arg(long, global = true, action = ArgAction::SetTrue)]
157    pub no_color: bool,
158
159    /// Global output format (text, json). If omitted: JSON when stdout is not a TTY.
160    #[arg(long, global = true, value_enum)]
161    pub output_format: Option<OutputFormat>,
162
163    /// Force JSON on stdout (agent; alias of `--output-format json`; G-AUD-01).
164    ///
165    /// Global — appears before or after subcommands. Subcommand fields use
166    /// `from_global` so there is a single `--json` long name (clap uniqueness).
167    #[arg(long, global = true, action = ArgAction::SetTrue)]
168    pub json: bool,
169
170    /// Keep only these dotted paths in each record (CSV; alias `--fields`).
171    ///
172    /// Agent-native shaping: applied **before** serialization, so the full envelope is
173    /// never built. Without it an agent must pipe through `jaq`, by which point the
174    /// oversized payload has already been written and the tokens already spent.
175    #[arg(
176        long,
177        global = true,
178        alias = "fields",
179        value_name = "PATHS",
180        value_delimiter = ','
181    )]
182    pub select: Vec<String>,
183
184    /// Keep records matching `key=value`, `key!=value` or `key~substring` (repeatable, AND).
185    ///
186    /// A malformed predicate is rejected at parse time rather than silently matching
187    /// nothing — a typo must not be indistinguishable from an empty result.
188    #[arg(long, global = true, value_name = "EXPR")]
189    pub filter: Vec<String>,
190
191    /// Emit at most N records (distinct from per-command query limits).
192    #[arg(long, global = true, value_name = "N")]
193    pub limit: Option<usize>,
194
195    /// Sort records ascending by dotted path (numbers compare numerically).
196    #[arg(long, global = true, value_name = "PATH")]
197    pub sort: Option<String>,
198
199    /// Drop later records repeating this dotted path's value.
200    #[arg(long, global = true, value_name = "PATH")]
201    pub dedupe_by: Option<String>,
202
203    /// Replace the record collection with `{"count": N}`, counted after all filtering.
204    #[arg(long, global = true, action = ArgAction::SetTrue)]
205    pub count_only: bool,
206
207    /// Shorten strings longer than N **characters** (never bytes; UTF-8 stays valid).
208    #[arg(long, global = true, value_name = "CHARS")]
209    pub truncate_content: Option<usize>,
210
211    /// Cap envelope size by dropping trailing records (never by slicing the JSON text).
212    #[arg(long, global = true, value_name = "BYTES")]
213    pub max_output_bytes: Option<usize>,
214
215    /// Refuse to read stdin; fail fast instead of blocking on an absent human.
216    #[arg(long, global = true, action = ArgAction::SetTrue)]
217    pub no_input: bool,
218
219    /// Print the plan for a destructive operation and exit without executing it.
220    ///
221    /// Accepted only by commands that implement it (see
222    /// [`supports_dry_run`]); anywhere else it is rejected with exit 64 rather
223    /// than accepted and ignored. A global flag that silently does nothing on
224    /// half the surface is worse than no flag at all — that is exactly how
225    /// `--no-input` shipped broken on `vps add`.
226    #[arg(long, global = true, action = ArgAction::SetTrue)]
227    pub dry_run: bool,
228
229    /// Disables sudo-exec/su-exec for this invocation (alias --disableSudo).
230    #[arg(long, global = true, alias = "disableSudo", action = ArgAction::SetTrue)]
231    pub disable_sudo: bool,
232
233    /// Replaces a diverging host key in TOFU known_hosts.
234    #[arg(long, global = true, action = ArgAction::SetTrue)]
235    pub replace_host_key: bool,
236
237    /// Allow plaintext secrets at rest (no auto `secrets.key`). Prefer for tests only.
238    #[arg(long, global = true, action = ArgAction::SetTrue)]
239    pub allow_plaintext_secrets: bool,
240
241    /// Path to a 64-hex primary-key file (overrides XDG `secrets.key` for this one-shot).
242    #[arg(
243        long,
244        global = true,
245        value_name = "PATH",
246        value_hint = ValueHint::FilePath
247    )]
248    pub secrets_key_file: Option<PathBuf>,
249
250    /// Prefer OS keyring for the primary key (CLI flag only; no product env store).
251    #[arg(long, global = true, action = ArgAction::SetTrue)]
252    pub use_keyring: bool,
253
254    /// Global default timeout in milliseconds for SSH ops (exec/scp/health-check).
255    /// Local `--timeout` on a subcommand wins. Tunnel still requires `--timeout-ms`.
256    #[arg(long, global = true, value_name = "MS")]
257    pub timeout: Option<u64>,
258
259    /// Cap concurrent multi-host SSH sessions / tunnel forwards (1..=MAX_CONCURRENCY).
260    ///
261    /// Default: auto from CPUs × I/O oversubscribe vs free RAM (see `concurrency`).
262    /// Applies to `--all` fan-out and tunnel accepts (no env store; G-UNSAFE-14).
263    #[arg(
264        long,
265        global = true,
266        value_name = "N",
267        value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
268    )]
269    pub max_concurrency: Option<u16>,
270
271    /// Stop admitting new multi-host units after the first failure (G-O1).
272    ///
273    /// Default: continue all hosts (agent-friendly partial success). In-flight
274    /// units still finish; never-started hosts are omitted from batch results
275    /// unless callers pad skipped rows.
276    #[arg(long, global = true, action = ArgAction::SetTrue)]
277    pub fail_fast: bool,
278
279    /// Max concurrent SCP file transfers on **one** SSH session (G-O4).
280    ///
281    /// Default: 1 (serial multi-file, session reuse). Values >1 open parallel
282    /// SCP channels on the same session (bounded). Env: not used; CLI only.
283    #[arg(
284        long,
285        global = true,
286        value_name = "N",
287        value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
288    )]
289    pub scp_file_concurrency: Option<u16>,
290
291    /// Subcommand to run.
292    #[command(subcommand)]
293    pub command: Command,
294}
295
296/// Parses CLI arguments.
297#[must_use]
298pub fn parse_args() -> CliArgs {
299    CliArgs::parse()
300}
301
302/// Merges local subcommand timeout with global `--timeout` (local wins).
303#[must_use]
304pub fn effective_timeout(local: Option<u64>, global: Option<u64>) -> Option<u64> {
305    local.or(global)
306}
307
308/// Merges local/global timeout and refines to [`crate::domain::TimeoutMs`] (G-TYPE-18).
309///
310/// # Errors
311/// Returns domain error text when the effective value is out of range.
312pub fn effective_timeout_ms(
313    local: Option<u64>,
314    global: Option<u64>,
315) -> Result<Option<crate::domain::TimeoutMs>, String> {
316    match effective_timeout(local, global) {
317        None => Ok(None),
318        Some(ms) => crate::domain::TimeoutMs::try_new(ms)
319            .map(Some)
320            .map_err(|e| e.to_string()),
321    }
322}
323
324/// Maps CLI `--step` strings into refined remote commands (G-TYPE-19).
325///
326/// # Errors
327/// Returns domain error text when any step is empty or contains NUL.
328pub fn parse_remote_steps(steps: Vec<String>) -> Result<Vec<crate::domain::RemoteCommand>, String> {
329    steps
330        .into_iter()
331        .map(|s| crate::domain::RemoteCommand::try_new(s).map_err(|e| e.to_string()))
332        .collect()
333}
334
335/// Installs stderr tracing before clap parse (delegates to [`crate::telemetry`]).
336#[inline]
337pub fn bootstrap_logs() {
338    crate::telemetry::bootstrap_logs();
339}
340
341/// Reloads the tracing filter from CLI flags (delegates to [`crate::telemetry`]).
342#[inline]
343pub fn initialize_logs(args: &CliArgs) {
344    crate::telemetry::initialize_logs(args.verbose);
345}
346
347/// Writes shell completions to stdout.
348///
349/// GAP-SSH-CLI-003 / G-IO-08: broken pipe (EPIPE) does not panic — returns
350/// [`crate::errors::SshCliError::Io`] so `main` exits **141**.
351///
352/// # Errors
353/// Stdout write failures (including BrokenPipe).
354pub fn generate_completions(shell: Shell) -> Result<()> {
355    use clap::CommandFactory;
356    use std::io::Write;
357    let mut cmd = CliArgs::command();
358    let mut buf: Vec<u8> = Vec::new();
359    clap_complete::generate(shell, &mut cmd, crate::constants::APP_NAME, &mut buf);
360    let mut out = std::io::stdout().lock();
361    out.write_all(&buf).and_then(|()| out.flush())?;
362    Ok(())
363}
364
365/// Builds a JSON command tree from the clap `Command` graph (G-IO-10).
366#[must_use]
367pub fn command_tree_json() -> serde_json::Value {
368    use clap::CommandFactory;
369    fn walk(cmd: &clap::Command) -> serde_json::Value {
370        let name = cmd.get_name().to_string();
371        let about = cmd.get_about().map(|s| s.to_string());
372        let mut children = Vec::new();
373        for sub in cmd.get_subcommands() {
374            if sub.is_hide_set() {
375                continue;
376            }
377            children.push(walk(sub));
378        }
379        serde_json::json!({
380            "name": name,
381            "about": about,
382            "subcommands": children,
383        })
384    }
385    let root = CliArgs::command();
386    serde_json::json!({
387        "ok": true,
388        "event": "commands",
389        "bin": root.get_name(),
390        "version": env!("CARGO_PKG_VERSION"),
391        "tree": walk(&root),
392    })
393}
394
395/// Renders a man page for `ssh-cli` (G-12 / clap_mangen).
396pub fn render_manpage() -> Result<Vec<u8>, std::io::Error> {
397    use clap::CommandFactory;
398    use std::io::Write;
399    let cmd = CliArgs::command();
400    let man = clap_mangen::Man::new(cmd);
401    let mut buf = Vec::new();
402    man.render(&mut buf)?;
403    // Ensure trailing newline for POSIX man consumers.
404    if !buf.ends_with(b"\n") {
405        buf.write_all(b"\n")?;
406    }
407    Ok(buf)
408}
409
410/// Resolves a secret from `--*-stdin` or an argv value into [`secrecy::SecretString`].
411///
412/// G-SECDEV-01: wrap credentials at the CLI boundary — never forward bare
413/// `String` passwords into exec/scp/tunnel/health overrides.
414pub(crate) fn read_stdin_if(
415    flag: bool,
416    value: Option<String>,
417) -> Result<Option<secrecy::SecretString>> {
418    if flag {
419        // C2: `--no-input` refuses stdin declaratively. Without it, an agent that
420        // passed `--password-stdin` with nothing piped in would block forever waiting
421        // on a human who is not there — the failure mode is a hung process rather than
422        // an error, which is the worst outcome for unattended automation.
423        //
424        // The refusal lives inside `read_secret_stdin` so every caller inherits it;
425        // duplicating it here would leave `vps add`/`vps edit` uncovered again.
426        Ok(Some(crate::vps::read_secret_stdin()?))
427    } else {
428        Ok(value.map(secrecy::SecretString::from))
429    }
430}
431
432/// Process-wide `--no-input` switch.
433static NO_INPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
434
435/// Installs the `--no-input` policy for this one-shot process.
436pub fn set_no_input(value: bool) {
437    NO_INPUT.store(value, std::sync::atomic::Ordering::Relaxed);
438}
439
440/// Whether stdin reads must fail instead of blocking.
441#[must_use]
442pub fn is_no_input() -> bool {
443    NO_INPUT.load(std::sync::atomic::Ordering::Relaxed)
444}
445
446/// Resolves the tunnel mode from the mutually exclusive mode flags.
447///
448/// Pure: no registry, no socket, no clock. The positional arguments mean
449/// different things per mode — `remote_host` is a destination for a local
450/// forward and a *server bind address* under `--reverse` — so getting this wrong
451/// points the tunnel somewhere the caller never asked for. Keeping the decision
452/// in one testable function is what lets every combination be covered without a
453/// server.
454///
455/// # Errors
456/// [`crate::errors::SshCliError::InvalidArgument`] (exit 64) when a mode is given
457/// arguments it cannot use, or is missing arguments it requires.
458pub fn resolve_tunnel_mode(
459    socks5: bool,
460    remote_socket: Option<String>,
461    reverse: bool,
462    remote_host: Option<String>,
463    remote_port: Option<u16>,
464) -> Result<crate::tunnel::TunnelMode, crate::errors::SshCliError> {
465    use crate::errors::SshCliError::InvalidArgument;
466    use crate::tunnel::TunnelMode;
467
468    let positional_given = remote_host.is_some() || remote_port.is_some();
469
470    if socks5 {
471        if positional_given {
472            return Err(InvalidArgument(
473                "--socks5 chooses a destination per connection; remove REMOTE_HOST and REMOTE_PORT"
474                    .to_string(),
475            ));
476        }
477        return Ok(TunnelMode::Socks5);
478    }
479
480    if let Some(socket_path) = remote_socket {
481        if positional_given {
482            return Err(InvalidArgument(
483                "--remote-socket replaces the destination; remove REMOTE_HOST and REMOTE_PORT"
484                    .to_string(),
485            ));
486        }
487        return Ok(TunnelMode::StreamLocal { socket_path });
488    }
489
490    let (Some(host), Some(port)) = (remote_host, remote_port) else {
491        return Err(InvalidArgument(
492            "tunnel requires REMOTE_HOST and REMOTE_PORT unless --socks5 or --remote-socket \
493             is used"
494                .to_string(),
495        ));
496    };
497
498    if reverse {
499        // Port 0 is meaningful here and only here: the server allocates and reports
500        // back, exactly like a local ephemeral bind.
501        return Ok(TunnelMode::Reverse {
502            remote_bind: host,
503            remote_port: port,
504        });
505    }
506
507    if port == 0 {
508        return Err(InvalidArgument(
509            "REMOTE_PORT 0 is only valid with --reverse, where the server allocates the port"
510                .to_string(),
511        ));
512    }
513    Ok(TunnelMode::Local {
514        remote_host: host,
515        remote_port: port,
516    })
517}
518
519/// Process-wide `--dry-run` switch.
520static DRY_RUN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
521
522/// Installs the `--dry-run` policy for this one-shot process.
523pub fn set_dry_run(value: bool) {
524    DRY_RUN.store(value, std::sync::atomic::Ordering::Relaxed);
525}
526
527/// Whether destructive operations must emit a plan instead of executing.
528#[must_use]
529pub fn is_dry_run() -> bool {
530    DRY_RUN.load(std::sync::atomic::Ordering::Relaxed)
531}
532
533/// Whether this command implements `--dry-run`.
534///
535/// C2 covers the operations that destroy state without a prior read: registry
536/// removal and import, remote unlink and rmdir, and the two `secrets` writes
537/// that can invalidate every stored credential at once. Transfers and `exec`
538/// are deliberately absent — their effect is the remote command itself, which
539/// this CLI cannot preview without running it.
540#[must_use]
541pub fn supports_dry_run(command: &Command) -> bool {
542    use crate::cli::{SecretsAction, SftpAction, VpsAction};
543    match command {
544        Command::Vps { action } => {
545            matches!(action, VpsAction::Remove { .. } | VpsAction::Import { .. })
546        }
547        Command::Sftp { action, .. } => {
548            matches!(action, SftpAction::Rm { .. } | SftpAction::Rmdir { .. })
549        }
550        Command::Secrets { action } => matches!(
551            action,
552            SecretsAction::Init { .. } | SecretsAction::Reencrypt { .. }
553        ),
554        _ => false,
555    }
556}
557
558/// Rejects `--dry-run` on a command that cannot honour it.
559///
560/// # Errors
561/// [`crate::errors::SshCliError::InvalidArgument`] (exit 64) when the flag was
562/// passed to an unsupported command.
563pub fn guard_dry_run_supported(command: &Command) -> Result<(), crate::errors::SshCliError> {
564    if !is_dry_run() || supports_dry_run(command) {
565        return Ok(());
566    }
567    Err(crate::errors::SshCliError::InvalidArgument(
568        "--dry-run is not implemented for this command; it is accepted only by \
569         `vps remove`, `vps import`, `sftp rm`, `sftp rmdir`, `secrets init` and \
570         `secrets reencrypt`"
571            .to_string(),
572    ))
573}
574
575/// Emits the plan for a destructive operation and reports whether to stop.
576///
577/// Returns `true` when the caller must return without mutating anything. The
578/// plan always reaches stdout as JSON, even in text mode: the point of a
579/// preview is that a machine can diff it against what it intended, and prose
580/// cannot be diffed.
581///
582/// # Errors
583/// stdout write failures (including `BrokenPipe`).
584pub fn dry_run_stop(
585    operation: &str,
586    fields: &[(&str, serde_json::Value)],
587) -> Result<bool, crate::errors::SshCliError> {
588    if !is_dry_run() {
589        return Ok(false);
590    }
591    let mut map = std::collections::BTreeMap::new();
592    map.insert("operation".to_string(), serde_json::json!(operation));
593    map.insert("dry_run".to_string(), serde_json::json!(true));
594    map.insert("executed".to_string(), serde_json::json!(false));
595    for (k, v) in fields {
596        map.insert((*k).to_string(), v.clone());
597    }
598    crate::json_wire::print_json_line(&crate::json_wire::SuccessEnvelope::new("dry-run", map))
599        .map_err(crate::errors::SshCliError::Io)?;
600    Ok(true)
601}
602
603/// Warns only when a secret value is present on argv (not stdin flags).
604///
605/// G-AUD-08: inspect concrete `Option` fields — never `Debug` string heuristics
606/// (`password: None` + any `Some(` elsewhere was a false positive).
607pub(crate) fn warn_if_password_argv(args: &CliArgs) {
608    let has = match &args.command {
609        Command::Exec { auth, .. }
610        | Command::HealthCheck { auth, .. }
611        | Command::Tunnel { auth, .. } => auth.password.is_some() || auth.key_passphrase.is_some(),
612        Command::SudoExec {
613            auth,
614            sudo_password,
615            ..
616        } => auth.password.is_some() || auth.key_passphrase.is_some() || sudo_password.is_some(),
617        Command::SuExec {
618            auth, su_password, ..
619        } => auth.password.is_some() || auth.key_passphrase.is_some() || su_password.is_some(),
620        Command::Scp { action } => match action {
621            ScpAction::Upload { auth, .. } | ScpAction::Download { auth, .. } => {
622                auth.password.is_some() || auth.key_passphrase.is_some()
623            }
624        },
625        Command::Sftp { action } => sftp_auth_has_argv_secret(action),
626        Command::Vps { action } => vps_action_has_argv_secret(action),
627        _ => false,
628    };
629
630    if has {
631        crate::output::print_warning(
632            "a password-like value was passed on the command line (visible in process lists); prefer --*-stdin",
633        );
634    }
635}
636
637fn sftp_auth_has_argv_secret(action: &SftpAction) -> bool {
638    let auth = match action {
639        SftpAction::Upload { auth, .. }
640        | SftpAction::Download { auth, .. }
641        | SftpAction::Ls { auth, .. }
642        | SftpAction::Mkdir { auth, .. }
643        | SftpAction::Rmdir { auth, .. }
644        | SftpAction::Rm { auth, .. }
645        | SftpAction::Rename { auth, .. }
646        | SftpAction::Stat { auth, .. } => auth,
647    };
648    auth.password.is_some() || auth.key_passphrase.is_some()
649}
650
651fn vps_action_has_argv_secret(action: &VpsAction) -> bool {
652    match action {
653        VpsAction::Add {
654            password,
655            key_passphrase,
656            sudo_password,
657            su_password,
658            ..
659        }
660        | VpsAction::Edit {
661            password,
662            key_passphrase,
663            sudo_password,
664            su_password,
665            ..
666        } => {
667            password.is_some()
668                || key_passphrase.is_some()
669                || sudo_password.is_some()
670                || su_password.is_some()
671        }
672        _ => false,
673    }
674}
675
676/// Resolves output format: `--json` global / explicit enum > non-TTY JSON > Text.
677///
678/// G-AUD-01/12: no `SSH_CLI_FORCE_TEXT` env store — use `--output-format text`.
679#[must_use]
680pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
681    if let Some(f) = explicit {
682        return f;
683    }
684    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
685        OutputFormat::Json
686    } else {
687        OutputFormat::Text
688    }
689}
690
691/// Resolves format from global `--json` + `--output-format` (G-AUD-01).
692///
693/// `# Errors`
694/// `--json` together with `--output-format text`.
695pub fn resolve_format_from_cli(
696    json: bool,
697    explicit: Option<OutputFormat>,
698) -> Result<OutputFormat, crate::errors::SshCliError> {
699    // G-AUD-01: `--json` always wins (including when tests pass `--output-format text`
700    // for human stderr isolation while still requesting JSON success bodies).
701    if json {
702        return Ok(OutputFormat::Json);
703    }
704    Ok(resolve_format(explicit))
705}
706
707mod dispatch;
708
709pub use dispatch::{dispatch, dispatch_impl};
710
711#[cfg(test)]
712mod tests;