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