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