ssh_cli/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! # ssh-cli
3//!
4//! Full-stack Rust CLI that gives an LLM (Claude Code, Cursor, Windsurf) the ability
5//! to operate remote servers over SSH in a subprocess flow via stdin/stdout.
6//!
7//! ## Modules
8//!
9//! | Module | Responsibility |
10//! |-----------------|---------------------------------------------------------------|
11//! | `cli` | Clap derive argument definitions (contract) |
12//! | `commands` | Subcommand dispatch layer (CAMADA 2) |
13//! | `concurrency` | Bounded multi-host / tunnel fan-out (`Semaphore` + `JoinSet`) |
14//! | `constants` | Named domain constants (XDG names, env keys, network/timing) |
15//! | `net` | Async DNS + Happy Eyeballs TCP dial for SSH connect |
16//! | `error`/`errors`| Structured error types via `thiserror` + retry classification |
17//! | `retry` | Named `RetryConfig` + full-jitter backoff (agent contract) |
18//! | `vps` | CRUD and persistence of VPS records (XDG + TOML + 0o600) |
19//! | `secrets` | Primary key and default at-rest encryption (ChaCha20-Poly1305)|
20//! | `ssh` | Real one-shot SSH client via `russh` (password/key, TOFU) |
21//! | `tls` | rustls (aws_lc_rs): SSH-over-TLS, mTLS, ACME (feature `tls`) |
22//! | `i18n` | Bilingual UI (`Message` enum + exhaustive EN/pt-BR match) |
23//! | `json_wire` | Typed agent JSON DTOs + compact emit (RFC 8259, not NDJSON) |
24//! | `locale` | BCP47 detect/negotiate (`sys-locale` + `unic-langid` + langneg)|
25//! | `platform` | Windows UTF-8/VT, runtime env (WSL/container/CI), TTY |
26//! | `masking` | Unicode-safe masking of sensitive values |
27//! | `output` | Sole module authorized for stdout/stderr data emission |
28//! | `paths` | Path validation and normalization (anti-traversal, NFC) |
29//! | `signals` | One-shot SIGINT/SIGTERM flags + cooperative `should_stop` |
30//! | `telemetry` | Process-local `tracing` install (stderr; no OTEL / no files) |
31//! | `validation` | Parse→serde→validator pipeline for config/import (no OTEL) |
32//! | `domain` | Newtypes: VpsName, Rfc3339Utc, BatchRunId, HttpsUrl, Money… |
33//! | `terminal` | TTY detection and color choice via `termcolor` |
34//!
35//! ## Features
36//!
37//! | Feature | Default | Effect |
38//! |--------------------|---------|---------------------------------------------------------------------|
39//! | `ssh-real` | yes | Real SSH via `russh` + `aws-lc-rs` (compression `none` only; G-TLS) |
40//! | `tls` | yes | rustls ≥0.23.18 + aws_lc_rs: SSH-over-TLS, mTLS, ACME |
41//! | `musl-allocator` | no | Uses `mimalloc` as `#[global_allocator]` (binary only; musl/Alpine) |
42//! | `i18n-full` | no | Reserved: top-20 economic locales (no extra strings yet) |
43//! | `i18n-cjk` | no | Reserved: zh-Hans / zh-Hant / ja / ko |
44//! | `i18n-rtl` | no | Reserved: ar / he (RTL isolation) |
45//! | `i18n-europe` | no | Reserved: additional European locales |
46//!
47//! Disable real SSH only for dependency diagnosis: `--no-default-features`.
48//! Documented feature gates use `#[doc(cfg(...))]` under the `docsrs` cfg.
49//! Default binary always embeds **en** + **pt-BR** only (Rules: no full top-20).
50//!
51//! ## Entry point
52//!
53//! The public [`run`] function is the entry point called by `main.rs`.
54//!
55//! ## Safety
56//!
57//! - **docs.rs / rustdoc:** when built with `--cfg docsrs`, this crate enables
58//! `#![feature(doc_cfg)]` so `#[doc(cfg(...))]` labels render on feature-gated
59//! items (migration `doc_auto_cfg` → `doc_cfg`). Consumers of *this* crate do
60//! not need nightly; only the docs.rs build uses the feature gate.
61//! - **unsafe:** product code avoids `unsafe` on the happy path; remaining blocks
62//! (platform console / Unix permissions) are documented at the call site.
63
64#![cfg_attr(docsrs, feature(doc_cfg))]
65// A6: without `ssh-real` the SCP/exec wire helpers have no caller — the stub client
66// answers every method with a typed refusal. They are not dead code in any shipped
67// build, so silencing the lint only in the diagnostic configuration keeps
68// `--no-default-features` usable without weakening the default build's warnings.
69#![cfg_attr(not(feature = "ssh-real"), allow(dead_code, unused_imports))]
70#![warn(missing_docs)]
71#![warn(rust_2018_idioms)]
72// G-SECDEV-05: crate root cannot `forbid(unsafe_code)` — Windows console FFI and
73// Unix test env helpers need minimal `unsafe`. Pure modules apply
74// `#![forbid(unsafe_code)]` individually. Undocumented/multi-op blocks are deny.
75#![deny(clippy::undocumented_unsafe_blocks)]
76#![deny(clippy::multiple_unsafe_ops_per_block)]
77// G-SEC-01: each unsafe op inside `unsafe fn` must still sit in an explicit
78// `unsafe {}` block (RFC 2585 / edition 2024 posture). Deny so regressions fail CI.
79#![deny(unsafe_op_in_unsafe_fn)]
80// G-SECDEV-06: `mem::forget` on Drop-critical types is a security antipattern.
81#![deny(clippy::mem_forget)]
82#![warn(rustdoc::broken_intra_doc_links)]
83// Const/static rules: forbid static mut refs and interior-mutable `const`.
84#![deny(static_mut_refs)]
85#![deny(clippy::declare_interior_mutable_const)]
86#![deny(clippy::borrow_interior_mutable_const)]
87
88/// Agent-native payload shaping applied at the JSON serialization funnel.
89pub mod agent_shape;
90pub mod cli;
91pub mod commands;
92/// Bounded multi-host / tunnel fan-out (Semaphore + JoinSet).
93pub mod concurrency;
94/// Named domain constants (XDG file names, env keys, network/timing defaults).
95pub mod constants;
96/// Domain newtypes (parse, don't validate — G-TYPE / G-DOM 4-crates).
97pub mod domain;
98/// Canonical error module name per clap layout rules (`error.rs`).
99pub mod error;
100/// Structured error types (`thiserror`) and sysexits-style exit codes.
101pub mod errors;
102/// Unix secret file/dir permission helpers (G-AUD-24).
103pub mod fs_perm;
104pub mod i18n;
105pub mod json_wire;
106pub mod locale;
107pub mod masking;
108/// TCP dial (async DNS + Happy Eyeballs multi-address connect).
109pub mod net;
110pub mod output;
111pub mod paths;
112pub mod platform;
113/// Explicit retry policy + full-jitter backoff (agent re-invoke; opt-in in-process).
114pub mod retry;
115pub mod scp;
116pub mod secrets;
117/// SFTP subsystem (requires the real SSH stack).
118///
119/// A6: this module calls `russh_sftp` directly through `crate::ssh::sftp_session`,
120/// which is itself gated behind `ssh-real`. Leaving it ungated meant the
121/// `--no-default-features` build documented in `Cargo.toml` — the one used to diagnose
122/// dependency problems — failed with 62 errors instead of producing a stack-free
123/// binary. Gating the module is what makes that documented configuration real again.
124#[cfg(feature = "ssh-real")]
125pub mod sftp;
126pub mod signals;
127pub mod ssh;
128/// Process-local tracing subscriber (binary path only; libraries only emit).
129pub mod telemetry;
130pub mod terminal;
131/// rustls TLS stack (SSH-over-TLS, mTLS, ACME) — feature `tls` (default).
132pub mod tls;
133pub mod tunnel;
134/// Shared parse→validate pipeline for external config/import (G-SERDE-07).
135pub mod validation;
136pub mod vps;
137
138/// Test-only helpers (env mutation with SAFETY). Not linked into release builds.
139#[cfg(test)]
140pub(crate) mod test_util;
141
142use anyhow::Result;
143
144/// Runs ssh-cli from the command-line arguments.
145///
146/// One-shot lifecycle (Rules Rust CLI one-shot — six phases):
147/// 1. **Init** — SIGINT/SIGTERM handlers, bootstrap tracing (stderr, error-only).
148/// 2. Platform (UTF-8 / TTY).
149/// 3. **Parse** — clap derive.
150/// 4. **Validate / configure** — reload log filter from `-v` only (ambient `RUST_LOG` ignored), terminal, i18n.
151/// 5. **Execute** — subcommand dispatch (bounded SSH timeouts + cancel flags).
152/// 6. **Finalize / exit** — handled by `main` (flush + runtime shutdown + sysexits).
153///
154/// # Workload classification (resource economy + performance)
155///
156/// **I/O-bound** one-shot CLI (SSH/TCP, optional disk SCP). Not CPU-bound:
157/// **no Rayon** on product paths (crypto/IO already on Tokio). Multi-host
158/// fan-out is the default modus operandi for SSH ops that accept `--all`
159/// (`health-check`, `exec`, `sudo-exec`, `su-exec`, `scp`) via
160/// [`concurrency::map_bounded`] (`Semaphore` + `JoinSet`, cap from
161/// `--max-concurrency` / auto CPU×RAM formula; no env-as-store).
162/// Tunnel forwards use the same admission gate. Local TOML CRUD / locale /
163/// completions stay sequential (work ≪ coordination). Heavy-memory
164/// singletons use `OnceLock` / atomics (signals, locale, logs).
165///
166/// # Ownership / borrowing policy (Rules Rust)
167///
168/// - Prefer the **least** permission: `&T` → `&mut T` → `T` (consume) only when needed.
169/// - Config override paths: APIs take `Option<&Path>` (`resolve_config_path`,
170/// `find_by_name`, `winning_layer`, `read_active_vps`) so one CLI `PathBuf` is shared
171/// without `clone` at every hop.
172/// - Local `ConfigFile` maps: `remove` the `VpsRecord` for one-shot exec/sudo/su/health
173/// when the file is discarded after load (move, not clone).
174/// - Errors that own stderr: **move** `output.stderr` into `SshCliError::CommandFailed`.
175/// - Secrets: `Option::take` / `SecretString` move; never clone passwords for convenience.
176/// - Shared SSH client in tunnel: `Arc<dyn SshClientTrait>` + `Arc::clone` (refcount only).
177/// - No `Rc` / `RefCell` / `Arc<RefCell<_>>` / `static mut` in product code.
178/// - Lifetimes: elision preferred; no `'static` escapes for non-global data.
179/// - `unsafe` only at OS boundaries (console, signals env tests) with `// SAFETY:`.
180///
181/// # Interior mutability policy (Rules Rust)
182///
183/// - Prefer **no** interior mutability: reorganize ownership first.
184/// - Process flags: `static AtomicBool` with documented `Ordering` (signals, quiet/json).
185/// - One-shot init: `OnceLock` (locale, color, log reload handle) — not `lazy_static`.
186/// - Composite process state: single `std::sync::Mutex` + poison recovery with log
187/// (`secrets`); never hold across `.await`.
188/// - No `RefCell` / `Rc` / `Arc<RefCell<_>>` / `static mut` in product code.
189/// - Tunnel deadline flag: `Arc<AtomicBool>` only where two tasks must share a bit.
190///
191/// # Graceful shutdown policy (Rules Rust — one-shot minimum)
192///
193/// - Detect SIGINT/SIGTERM early; long ops poll [`signals::should_stop`].
194/// - Tunnel stops accepts, drains/aborts tracked forwards, then disconnects.
195/// - Flush stdio; shut down Tokio runtime; exit **130** / **141** / **143**.
196/// - Not a daemon: no readiness probes, SIGHUP reload, or `TaskTracker` tree.
197///
198/// # JSON wire policy (Rules Rust — JSON / NDJSON)
199///
200/// - Agent contracts are **classic single-root JSON** (object or array), RFC 8259 —
201/// **not** NDJSON/JSONL streams. One document per invocation on the data path.
202/// - Emit **compact** UTF-8 (`serde_json::to_string`) + trailing LF; no pretty-print,
203/// no BOM, no JSON5 on the machine wire.
204/// - Known payloads use typed DTOs in [`json_wire`]; `serde_json::Value` only at
205/// dynamic edges (`meta command-tree`, flexible success-field maps).
206/// - Import of `vps export --json` strips BOM, caps size, Must-Ignore unknown fields.
207/// - Hand-versioned schemas live under `docs/schemas/`; no runtime schema engine.
208/// - On-disk host registry remains **TOML** (not JSON config).
209///
210/// # Performance policy (Rules Rust)
211///
212/// - Measure before micro-optimizing; prefer algorithmic / allocation caps
213/// (see `ssh::client` capture byte cap) over `#[inline(always)]` guesses.
214/// - Publish default is **size-min** release (`opt-level = "z"` + fat LTO);
215/// local speed A/B uses `--profile release-fast` / `release-lto` (`opt-level = 3`).
216/// - Criterion covers local mask/paths only — not SSH flamegraphs.
217///
218/// # Multiplatform policy (Rules Rust — sistemas operacionais)
219///
220/// - **Boot:** Windows console UTF-8 (65001) + `ENABLE_VIRTUAL_TERMINAL_PROCESSING`;
221/// Linux sandbox warn (Flatpak/Snap); runtime classify WSL/container/CI/Termux.
222/// - **Paths:** `PathBuf` only; Windows reserved names; component ≤255; MAX_PATH
223/// guard without `\\?\`; Unicode NFC normalization for comparisons.
224/// - **Permissions:** Unix `0o600` behind `#[cfg(unix)]` only (no ACL leakage).
225/// - **Config home:** `directories::ProjectDirs` + optional `--config-dir` (no `SSH_CLI_HOME` store).
226/// - **Completions:** clap_complete shells (Bash, Elvish, Fish, PowerShell, Zsh).
227/// - **Out of scope:** browser discovery, WASM/WASI, Job Objects, seccomp default,
228/// macOS notarization inside the binary (release process — see CROSS_PLATFORM).
229///
230/// # i18n policy (Rules Rust — multi-idioma / locale do SO)
231///
232/// - **Boot order:** platform console + runtime detect → TTY/color → locale → rest.
233/// - **Detection:** single `sys_locale::get_locale` call; never portable raw `LANG`.
234/// - **Parse / negotiate:** `unic-langid` + `fluent-langneg` against [`i18n::Language::AVAILABLE`].
235/// - **State:** one immutable [`std::sync::OnceLock`] language per process (no mid-session mix).
236/// - **Overrides:** `--lang` > XDG `lang` file (`locale set`) > system > `en` (`SSH_CLI_LANG` not a store).
237/// - **UI copy:** human success/status/cancel via [`i18n::Message`]; agent JSON +
238/// [`errors::SshCliError`] Display stay **stable English** (pipe/agent contract).
239/// - **MVP:** `en` + `pt-BR` only; optional locales behind `i18n-*` features (stubs).
240/// - **Out of scope for default binary:** full Fluent FTL runtime, ICU calendars/collators,
241/// pseudolocalization, RTL shaping (reserved features; size-sensitive one-shot).
242///
243/// # Parallelism / multiprocessing policy (Rules Rust — paralelismo)
244///
245/// - **Modus operandi:** bounded concurrent I/O on every multi-target SSH surface
246/// (`--all` **or** `--hosts a,b`); sequential only when work is local/tiny
247/// (documented at each call site — G-PAR-28).
248/// - **Session reuse (G-PAR-47):** multi-file SCP on one host uses **one** SSH
249/// session and serial transfers (auth once). Multi-host × multi-file (G-PAR-48)
250/// bounds **sessions** via `map_bounded`, reusing the session for all files.
251/// - **TOFU (G-PAR-49):** `known_hosts` mutations take exclusive flock + reload-merge.
252/// - **Selection:** [`vps::HostSelection`] + [`vps::resolve_host_jobs`] is the
253/// single path that builds fan-out jobs (G-PAR-31). Batch JSON when selection
254/// is `All`/`Named` even if one name (G-PAR-36).
255/// - **Gate:** `tokio::sync::Semaphore` in [`concurrency`]; `acquire_owned` + RAII
256/// permit drop; `JoinSet` for dynamic fan-out; never unbounded `spawn` loops.
257/// - **Budget:** `min(cpus×4, free_ram×50%/16MiB)` clamped `1..=64`; override
258/// `--max-concurrency` (auto formula pre-parse; no env store).
259/// - **Runtime:** multi_thread workers from [`concurrency::worker_threads`];
260/// `max_blocking_threads` capped; no nested runtimes; no Rayon.
261/// - **Tunnel:** one local bind + one SSH session per one-shot (G-PAR-30); multi-host
262/// tunnels = N invocations. Accepts still use JoinSet + Semaphore.
263/// - **N/A for this product:** loom lock models, parking_lot deadlock detector,
264/// systemd-run MemoryMax child scopes, OTEL available_permits metrics, hierarchical
265/// `CancellationToken` trees (one-shot uses atomic signal flags).
266///
267/// # Latency policy (Rules Rust — redução de latência)
268///
269/// - **Identity:** one-shot I/O-bound agent CLI. End-to-end latency is dominated by
270/// **SSH/TCP RTT**, not CPU nanoseconds. HFT budgets (P9999 ns, isolcpus, mlockall,
271/// huge pages, kernel bypass, PGO/BOLT pipelines) are **out of scope**.
272/// - **What we optimize:** cold-start (capped Tokio workers), multi-host wall-clock
273/// via bounded fan-out, zero extra copies on exec capture happy path, non-blocking
274/// disk I/O on the async runtime (SCP), bounded capture RAM, cooperative cancel.
275/// - **What we do not claim:** process-level P50/P99 histograms per release, HDR
276/// export, or coordinated-omission load tests — there is no long-lived server.
277/// - **Build:** fat LTO + `codegen-units = 1` + `panic = abort` on release;
278/// `opt-level = "z"` for publish footprint; `release-fast`/`release-lto` for
279/// local CPU A/B. No `target-cpu=native` on published artifacts.
280/// - **Allocator:** system default on glibc; optional `mimalloc` via `musl-allocator`
281/// (measure before making default).
282///
283/// # Logging / tracing policy (Rules Rust — logs com tracing e rotação)
284///
285/// - **Facade:** `tracing` only. Product code never uses `println!`/`dbg!` for
286/// diagnostics; agent data is emitted only via [`output`].
287/// - **Install once:** [`telemetry::bootstrap_logs`] before clap parse, then
288/// [`telemetry::initialize_logs`] reloads `EnvFilter` (`reload::Layer`).
289/// - **Sink:** stderr text with targets + thread names; default filter `error`.
290/// - **Bridge:** `tracing-log` so `russh`/`keyring` `log` records appear under
291/// the same filter.
292/// - **Not installed:** OpenTelemetry, file rotation (`tracing-appender`),
293/// admin log-level HTTP, `tokio-console` — out of product identity
294/// (one-shot agent CLI, zero telemetry, stdout = data).
295///
296/// # Macro policy (Rules Rust — macros)
297///
298/// - **No product `macro_rules!` / proc-macro crates in this workspace.** Prefer
299/// generics, traits, functions, and `const` before inventing syntax. A thin
300/// rename macro over a function is an antipattern.
301/// - **External derives only when justified:** `clap` / `serde` / `thiserror`
302/// (`proc_macro_derive`) generate type-driven boilerplate that functions cannot
303/// express; no hand-rolled derive crate.
304/// - **Built-in std macros, idiomatically:**
305/// - `format!` when an owned `String` is required (i18n, error payloads).
306/// - `format_args!` + [`output::write_line_fmt`] / [`output::write_stderr_fmt`]
307/// / `writeln!` for stream emission — **never** `write_*(&format!(…))`.
308/// - `matches!` for boolean pattern checks; `env!`/`concat!` for version wire
309/// (`cli` long version); `include_str!` only in tests that audit source.
310/// - **Forbidden in product paths:** `todo!`, `unimplemented!`, `dbg!`, and
311/// `panic!` for recoverable errors (tests may `panic!` on fixture mismatch).
312/// - **Not applicable:** custom declarative/proc macro hygiene, `trybuild` UI
313/// suites, `$crate` export crates — there is no macro surface to publish.
314///
315/// # Stream architecture (G-IO-11)
316///
317/// - **Binary path:** [`run`] parses `std::env::args` and uses process stdio.
318/// - **Library path:** [`run_with_args`] accepts a pre-parsed [`cli::CliArgs`].
319/// - **DI write primitives:** [`output::write_line_to`], [`output::write_stderr_line_to`],
320/// [`json_wire::write_json_line`] — pass `Cursor`/`Vec` in tests.
321/// - **Exit mapping:** [`resolve_exit_code`] keeps `main` thin (flush + runtime
322/// shutdown + `process::exit` only).
323pub async fn run() -> Result<()> {
324 // Phase 1: signals BEFORE any work (rules: first). Binary `main` already
325 // registers before Tokio multi_thread (G-UNSAFE-13); this call is idempotent.
326 signals::register_handler()?;
327 // Phase 1b: tracing BEFORE parse (rules: second); verbosity reloaded after argv.
328 telemetry::bootstrap_logs();
329
330 platform::initialize_platform()?;
331
332 // Phase 3: parse real process argv
333 let args = cli::parse_args();
334
335 // Phases 4–5
336 run_with_args(args).await
337}
338
339/// Executes phases 4–5 with **pre-parsed** arguments (G-IO-11 library entry).
340///
341/// Callers that already own a [`cli::CliArgs`] (tests, embedders, alternate
342/// front-ends) skip clap parse. Process stdout/stderr remain the default sinks
343/// via [`output`]; injectable writers live on `write_*_to` / `write_json_line`.
344///
345/// Does **not** re-register signals or re-bootstrap tracing — call
346/// [`signals::register_handler`] + [`telemetry::bootstrap_logs`] first when
347/// embedding outside [`run`].
348///
349/// # Errors
350/// Propagates domain / I/O errors from command dispatch.
351pub async fn run_with_args(args: cli::CliArgs) -> Result<()> {
352 // Phase 4: configure from args (logs → terminal/TTY → locale before any UI)
353 telemetry::initialize_logs(args.verbose);
354 terminal::initialize(args.no_color)?;
355 i18n::initialize_language(args.lang.as_deref(), args.config_dir.as_deref())?;
356 // Shaping is installed before any command runs so every payload leaving the
357 // JSON funnel is already reduced. A malformed `--filter` fails here, not silently
358 // as an empty result set.
359 install_agent_shape(&args)?;
360 cli::set_no_input(args.no_input);
361 cli::set_dry_run(args.dry_run);
362 // Phase 5: execute
363 commands::run(args).await
364}
365
366/// Builds the shaping config from global flags and installs it process-wide.
367fn install_agent_shape(args: &cli::CliArgs) -> Result<()> {
368 let mut filters = Vec::with_capacity(args.filter.len());
369 for raw in &args.filter {
370 filters
371 .push(agent_shape::Filter::parse(raw).map_err(errors::SshCliError::InvalidArgument)?);
372 }
373 agent_shape::set_shape(agent_shape::ShapeConfig {
374 select: args.select.clone(),
375 filters,
376 limit: args.limit,
377 sort: args.sort.clone(),
378 dedupe_by: args.dedupe_by.clone(),
379 count_only: args.count_only,
380 truncate_content: args.truncate_content,
381 max_output_bytes: args.max_output_bytes,
382 });
383 Ok(())
384}
385
386/// Prints a product error envelope and returns its exit code.
387fn emit_resolved_ssh_error(ssh_err: &errors::SshCliError, wants_json: bool) -> i32 {
388 let code = ssh_err.exit_code();
389 let remote = match ssh_err {
390 errors::SshCliError::CommandFailed { exit_code, .. } => Some(*exit_code),
391 _ => None,
392 };
393 if wants_json {
394 // Envelope DTO owns the message String (required by serde).
395 // G-RETRY / G-ERR-08: error_code + error_class + retryable.
396 let _ = output::print_error_envelope(
397 code,
398 ssh_err.error_code(),
399 &ssh_err.to_string(),
400 remote,
401 ssh_err.classify(),
402 ssh_err.is_retryable(),
403 ssh_err.suggestion(),
404 );
405 } else if let Some(localized) = i18n::localized_error_text(ssh_err) {
406 // B2: human branch only. The JSON envelope above deliberately keeps the
407 // English Display, because agents parse `error_code`, never prose.
408 let _ = output::print_error(&localized);
409 } else {
410 // G-MAC-01: Display via write_fmt — no temporary String.
411 // Reached for error codes without a translation (io, json, toml_*, …).
412 let _ = output::print_error_fmt(format_args!("{ssh_err}"));
413 }
414 let _ = std::io::Write::flush(&mut std::io::stderr());
415 code
416}
417
418/// Maps a [`run`] / [`run_with_args`] result to a sysexits-aligned exit code.
419///
420/// Side effect: on domain errors (and not signal/pipe), prints the human or
421/// JSON error envelope to stderr via [`output`] (same contract as the binary).
422///
423/// Prefer this from `main` so exit policy stays in the library (G-IO-11).
424///
425/// Recovers [`errors::SshCliError`] and bare [`crate::domain::DomainError`]
426/// that bubbled through `anyhow` without the product wrapper (R-04 / R-14).
427///
428/// # Examples
429///
430/// ```
431/// use ssh_cli::{errors::exit_codes, resolve_exit_code};
432///
433/// assert_eq!(resolve_exit_code(Ok(())), exit_codes::EX_OK);
434/// ```
435#[must_use]
436pub fn resolve_exit_code(result: Result<()>) -> i32 {
437 match result {
438 Ok(()) => signals::signal_exit_code().unwrap_or(errors::exit_codes::EX_OK),
439 Err(e) => {
440 if let Some(sig) = signals::signal_exit_code() {
441 return sig;
442 }
443 if errors::anyhow_is_broken_pipe(&e) {
444 return errors::exit_codes::EX_PIPE;
445 }
446 let wants_json = output::wants_json_errors();
447 if let Some(ssh_err) = e.downcast_ref::<errors::SshCliError>() {
448 return emit_resolved_ssh_error(ssh_err, wants_json);
449 }
450 if let Some(domain) = e.downcast_ref::<crate::domain::DomainError>() {
451 let ssh_err = errors::SshCliError::from(domain.clone());
452 return emit_resolved_ssh_error(&ssh_err, wants_json);
453 }
454 // Walk the chain for DomainError / SshCliError nested under context.
455 for cause in e.chain().skip(1) {
456 if let Some(ssh_err) = cause.downcast_ref::<errors::SshCliError>() {
457 return emit_resolved_ssh_error(ssh_err, wants_json);
458 }
459 if let Some(domain) = cause.downcast_ref::<crate::domain::DomainError>() {
460 let ssh_err = errors::SshCliError::from(domain.clone());
461 return emit_resolved_ssh_error(&ssh_err, wants_json);
462 }
463 }
464 // D15: `src/output/emit.rs` returns `io::Result`, and 28 product call
465 // sites propagate it with a bare `?` into `anyhow::Result`. The result
466 // was that one identical failure reported two different contracts:
467 // `schema` mapped it and exited 74 with `error_code: "io"`, while
468 // `commands` and `vps export` leaked it here and exited 1 with
469 // `error_code: "unexpected"`. Measured with `> /dev/full` (ENOSPC).
470 //
471 // Classifying at this seam fixes every call site at once and keeps
472 // `emit.rs` on `io::Result`, which is the honest type for a writer.
473 // `io::Error` is not `Clone`, so the kind and message are rebuilt.
474 for cause in e.chain() {
475 if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
476 let ssh_err = errors::SshCliError::Io(std::io::Error::new(
477 io_err.kind(),
478 io_err.to_string(),
479 ));
480 return emit_resolved_ssh_error(&ssh_err, wants_json);
481 }
482 }
483 let code = errors::exit_codes::EX_GENERAL;
484 if wants_json {
485 let _ = output::print_error_envelope(
486 code,
487 "unexpected",
488 &e.to_string(),
489 None,
490 errors::ErrorClass::Permanent,
491 false,
492 Some("unexpected non-domain error; do not blind-retry"),
493 );
494 } else {
495 // C2: the untyped branch is localized too. B2 wired every typed
496 // `SshCliError` through i18n and left this one printing the raw
497 // English `anyhow` chain under `--lang pt-BR`. Only the label is
498 // translated; the chain itself stays verbatim so no diagnostic
499 // detail is lost.
500 let _ = output::print_error(&i18n::localized_unexpected_text(&e.to_string()));
501 }
502 let _ = std::io::Write::flush(&mut std::io::stderr());
503 code
504 }
505 }
506}
507
508#[cfg(test)]
509mod resolve_exit_tests {
510 use super::*;
511 use crate::errors::{exit_codes, SshCliError};
512
513 #[test]
514 fn resolve_ok_is_ex_ok_without_signal() {
515 // If a prior test left signal flags set, signal_exit_code wins — only
516 // assert the pure Ok path when no signal is active.
517 if signals::signal_exit_code().is_none() {
518 assert_eq!(resolve_exit_code(Ok(())), exit_codes::EX_OK);
519 }
520 }
521
522 #[test]
523 fn resolve_broken_pipe_is_141() {
524 let err = SshCliError::Io(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe"));
525 // Prefer signal exit if a concurrent test set flags; otherwise EPIPE.
526 let code = resolve_exit_code(Err(err.into()));
527 assert!(
528 code == exit_codes::EX_PIPE
529 || code == exit_codes::EX_SIGINT
530 || code == exit_codes::EX_SIGTERM,
531 "unexpected exit code {code}"
532 );
533 }
534
535 #[test]
536 fn resolve_auth_failed_is_77_without_signal() {
537 if signals::signal_exit_code().is_some() {
538 return;
539 }
540 let code = resolve_exit_code(Err(SshCliError::AuthenticationFailed.into()));
541 assert_eq!(code, exit_codes::EX_NOPERM);
542 }
543
544 #[test]
545 fn resolve_bare_domain_error_is_usage_not_unexpected() {
546 if signals::signal_exit_code().is_some() {
547 return;
548 }
549 let d = crate::domain::DomainError::new(
550 "vps_auth",
551 "primary auth methods are mutually exclusive",
552 );
553 let code = resolve_exit_code(Err(anyhow::Error::new(d)));
554 assert_eq!(code, exit_codes::EX_USAGE);
555 }
556}