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}