Skip to main content

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}