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    /// Disables sudo-exec/su-exec for this invocation (alias --disableSudo).
171    #[arg(long, global = true, alias = "disableSudo", action = ArgAction::SetTrue)]
172    pub disable_sudo: bool,
173
174    /// Replaces a diverging host key in TOFU known_hosts.
175    #[arg(long, global = true, action = ArgAction::SetTrue)]
176    pub replace_host_key: bool,
177
178    /// Allow plaintext secrets at rest (no auto `secrets.key`). Prefer for tests only.
179    #[arg(long, global = true, action = ArgAction::SetTrue)]
180    pub allow_plaintext_secrets: bool,
181
182    /// Path to a 64-hex primary-key file (overrides XDG `secrets.key` for this one-shot).
183    #[arg(
184        long,
185        global = true,
186        value_name = "PATH",
187        value_hint = ValueHint::FilePath
188    )]
189    pub secrets_key_file: Option<PathBuf>,
190
191    /// Prefer OS keyring for the primary key (CLI flag only; no product env store).
192    #[arg(long, global = true, action = ArgAction::SetTrue)]
193    pub use_keyring: bool,
194
195    /// Global default timeout in milliseconds for SSH ops (exec/scp/health-check).
196    /// Local `--timeout` on a subcommand wins. Tunnel still requires `--timeout-ms`.
197    #[arg(long, global = true, value_name = "MS")]
198    pub timeout: Option<u64>,
199
200    /// Cap concurrent multi-host SSH sessions / tunnel forwards (1..=MAX_CONCURRENCY).
201    ///
202    /// Default: auto from CPUs × I/O oversubscribe vs free RAM (see `concurrency`).
203    /// Applies to `--all` fan-out and tunnel accepts (no env store; G-UNSAFE-14).
204    #[arg(
205        long,
206        global = true,
207        value_name = "N",
208        value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
209    )]
210    pub max_concurrency: Option<u16>,
211
212    /// Stop admitting new multi-host units after the first failure (G-O1).
213    ///
214    /// Default: continue all hosts (agent-friendly partial success). In-flight
215    /// units still finish; never-started hosts are omitted from batch results
216    /// unless callers pad skipped rows.
217    #[arg(long, global = true, action = ArgAction::SetTrue)]
218    pub fail_fast: bool,
219
220    /// Max concurrent SCP file transfers on **one** SSH session (G-O4).
221    ///
222    /// Default: 1 (serial multi-file, session reuse). Values >1 open parallel
223    /// SCP channels on the same session (bounded). Env: not used; CLI only.
224    #[arg(
225        long,
226        global = true,
227        value_name = "N",
228        value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
229    )]
230    pub scp_file_concurrency: Option<u16>,
231
232    /// Subcommand to run.
233    #[command(subcommand)]
234    pub command: Command,
235}
236
237/// Parses CLI arguments.
238#[must_use]
239pub fn parse_args() -> CliArgs {
240    CliArgs::parse()
241}
242
243/// Merges local subcommand timeout with global `--timeout` (local wins).
244#[must_use]
245pub fn effective_timeout(local: Option<u64>, global: Option<u64>) -> Option<u64> {
246    local.or(global)
247}
248
249/// Merges local/global timeout and refines to [`crate::domain::TimeoutMs`] (G-TYPE-18).
250///
251/// # Errors
252/// Returns domain error text when the effective value is out of range.
253pub fn effective_timeout_ms(
254    local: Option<u64>,
255    global: Option<u64>,
256) -> Result<Option<crate::domain::TimeoutMs>, String> {
257    match effective_timeout(local, global) {
258        None => Ok(None),
259        Some(ms) => crate::domain::TimeoutMs::try_new(ms)
260            .map(Some)
261            .map_err(|e| e.to_string()),
262    }
263}
264
265/// Maps CLI `--step` strings into refined remote commands (G-TYPE-19).
266///
267/// # Errors
268/// Returns domain error text when any step is empty or contains NUL.
269pub fn parse_remote_steps(steps: Vec<String>) -> Result<Vec<crate::domain::RemoteCommand>, String> {
270    steps
271        .into_iter()
272        .map(|s| crate::domain::RemoteCommand::try_new(s).map_err(|e| e.to_string()))
273        .collect()
274}
275
276/// Installs stderr tracing before clap parse (delegates to [`crate::telemetry`]).
277#[inline]
278pub fn bootstrap_logs() {
279    crate::telemetry::bootstrap_logs();
280}
281
282/// Reloads the tracing filter from CLI flags (delegates to [`crate::telemetry`]).
283#[inline]
284pub fn initialize_logs(args: &CliArgs) {
285    crate::telemetry::initialize_logs(args.verbose);
286}
287
288/// Writes shell completions to stdout.
289///
290/// GAP-SSH-CLI-003 / G-IO-08: broken pipe (EPIPE) does not panic — returns
291/// [`crate::errors::SshCliError::Io`] so `main` exits **141**.
292///
293/// # Errors
294/// Stdout write failures (including BrokenPipe).
295pub fn generate_completions(shell: Shell) -> Result<()> {
296    use clap::CommandFactory;
297    use std::io::Write;
298    let mut cmd = CliArgs::command();
299    let mut buf: Vec<u8> = Vec::new();
300    clap_complete::generate(shell, &mut cmd, crate::constants::APP_NAME, &mut buf);
301    let mut out = std::io::stdout().lock();
302    out.write_all(&buf).and_then(|()| out.flush())?;
303    Ok(())
304}
305
306/// Builds a JSON command tree from the clap `Command` graph (G-IO-10).
307#[must_use]
308pub fn command_tree_json() -> serde_json::Value {
309    use clap::CommandFactory;
310    fn walk(cmd: &clap::Command) -> serde_json::Value {
311        let name = cmd.get_name().to_string();
312        let about = cmd.get_about().map(|s| s.to_string());
313        let mut children = Vec::new();
314        for sub in cmd.get_subcommands() {
315            if sub.is_hide_set() {
316                continue;
317            }
318            children.push(walk(sub));
319        }
320        serde_json::json!({
321            "name": name,
322            "about": about,
323            "subcommands": children,
324        })
325    }
326    let root = CliArgs::command();
327    serde_json::json!({
328        "ok": true,
329        "event": "commands",
330        "bin": root.get_name(),
331        "version": env!("CARGO_PKG_VERSION"),
332        "tree": walk(&root),
333    })
334}
335
336/// Renders a man page for `ssh-cli` (G-12 / clap_mangen).
337pub fn render_manpage() -> Result<Vec<u8>, std::io::Error> {
338    use clap::CommandFactory;
339    use std::io::Write;
340    let cmd = CliArgs::command();
341    let man = clap_mangen::Man::new(cmd);
342    let mut buf = Vec::new();
343    man.render(&mut buf)?;
344    // Ensure trailing newline for POSIX man consumers.
345    if !buf.ends_with(b"\n") {
346        buf.write_all(b"\n")?;
347    }
348    Ok(buf)
349}
350
351/// Resolves a secret from `--*-stdin` or an argv value into [`secrecy::SecretString`].
352///
353/// G-SECDEV-01: wrap credentials at the CLI boundary — never forward bare
354/// `String` passwords into exec/scp/tunnel/health overrides.
355pub(crate) fn read_stdin_if(
356    flag: bool,
357    value: Option<String>,
358) -> Result<Option<secrecy::SecretString>> {
359    if flag {
360        Ok(Some(crate::vps::read_secret_stdin()?))
361    } else {
362        Ok(value.map(secrecy::SecretString::from))
363    }
364}
365
366/// Warns only when a secret value is present on argv (not stdin flags).
367///
368/// G-AUD-08: inspect concrete `Option` fields — never `Debug` string heuristics
369/// (`password: None` + any `Some(` elsewhere was a false positive).
370pub(crate) fn warn_if_password_argv(args: &CliArgs) {
371    let has = match &args.command {
372        Command::Exec { auth, .. }
373        | Command::HealthCheck { auth, .. }
374        | Command::Tunnel { auth, .. } => auth.password.is_some() || auth.key_passphrase.is_some(),
375        Command::SudoExec {
376            auth,
377            sudo_password,
378            ..
379        } => auth.password.is_some() || auth.key_passphrase.is_some() || sudo_password.is_some(),
380        Command::SuExec {
381            auth, su_password, ..
382        } => auth.password.is_some() || auth.key_passphrase.is_some() || su_password.is_some(),
383        Command::Scp { action } => match action {
384            ScpAction::Upload { auth, .. } | ScpAction::Download { auth, .. } => {
385                auth.password.is_some() || auth.key_passphrase.is_some()
386            }
387        },
388        Command::Sftp { action } => sftp_auth_has_argv_secret(action),
389        Command::Vps { action } => vps_action_has_argv_secret(action),
390        _ => false,
391    };
392
393    if has {
394        crate::output::print_warning(
395            "a password-like value was passed on the command line (visible in process lists); prefer --*-stdin",
396        );
397    }
398}
399
400fn sftp_auth_has_argv_secret(action: &SftpAction) -> bool {
401    let auth = match action {
402        SftpAction::Upload { auth, .. }
403        | SftpAction::Download { auth, .. }
404        | SftpAction::Ls { auth, .. }
405        | SftpAction::Mkdir { auth, .. }
406        | SftpAction::Rmdir { auth, .. }
407        | SftpAction::Rm { auth, .. }
408        | SftpAction::Rename { auth, .. }
409        | SftpAction::Stat { auth, .. } => auth,
410    };
411    auth.password.is_some() || auth.key_passphrase.is_some()
412}
413
414fn vps_action_has_argv_secret(action: &VpsAction) -> bool {
415    match action {
416        VpsAction::Add {
417            password,
418            key_passphrase,
419            sudo_password,
420            su_password,
421            ..
422        }
423        | VpsAction::Edit {
424            password,
425            key_passphrase,
426            sudo_password,
427            su_password,
428            ..
429        } => {
430            password.is_some()
431                || key_passphrase.is_some()
432                || sudo_password.is_some()
433                || su_password.is_some()
434        }
435        _ => false,
436    }
437}
438
439/// Resolves output format: `--json` global / explicit enum > non-TTY JSON > Text.
440///
441/// G-AUD-01/12: no `SSH_CLI_FORCE_TEXT` env store — use `--output-format text`.
442#[must_use]
443pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
444    if let Some(f) = explicit {
445        return f;
446    }
447    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
448        OutputFormat::Json
449    } else {
450        OutputFormat::Text
451    }
452}
453
454/// Resolves format from global `--json` + `--output-format` (G-AUD-01).
455///
456/// `# Errors`
457/// `--json` together with `--output-format text`.
458pub fn resolve_format_from_cli(
459    json: bool,
460    explicit: Option<OutputFormat>,
461) -> Result<OutputFormat, crate::errors::SshCliError> {
462    // G-AUD-01: `--json` always wins (including when tests pass `--output-format text`
463    // for human stderr isolation while still requesting JSON success bodies).
464    if json {
465        return Ok(OutputFormat::Json);
466    }
467    Ok(resolve_format(explicit))
468}
469
470mod dispatch;
471
472pub use dispatch::{dispatch, dispatch_impl};
473
474#[cfg(test)]
475mod tests;