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 scp_args;
15mod sftp_args;
16mod vps_action;
17mod commands;
18mod path_parse;
19mod schema_cmd;
20
21pub use scp_args::ScpAction;
22pub use sftp_args::SftpAction;
23pub use vps_action::VpsAction;
24pub use commands::{
25    Command, LocaleAction, SecretsAction, TlsAcmeAccountAction, TlsAcmeAction, TlsAction,
26    TlsMtlsAction,
27};
28pub use schema_cmd::run_schema;
29pub(crate) use path_parse::{parse_exec_target, parse_hosts_list, parse_scp_target, ScpPathPlan};
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
89            .as_ref()
90            .map(|p| p.to_string_lossy().into_owned())
91    }
92}
93
94/// Global ssh-cli arguments.
95#[derive(Debug, Parser)]
96#[command(
97    name = crate::constants::APP_NAME,
98    version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
99    about = "One-shot multi-host XDG Rust CLI for LLMs to operate servers over SSH.",
100    long_about = "ssh-cli: lightweight one-shot binary (spawn→run→exit). Multi-host XDG storage without .env. \
101Password or key auth. No telemetry.",
102    after_help = "Examples:\n  \
103ssh-cli vps add --name prod --host h.example --user deploy --key ~/.ssh/id_ed25519\n  \
104printf '%s' \"$PASS\" | ssh-cli exec prod 'hostname' --json --password-stdin\n  \
105ssh-cli scp upload prod ./a.bin /tmp/a.bin --json\n  \
106ssh-cli tunnel prod 8080 127.0.0.1 80 --timeout-ms 60000 --json\n  \
107ssh-cli vps export -o /tmp/hosts.toml",
108    propagate_version = true,
109    arg_required_else_help = true,
110    subcommand_required = true,
111    next_help_heading = "Global options"
112)]
113pub struct CliArgs {
114    /// Forces the CLI language (BCP47; must negotiate to `en` or `pt-BR`).
115    ///
116    /// Examples: `en`, `en-US`, `pt-BR`, `pt`. Invalid tags fail clap validation.
117    #[arg(
118        long,
119        global = true,
120        value_name = "LOCALE",
121        value_parser = crate::locale::parse_lang_cli_arg
122    )]
123    pub lang: Option<String>,
124
125    /// Increases log verbosity on stderr.
126    #[arg(
127        short,
128        long,
129        global = true,
130        action = ArgAction::SetTrue,
131        conflicts_with = "quiet"
132    )]
133    pub verbose: bool,
134
135    /// Suppresses non-JSON output (quiet mode).
136    #[arg(
137        short,
138        long,
139        global = true,
140        action = ArgAction::SetTrue,
141        conflicts_with = "verbose"
142    )]
143    pub quiet: bool,
144
145    /// Configuration directory override (useful for tests).
146    #[arg(
147        long,
148        global = true,
149        value_name = "DIR",
150        value_hint = ValueHint::DirPath
151    )]
152    pub config_dir: Option<PathBuf>,
153
154    /// Disables colored output.
155    #[arg(long, global = true, action = ArgAction::SetTrue)]
156    pub no_color: bool,
157
158    /// Global output format (text, json). If omitted: JSON when stdout is not a TTY.
159    #[arg(long, global = true, value_enum)]
160    pub output_format: Option<OutputFormat>,
161
162    /// Force JSON on stdout (agent; alias of `--output-format json`; G-AUD-01).
163    ///
164    /// Global — appears before or after subcommands. Subcommand fields use
165    /// `from_global` so there is a single `--json` long name (clap uniqueness).
166    #[arg(long, global = true, action = ArgAction::SetTrue)]
167    pub json: bool,
168
169    /// Disables sudo-exec/su-exec for this invocation (alias --disableSudo).
170    #[arg(long, global = true, alias = "disableSudo", action = ArgAction::SetTrue)]
171    pub disable_sudo: bool,
172
173    /// Replaces a diverging host key in TOFU known_hosts.
174    #[arg(long, global = true, action = ArgAction::SetTrue)]
175    pub replace_host_key: bool,
176
177    /// Allow plaintext secrets at rest (no auto `secrets.key`). Prefer for tests only.
178    #[arg(long, global = true, action = ArgAction::SetTrue)]
179    pub allow_plaintext_secrets: bool,
180
181    /// Path to a 64-hex primary-key file (overrides XDG `secrets.key` for this one-shot).
182    #[arg(
183        long,
184        global = true,
185        value_name = "PATH",
186        value_hint = ValueHint::FilePath
187    )]
188    pub secrets_key_file: Option<PathBuf>,
189
190    /// Prefer OS keyring for the primary key (CLI flag only; no product env store).
191    #[arg(long, global = true, action = ArgAction::SetTrue)]
192    pub use_keyring: bool,
193
194    /// Global default timeout in milliseconds for SSH ops (exec/scp/health-check).
195    /// Local `--timeout` on a subcommand wins. Tunnel still requires `--timeout-ms`.
196    #[arg(long, global = true, value_name = "MS")]
197    pub timeout: Option<u64>,
198
199    /// Cap concurrent multi-host SSH sessions / tunnel forwards (1..=MAX_CONCURRENCY).
200    ///
201    /// Default: auto from CPUs × I/O oversubscribe vs free RAM (see `concurrency`).
202    /// Applies to `--all` fan-out and tunnel accepts (no env store; G-UNSAFE-14).
203    #[arg(
204        long,
205        global = true,
206        value_name = "N",
207        value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
208    )]
209    pub max_concurrency: Option<u16>,
210
211    /// Stop admitting new multi-host units after the first failure (G-O1).
212    ///
213    /// Default: continue all hosts (agent-friendly partial success). In-flight
214    /// units still finish; never-started hosts are omitted from batch results
215    /// unless callers pad skipped rows.
216    #[arg(long, global = true, action = ArgAction::SetTrue)]
217    pub fail_fast: bool,
218
219    /// Max concurrent SCP file transfers on **one** SSH session (G-O4).
220    ///
221    /// Default: 1 (serial multi-file, session reuse). Values >1 open parallel
222    /// SCP channels on the same session (bounded). Env: not used; CLI only.
223    #[arg(
224        long,
225        global = true,
226        value_name = "N",
227        value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
228    )]
229    pub scp_file_concurrency: Option<u16>,
230
231    /// Subcommand to run.
232    #[command(subcommand)]
233    pub command: Command,
234}
235
236/// Parses CLI arguments.
237#[must_use]
238pub fn parse_args() -> CliArgs {
239    CliArgs::parse()
240}
241
242/// Merges local subcommand timeout with global `--timeout` (local wins).
243#[must_use]
244pub fn effective_timeout(local: Option<u64>, global: Option<u64>) -> Option<u64> {
245    local.or(global)
246}
247
248/// Merges local/global timeout and refines to [`crate::domain::TimeoutMs`] (G-TYPE-18).
249///
250/// # Errors
251/// Returns domain error text when the effective value is out of range.
252pub fn effective_timeout_ms(
253    local: Option<u64>,
254    global: Option<u64>,
255) -> Result<Option<crate::domain::TimeoutMs>, String> {
256    match effective_timeout(local, global) {
257        None => Ok(None),
258        Some(ms) => crate::domain::TimeoutMs::try_new(ms)
259            .map(Some)
260            .map_err(|e| e.to_string()),
261    }
262}
263
264/// Maps CLI `--step` strings into refined remote commands (G-TYPE-19).
265///
266/// # Errors
267/// Returns domain error text when any step is empty or contains NUL.
268pub fn parse_remote_steps(
269    steps: Vec<String>,
270) -> Result<Vec<crate::domain::RemoteCommand>, String> {
271    steps
272        .into_iter()
273        .map(|s| crate::domain::RemoteCommand::try_new(s).map_err(|e| e.to_string()))
274        .collect()
275}
276
277/// Installs stderr tracing before clap parse (delegates to [`crate::telemetry`]).
278#[inline]
279pub fn bootstrap_logs() {
280    crate::telemetry::bootstrap_logs();
281}
282
283/// Reloads the tracing filter from CLI flags (delegates to [`crate::telemetry`]).
284#[inline]
285pub fn initialize_logs(args: &CliArgs) {
286    crate::telemetry::initialize_logs(args.verbose);
287}
288
289/// Writes shell completions to stdout.
290///
291/// GAP-SSH-CLI-003 / G-IO-08: broken pipe (EPIPE) does not panic — returns
292/// [`crate::errors::SshCliError::Io`] so `main` exits **141**.
293///
294/// # Errors
295/// Stdout write failures (including BrokenPipe).
296pub fn generate_completions(shell: Shell) -> Result<()> {
297    use clap::CommandFactory;
298    use std::io::Write;
299    let mut cmd = CliArgs::command();
300    let mut buf: Vec<u8> = Vec::new();
301    clap_complete::generate(shell, &mut cmd, crate::constants::APP_NAME, &mut buf);
302    let mut out = std::io::stdout().lock();
303    out.write_all(&buf).and_then(|()| out.flush())?;
304    Ok(())
305}
306
307/// Builds a JSON command tree from the clap `Command` graph (G-IO-10).
308#[must_use]
309pub fn command_tree_json() -> serde_json::Value {
310    use clap::CommandFactory;
311    fn walk(cmd: &clap::Command) -> serde_json::Value {
312        let name = cmd.get_name().to_string();
313        let about = cmd.get_about().map(|s| s.to_string());
314        let mut children = Vec::new();
315        for sub in cmd.get_subcommands() {
316            if sub.is_hide_set() {
317                continue;
318            }
319            children.push(walk(sub));
320        }
321        serde_json::json!({
322            "name": name,
323            "about": about,
324            "subcommands": children,
325        })
326    }
327    let root = CliArgs::command();
328    serde_json::json!({
329        "ok": true,
330        "event": "commands",
331        "bin": root.get_name(),
332        "version": env!("CARGO_PKG_VERSION"),
333        "tree": walk(&root),
334    })
335}
336
337/// Renders a man page for `ssh-cli` (G-12 / clap_mangen).
338pub fn render_manpage() -> Result<Vec<u8>, std::io::Error> {
339    use clap::CommandFactory;
340    use std::io::Write;
341    let cmd = CliArgs::command();
342    let man = clap_mangen::Man::new(cmd);
343    let mut buf = Vec::new();
344    man.render(&mut buf)?;
345    // Ensure trailing newline for POSIX man consumers.
346    if !buf.ends_with(b"\n") {
347        buf.write_all(b"\n")?;
348    }
349    Ok(buf)
350}
351
352/// Resolves a secret from `--*-stdin` or an argv value into [`secrecy::SecretString`].
353///
354/// G-SECDEV-01: wrap credentials at the CLI boundary — never forward bare
355/// `String` passwords into exec/scp/tunnel/health overrides.
356pub(crate) fn read_stdin_if(
357    flag: bool,
358    value: Option<String>,
359) -> Result<Option<secrecy::SecretString>> {
360    if flag {
361        Ok(Some(crate::vps::read_secret_stdin()?))
362    } else {
363        Ok(value.map(secrecy::SecretString::from))
364    }
365}
366
367/// Warns only when a secret value is present on argv (not stdin flags).
368///
369/// G-AUD-08: inspect concrete `Option` fields — never `Debug` string heuristics
370/// (`password: None` + any `Some(` elsewhere was a false positive).
371pub(crate) fn warn_if_password_argv(args: &CliArgs) {
372    let has = match &args.command {
373        Command::Exec { auth, .. }
374        | Command::HealthCheck { auth, .. }
375        | Command::Tunnel { auth, .. } => {
376            auth.password.is_some() || auth.key_passphrase.is_some()
377        }
378        Command::SudoExec {
379            auth,
380            sudo_password,
381            ..
382        } => {
383            auth.password.is_some()
384                || auth.key_passphrase.is_some()
385                || sudo_password.is_some()
386        }
387        Command::SuExec {
388            auth,
389            su_password,
390            ..
391        } => {
392            auth.password.is_some() || auth.key_passphrase.is_some() || su_password.is_some()
393        }
394        Command::Scp { action } => match action {
395            ScpAction::Upload { auth, .. } | ScpAction::Download { auth, .. } => {
396                auth.password.is_some() || auth.key_passphrase.is_some()
397            }
398        },
399        Command::Sftp { action } => sftp_auth_has_argv_secret(action),
400        Command::Vps { action } => vps_action_has_argv_secret(action),
401        _ => false,
402    };
403
404    if has {
405        crate::output::print_warning(
406            "a password-like value was passed on the command line (visible in process lists); prefer --*-stdin",
407        );
408    }
409}
410
411fn sftp_auth_has_argv_secret(action: &SftpAction) -> bool {
412    let auth = match action {
413        SftpAction::Upload { auth, .. }
414        | SftpAction::Download { auth, .. }
415        | SftpAction::Ls { auth, .. }
416        | SftpAction::Mkdir { auth, .. }
417        | SftpAction::Rmdir { auth, .. }
418        | SftpAction::Rm { auth, .. }
419        | SftpAction::Rename { auth, .. }
420        | SftpAction::Stat { auth, .. } => auth,
421    };
422    auth.password.is_some() || auth.key_passphrase.is_some()
423}
424
425fn vps_action_has_argv_secret(action: &VpsAction) -> bool {
426    match action {
427        VpsAction::Add {
428            password,
429            key_passphrase,
430            sudo_password,
431            su_password,
432            ..
433        }
434        | VpsAction::Edit {
435            password,
436            key_passphrase,
437            sudo_password,
438            su_password,
439            ..
440        } => {
441            password.is_some()
442                || key_passphrase.is_some()
443                || sudo_password.is_some()
444                || su_password.is_some()
445        }
446        _ => false,
447    }
448}
449
450/// Resolves output format: `--json` global / explicit enum > non-TTY JSON > Text.
451///
452/// G-AUD-01/12: no `SSH_CLI_FORCE_TEXT` env store — use `--output-format text`.
453#[must_use]
454pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
455    if let Some(f) = explicit {
456        return f;
457    }
458    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
459        OutputFormat::Json
460    } else {
461        OutputFormat::Text
462    }
463}
464
465/// Resolves format from global `--json` + `--output-format` (G-AUD-01).
466///
467/// `# Errors`
468/// `--json` together with `--output-format text`.
469pub fn resolve_format_from_cli(
470    json: bool,
471    explicit: Option<OutputFormat>,
472) -> Result<OutputFormat, crate::errors::SshCliError> {
473    // G-AUD-01: `--json` always wins (including when tests pass `--output-format text`
474    // for human stderr isolation while still requesting JSON success bodies).
475    if json {
476        return Ok(OutputFormat::Json);
477    }
478    Ok(resolve_format(explicit))
479}
480
481
482mod dispatch;
483
484pub use dispatch::{dispatch, dispatch_impl};
485
486#[cfg(test)]
487mod tests;