Skip to main content

ssh_cli/
errors.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Structured error types for ssh-cli.
5//!
6//! Defines [`crate::errors::SshCliError`] with domain error categories used by the CLI.
7//! Display strings are technical English (lowercase, no trailing period — G-ERR-01).
8//! Localized UI copy belongs in `i18n`.
9//!
10//! # Errors contract (G-ERR)
11//!
12//! - Prefer typed variants over [`crate::errors::SshCliError::Generic`].
13//! - Preserve [`std::error::Error::source`] via helpers [`crate::errors::SshCliError::tls_src`] /
14//!   [`crate::errors::SshCliError::channel_src`] instead of embedding `{e}` in a flat string.
15//! - Agents must read [`crate::errors::SshCliError::error_code`] + [`crate::errors::ErrorClass`], not parse Display.
16
17use thiserror::Error;
18
19/// Shared source box for layered errors (Send + Sync for fan-out tasks).
20pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
21
22/// Domain errors produced by ssh-cli operations.
23///
24/// `#[non_exhaustive]` (G-SEC-11): external crates must use wildcard arms so
25/// new variants in minor releases do not break SemVer consumers.
26#[derive(Debug, Error)]
27#[non_exhaustive]
28pub enum SshCliError {
29    /// Underlying I/O error.
30    #[error("i/o error: {0}")]
31    Io(#[from] std::io::Error),
32
33    /// JSON serialization or deserialization error.
34    #[error("json error: {0}")]
35    Json(#[from] serde_json::Error),
36
37    /// TOML deserialization error.
38    #[error("toml read error: {0}")]
39    TomlDe(#[from] toml::de::Error),
40
41    /// TOML serialization error.
42    #[error("toml write error: {0}")]
43    TomlSer(#[from] toml::ser::Error),
44
45    /// Domain newtype / parse construction failure (G-ERR-02).
46    #[error("{0}")]
47    Domain(#[from] crate::domain::DomainError),
48
49    /// SSH connection-layer error.
50    #[error("ssh connection error: {0}")]
51    SshConnection(String),
52
53    /// SSH authentication error with detail.
54    #[error("ssh authentication error: {0}")]
55    SshAuthentication(String),
56
57    /// TCP/SSH connection failed before authentication.
58    #[error("ssh connection failed: {0}")]
59    ConnectionFailed(String),
60
61    /// SSH authentication rejected by the server.
62    #[error(
63        "ssh authentication failed; try --password-stdin, --key PATH, --key-passphrase-stdin, or verify the user"
64    )]
65    AuthenticationFailed,
66
67    /// Host key diverged from known_hosts (possible MITM).
68    #[error(
69        "host key changed for {host}:{port}: expected {expected}, got {obtained} (use --replace-host-key if legitimate)"
70    )]
71    HostKeyChanged {
72        /// Host name.
73        host: String,
74        /// Port.
75        port: u16,
76        /// Expected fingerprint.
77        expected: String,
78        /// Observed fingerprint.
79        obtained: String,
80    },
81
82    /// Command exceeds `max_command_chars`.
83    #[error("command exceeds max_command_chars ({max}): {len} characters")]
84    CommandTooLong {
85        /// Configured limit.
86        max: usize,
87        /// Command length.
88        len: usize,
89    },
90
91    /// Elevation disabled (`disable_sudo`).
92    #[error("sudo/su disabled for this host (disable_sudo)")]
93    SudoDisabled,
94
95    /// Missing `su` password for `su-exec`.
96    #[error("su_password not configured; use vps edit --su-password or --su-password-stdin")]
97    SuPasswordMissing,
98
99    /// Failed to open or operate an SSH channel (G-ERR-05: optional source chain).
100    #[error("ssh channel failed: {message}")]
101    ChannelFailed {
102        /// Short context (no embedded cause Display).
103        message: String,
104        /// Root cause when available.
105        #[source]
106        source: Option<ErrorSource>,
107    },
108
109    /// SSH operation timed out.
110    #[error("ssh timeout after {0}ms")]
111    SshTimeout(u64),
112
113    /// Remote command exited non-zero.
114    #[error("command failed with exit code {exit_code}: {stderr}")]
115    CommandFailed {
116        /// Remote process exit code.
117        exit_code: i32,
118        /// Truncated stderr snippet.
119        stderr: String,
120    },
121
122    /// VPS name not found in the registry.
123    #[error("vps '{0}' not found in registry")]
124    VpsNotFound(String),
125
126    /// No active VPS (`active` sibling file missing) — GAP-SSH-EXIT-002.
127    #[error("no active vps; run 'ssh-cli connect <NAME>' first")]
128    NoActiveVps,
129
130    /// Duplicate VPS name in the registry.
131    #[error("vps '{0}' already exists in registry")]
132    VpsDuplicate(String),
133
134    /// Local or remote file not found.
135    #[error("file not found: {0}")]
136    FileNotFound(String),
137
138    /// Invalid CLI argument.
139    #[error("invalid argument: {0}")]
140    InvalidArgument(String),
141
142    /// TLS layer error (handshake, config, PEM, ACME, mTLS) — G-ERR-04.
143    #[error("tls: {message}")]
144    Tls {
145        /// Short context (no embedded cause Display).
146        message: String,
147        /// Root cause when available.
148        #[source]
149        source: Option<ErrorSource>,
150    },
151
152    /// Cryptographic / secrets key material failure (no secret bytes in Display).
153    #[error("crypto operation failed: {op}")]
154    Crypto {
155        /// Stable operation id (`encrypt`, `decrypt`, `keyring_get`, …).
156        op: &'static str,
157    },
158
159    /// Configuration / registry serialization failure (non-TOML crate errors).
160    #[error("configuration error: {0}")]
161    Config(String),
162
163    /// A host service the CLI depends on is not answering (exit 69).
164    ///
165    /// G-ERR-R01: `Config` was the default landing spot for every `map_err` without an
166    /// obvious variant, so a locked OS keyring exited 65 — the same code as corrupt
167    /// TOML — and inherited `retryable: false`. An agent therefore gave up permanently
168    /// on the one failure in the list that a plain retry actually fixes.
169    #[error("service unavailable: {service}")]
170    Unavailable {
171        /// Stable service id (`keyring`, …).
172        service: &'static str,
173    },
174
175    /// Internal failure with no user-fixable input (exit 70).
176    ///
177    /// G-ERR-R01: reserved for conditions the caller cannot influence at all, such as
178    /// a CSPRNG that refuses to produce bytes. Kept apart from [`Self::Unavailable`]
179    /// because waiting and retrying is useless here, while there it is the remedy.
180    #[error("internal failure: {op}")]
181    Software {
182        /// Stable operation id (`rng`, …).
183        op: &'static str,
184    },
185
186    /// Generic timeout.
187    #[error("timeout exceeded after {0}ms")]
188    Timeout(u64),
189
190    /// XDG config directory unavailable.
191    #[error("configuration directory unavailable")]
192    XdgDirectory,
193
194    /// Incompatible schema version.
195    #[error("incompatible schema version: expected {expected}, found {found}")]
196    SchemaIncompatible {
197        /// Expected schema version.
198        expected: u32,
199        /// Found schema version.
200        found: u32,
201    },
202
203    /// Multi-host fan-out where some targets failed and others succeeded.
204    ///
205    /// Partial success is the *normal* outcome of a fan-out, not malformed data. It
206    /// previously reused [`Self::Config`], which meant an agent could not tell
207    /// "1 of 10 hosts failed" apart from "the TOML is corrupt" — both exited 65.
208    /// This variant exits [`exit_codes::EX_GENERAL`] and carries the counts so the
209    /// agent can branch on scale of failure without parsing prose.
210    #[error("{failed}/{total} hosts failed during {op}")]
211    PartialFailure {
212        /// Number of targets that failed.
213        failed: usize,
214        /// Total number of targets attempted.
215        total: usize,
216        /// Stable operation id (`exec`, `health-check`, `scp upload`, …).
217        op: &'static str,
218    },
219
220    /// Uncategorized error — last resort (prefer typed variants).
221    #[error("error: {0}")]
222    Generic(String),
223}
224
225/// Process exit codes aligned with sysexits.h and Unix signal conventions.
226///
227/// # Examples
228///
229/// ```
230/// use ssh_cli::errors::exit_codes;
231///
232/// assert_eq!(exit_codes::EX_OK, 0);
233/// assert_eq!(exit_codes::EX_PIPE, 141);
234/// assert_eq!(exit_codes::EX_SIGINT, 130);
235/// assert_eq!(exit_codes::EX_SIGTERM, 143);
236/// assert_eq!(exit_codes::EX_NOPERM, 77);
237/// ```
238pub mod exit_codes {
239    /// Success.
240    pub const EX_OK: i32 = 0;
241    /// Generic domain failure.
242    pub const EX_GENERAL: i32 = 1;
243    /// Incorrect CLI usage.
244    pub const EX_USAGE: i32 = 64;
245    /// Invalid input data.
246    pub const EX_DATAERR: i32 = 65;
247    /// Input not found.
248    pub const EX_NOINPUT: i32 = 66;
249    /// A required host service is unavailable (OS keyring, secret service).
250    ///
251    /// G-ERR-R01: these failures used to exit [`EX_DATAERR`], which told an agent the
252    /// *input* was malformed and that retrying was pointless. A locked keyring is the
253    /// opposite: the argv is fine and the very same invocation succeeds once the
254    /// service is up, so it is classified transient.
255    pub const EX_UNAVAILABLE: i32 = 69;
256    /// Internal software failure with no user-fixable input (CSPRNG unavailable).
257    ///
258    /// G-ERR-R01: distinct from [`EX_UNAVAILABLE`] because waiting does not help —
259    /// nothing the caller can change makes the next attempt succeed.
260    pub const EX_SOFTWARE: i32 = 70;
261    /// Cannot create output.
262    pub const EX_CANTCREAT: i32 = 73;
263    /// I/O error.
264    pub const EX_IOERR: i32 = 74;
265    /// Permission denied.
266    pub const EX_NOPERM: i32 = 77;
267    /// Terminated by SIGINT (Ctrl+C).
268    pub const EX_SIGINT: i32 = 130;
269    /// Broken pipe on stdout/stderr (128 + SIGPIPE=13) — agent/pipe consumers closed early.
270    pub const EX_PIPE: i32 = 141;
271    /// Terminated by SIGTERM.
272    pub const EX_SIGTERM: i32 = 143;
273
274    // Compile-time invariants for sysexits-aligned codes.
275    const _: () = assert!(EX_OK == 0);
276    const _: () = assert!(EX_USAGE == 64);
277    const _: () = assert!(EX_UNAVAILABLE == 69);
278    const _: () = assert!(EX_SOFTWARE == 70);
279    const _: () = assert!(EX_PIPE == 141);
280    const _: () = assert!(EX_SIGINT == 130);
281    const _: () = assert!(EX_SIGTERM == 143);
282}
283
284/// Returns true when `err` is a broken-pipe condition (EPIPE / SIGPIPE path).
285///
286/// # Examples
287///
288/// ```
289/// use ssh_cli::errors::is_broken_pipe;
290/// use std::io::{Error, ErrorKind};
291///
292/// assert!(is_broken_pipe(&Error::new(ErrorKind::BrokenPipe, "pipe")));
293/// assert!(!is_broken_pipe(&Error::new(ErrorKind::Other, "x")));
294/// ```
295#[must_use]
296pub fn is_broken_pipe(err: &std::io::Error) -> bool {
297    err.kind() == std::io::ErrorKind::BrokenPipe
298}
299
300/// Walks `anyhow` / nested sources looking for a broken-pipe I/O error.
301#[must_use]
302pub fn anyhow_is_broken_pipe(err: &anyhow::Error) -> bool {
303    if let Some(ioe) = err.downcast_ref::<std::io::Error>() {
304        if is_broken_pipe(ioe) {
305            return true;
306        }
307    }
308    if let Some(SshCliError::Io(ioe)) = err.downcast_ref::<SshCliError>() {
309        return is_broken_pipe(ioe);
310    }
311    // Nested chain (e.g. anyhow context wrappers).
312    for cause in err.chain() {
313        if let Some(ioe) = cause.downcast_ref::<std::io::Error>() {
314            if is_broken_pipe(ioe) {
315                return true;
316            }
317        }
318        if let Some(SshCliError::Io(ioe)) = cause.downcast_ref::<SshCliError>() {
319            if is_broken_pipe(ioe) {
320                return true;
321            }
322        }
323    }
324    false
325}
326
327/// High-level error class for agent retry policy (Rules Rust — retry/backoff).
328///
329/// Serialized as snake_case in the JSON error envelope (`error_class`).
330#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum ErrorClass {
333    /// Network / SSH transport may succeed on a fresh process re-invocation.
334    Transient,
335    /// Fix inputs, credentials, or remote state — do not blind-retry.
336    Permanent,
337    /// Signal or broken pipe — do not retry.
338    Cancelled,
339    /// Fan-out where some targets succeeded and others failed; inspect per-host detail.
340    Partial,
341}
342
343/// Stack layer where the failure was observed (diagnostic only).
344#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ErrorLayer {
347    /// TCP connect / dial / DNS resolution failures.
348    Network,
349    /// SSH protocol / channel after TCP is up.
350    Ssh,
351    /// Authentication or host-key policy.
352    Auth,
353    /// Local CLI args, registry, schema, paths.
354    Application,
355    /// Local filesystem / std I/O (non-network).
356    Io,
357}
358
359/// Detailed retry kind returned by [`SshCliError::retry_kind`].
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum RetryKind {
362    /// Never retry with the same argv.
363    NotRetryable,
364    /// Transient network/connect failure (exit 74).
365    TransientNetwork,
366    /// Operation deadline exceeded (exit 74).
367    TransientTimeout,
368    /// SSH session/channel flaked after connect (exit 74).
369    TransientSsh,
370    /// Auth / host-key — change credentials first.
371    PermanentAuth,
372    /// Validation / not-found / schema — fix inputs.
373    PermanentClient,
374    /// Remote command non-zero — not a transport retry.
375    PermanentRemoteCommand,
376    /// SIGINT/SIGTERM/EPIPE.
377    Cancelled,
378}
379
380impl SshCliError {
381    /// TLS error with message only (no root cause).
382    ///
383    /// Accepts `&str` / `String` via [`AsRef<str>`] to avoid `Into` ambiguity
384    /// with foreign `From<&str>` impls in the dependency graph.
385    #[must_use]
386    pub fn tls_msg(message: impl AsRef<str>) -> Self {
387        Self::Tls {
388            message: message.as_ref().to_owned(),
389            source: None,
390        }
391    }
392
393    /// TLS error preserving [`std::error::Error::source`] (G-ERR-04 / G-ERR-16 DRY).
394    #[must_use]
395    pub fn tls_src(
396        message: impl AsRef<str>,
397        source: impl std::error::Error + Send + Sync + 'static,
398    ) -> Self {
399        Self::Tls {
400            message: message.as_ref().to_owned(),
401            source: Some(Box::new(source)),
402        }
403    }
404
405    /// Channel error with message only.
406    #[must_use]
407    pub fn channel_msg(message: impl AsRef<str>) -> Self {
408        Self::ChannelFailed {
409            message: message.as_ref().to_owned(),
410            source: None,
411        }
412    }
413
414    /// Channel error preserving source (G-ERR-05 / G-ERR-16 DRY).
415    #[must_use]
416    pub fn channel_src(
417        message: impl AsRef<str>,
418        source: impl std::error::Error + Send + Sync + 'static,
419    ) -> Self {
420        Self::ChannelFailed {
421            message: message.as_ref().to_owned(),
422            source: Some(Box::new(source)),
423        }
424    }
425
426    /// Crypto / secrets failure without embedding secret material.
427    #[must_use]
428    pub fn crypto(op: &'static str) -> Self {
429        Self::Crypto { op }
430    }
431
432    /// Builds an [`Self::Unavailable`] for a host service that is not answering.
433    #[must_use]
434    pub fn unavailable(service: &'static str) -> Self {
435        Self::Unavailable { service }
436    }
437
438    /// Builds a [`Self::Software`] for an internal failure with no user-fixable input.
439    #[must_use]
440    pub fn software(op: &'static str) -> Self {
441        Self::Software { op }
442    }
443
444    /// Stable machine-oriented code for JSON envelopes (G-ERR-08).
445    #[must_use]
446    pub fn error_code(&self) -> &'static str {
447        match self {
448            Self::Io(e) if is_broken_pipe(e) => "broken_pipe",
449            Self::Io(_) => "io",
450            Self::Json(_) => "json",
451            Self::TomlDe(_) => "toml_read",
452            Self::TomlSer(_) => "toml_write",
453            Self::Domain(_) => "domain_validation",
454            Self::SshConnection(_) => "ssh_connection",
455            Self::SshAuthentication(_) => "ssh_authentication",
456            Self::ConnectionFailed(_) => "connection_failed",
457            Self::AuthenticationFailed => "authentication_failed",
458            Self::HostKeyChanged { .. } => "host_key_changed",
459            Self::CommandTooLong { .. } => "command_too_long",
460            Self::SudoDisabled => "sudo_disabled",
461            Self::SuPasswordMissing => "su_password_missing",
462            Self::ChannelFailed { .. } => "channel_failed",
463            Self::SshTimeout(_) => "ssh_timeout",
464            Self::CommandFailed { .. } => "command_failed",
465            Self::VpsNotFound(_) => "vps_not_found",
466            Self::NoActiveVps => "no_active_vps",
467            Self::VpsDuplicate(_) => "vps_duplicate",
468            Self::FileNotFound(_) => "file_not_found",
469            Self::InvalidArgument(_) => "invalid_argument",
470            Self::Tls { .. } => "tls",
471            Self::Crypto { .. } => "crypto",
472            Self::Config(_) => "config",
473            Self::Unavailable { .. } => "unavailable",
474            Self::Software { .. } => "software",
475            Self::Timeout(_) => "timeout",
476            Self::XdgDirectory => "xdg_directory",
477            Self::SchemaIncompatible { .. } => "schema_incompatible",
478            Self::PartialFailure { .. } => "partial_failure",
479            Self::Generic(_) => "generic",
480        }
481    }
482
483    /// Returns the sysexits.h exit code for this error.
484    #[must_use]
485    pub fn exit_code(&self) -> i32 {
486        match self {
487            // G-IO-01: EPIPE → 141 (never report as generic I/O 74).
488            Self::Io(e) if is_broken_pipe(e) => exit_codes::EX_PIPE,
489            Self::Io(_) => exit_codes::EX_IOERR,
490            Self::Json(_) => exit_codes::EX_DATAERR,
491            Self::TomlDe(_) => exit_codes::EX_DATAERR,
492            Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
493            Self::Domain(_) => exit_codes::EX_USAGE,
494            Self::SshConnection(_) => exit_codes::EX_IOERR,
495            // GAP-AUD-020: authentication failures use EX_NOPERM (77); connect/IO stay 74.
496            Self::SshAuthentication(_) => exit_codes::EX_NOPERM,
497            Self::ConnectionFailed(_) => exit_codes::EX_IOERR,
498            Self::AuthenticationFailed => exit_codes::EX_NOPERM,
499            Self::HostKeyChanged { .. } => exit_codes::EX_NOPERM,
500            Self::CommandTooLong { .. } => exit_codes::EX_USAGE,
501            Self::SudoDisabled => exit_codes::EX_NOPERM,
502            Self::SuPasswordMissing => exit_codes::EX_USAGE,
503            Self::ChannelFailed { .. } => exit_codes::EX_IOERR,
504            Self::SshTimeout(_) => exit_codes::EX_IOERR,
505            Self::CommandFailed { .. } => exit_codes::EX_GENERAL,
506            Self::VpsNotFound(_) => exit_codes::EX_NOINPUT,
507            Self::NoActiveVps => exit_codes::EX_NOINPUT,
508            Self::VpsDuplicate(_) => exit_codes::EX_USAGE,
509            Self::FileNotFound(_) => exit_codes::EX_NOINPUT,
510            Self::InvalidArgument(_) => exit_codes::EX_USAGE,
511            Self::Tls { .. } => exit_codes::EX_IOERR,
512            Self::Crypto { .. } => exit_codes::EX_NOPERM,
513            Self::Config(_) => exit_codes::EX_DATAERR,
514            Self::Unavailable { .. } => exit_codes::EX_UNAVAILABLE,
515            Self::Software { .. } => exit_codes::EX_SOFTWARE,
516            Self::Timeout(_) => exit_codes::EX_IOERR,
517            Self::XdgDirectory => exit_codes::EX_CANTCREAT,
518            Self::SchemaIncompatible { .. } => exit_codes::EX_DATAERR,
519            // Partial fan-out is not a data error: EX_DATAERR (65) is reserved for
520            // malformed input. Per-host detail travels in the JSON envelope.
521            Self::PartialFailure { .. } => exit_codes::EX_GENERAL,
522            Self::Generic(_) => exit_codes::EX_GENERAL,
523        }
524    }
525
526    /// Typed retry kind (no string matching on `Display`).
527    #[must_use]
528    pub fn retry_kind(&self) -> RetryKind {
529        match self {
530            Self::Io(e) if is_broken_pipe(e) => RetryKind::Cancelled,
531            // Non-pipe I/O maps to exit 74 — agent may re-invoke (same as transport).
532            Self::Io(_) => RetryKind::TransientNetwork,
533            Self::Json(_) | Self::TomlDe(_) | Self::TomlSer(_) | Self::Config(_) => {
534                RetryKind::PermanentClient
535            }
536            Self::Domain(_) => RetryKind::PermanentClient,
537            Self::SshConnection(_) => RetryKind::TransientSsh,
538            Self::SshAuthentication(_)
539            | Self::AuthenticationFailed
540            | Self::HostKeyChanged { .. } => RetryKind::PermanentAuth,
541            Self::ConnectionFailed(_) | Self::Tls { .. } => RetryKind::TransientNetwork,
542            // G-ERR-R01: a keyring that is locked, not yet started, or momentarily
543            // busy answers the *same* argv successfully a moment later. This is the
544            // one case in the old `Config` bucket where retrying is the fix, and
545            // classifying it permanent is what made agents give up on it.
546            Self::Unavailable { .. } => RetryKind::TransientTimeout,
547            // No amount of waiting repairs a CSPRNG, so this stays permanent even
548            // though it is not the caller's fault.
549            Self::Software { .. } => RetryKind::PermanentClient,
550            Self::CommandTooLong { .. }
551            | Self::SudoDisabled
552            | Self::SuPasswordMissing
553            | Self::VpsNotFound(_)
554            | Self::NoActiveVps
555            | Self::VpsDuplicate(_)
556            | Self::FileNotFound(_)
557            | Self::InvalidArgument(_)
558            | Self::Crypto { .. }
559            | Self::XdgDirectory
560            | Self::SchemaIncompatible { .. }
561            | Self::Generic(_) => RetryKind::PermanentClient,
562            // Never blind-retry a batch: the hosts that succeeded would run twice.
563            Self::PartialFailure { .. } => RetryKind::NotRetryable,
564            Self::ChannelFailed { .. } => RetryKind::TransientSsh,
565            Self::SshTimeout(_) | Self::Timeout(_) => RetryKind::TransientTimeout,
566            Self::CommandFailed { .. } => RetryKind::PermanentRemoteCommand,
567        }
568    }
569
570    /// Whether an agent may re-invoke the CLI with the same argv after backoff.
571    #[must_use]
572    pub fn is_retryable(&self) -> bool {
573        matches!(
574            self.retry_kind(),
575            RetryKind::TransientNetwork | RetryKind::TransientTimeout | RetryKind::TransientSsh
576        )
577    }
578
579    /// Explicit complement of [`Self::is_retryable`].
580    #[must_use]
581    pub fn is_permanent(&self) -> bool {
582        !self.is_retryable() && !matches!(self.retry_kind(), RetryKind::Cancelled)
583    }
584
585    /// High-level class for the JSON envelope `error_class` field.
586    #[must_use]
587    pub fn classify(&self) -> ErrorClass {
588        // Partial fan-out is its own class: not transient (retrying the whole batch
589        // re-runs the hosts that already succeeded) and not plain permanent (part of
590        // the work did land). The agent must read per-host detail to decide.
591        if matches!(self, Self::PartialFailure { .. }) {
592            return ErrorClass::Partial;
593        }
594        match self.retry_kind() {
595            RetryKind::TransientNetwork | RetryKind::TransientTimeout | RetryKind::TransientSsh => {
596                ErrorClass::Transient
597            }
598            RetryKind::Cancelled => ErrorClass::Cancelled,
599            RetryKind::NotRetryable
600            | RetryKind::PermanentAuth
601            | RetryKind::PermanentClient
602            | RetryKind::PermanentRemoteCommand => ErrorClass::Permanent,
603        }
604    }
605
606    /// Stack layer for diagnostics.
607    #[must_use]
608    pub fn layer(&self) -> ErrorLayer {
609        match self {
610            Self::Io(e) if is_broken_pipe(e) => ErrorLayer::Io,
611            Self::Io(e) if io_error_is_transient_network(e) => ErrorLayer::Network,
612            Self::Io(_) => ErrorLayer::Io,
613            Self::ConnectionFailed(_) | Self::Timeout(_) | Self::Tls { .. } => ErrorLayer::Network,
614            Self::SshConnection(_) | Self::ChannelFailed { .. } | Self::SshTimeout(_) => {
615                ErrorLayer::Ssh
616            }
617            Self::SshAuthentication(_)
618            | Self::AuthenticationFailed
619            | Self::HostKeyChanged { .. }
620            | Self::SudoDisabled
621            | Self::Crypto { .. }
622            // Keyring failures are credential-material failures, not application ones.
623            | Self::Unavailable { .. } => ErrorLayer::Auth,
624            Self::Json(_)
625            | Self::TomlDe(_)
626            | Self::TomlSer(_)
627            | Self::Domain(_)
628            | Self::CommandTooLong { .. }
629            | Self::SuPasswordMissing
630            | Self::CommandFailed { .. }
631            | Self::VpsNotFound(_)
632            | Self::NoActiveVps
633            | Self::VpsDuplicate(_)
634            | Self::FileNotFound(_)
635            | Self::InvalidArgument(_)
636            | Self::Config(_)
637            | Self::Software { .. }
638            | Self::XdgDirectory
639            | Self::SchemaIncompatible { .. }
640            | Self::PartialFailure { .. }
641            | Self::Generic(_) => ErrorLayer::Application,
642        }
643    }
644
645    /// Optional cool-down hint (SSH has no HTTP Retry-After; always `None`).
646    #[must_use]
647    pub fn retry_after(&self) -> Option<std::time::Duration> {
648        let _ = self;
649        None
650    }
651
652    /// Short agent-facing suggestion for the JSON envelope.
653    #[must_use]
654    pub fn suggestion(&self) -> Option<&'static str> {
655        // G-ERR-R01: these two share a `RetryKind` with unrelated failures, and the
656        // generic hint derived from it would be actively misleading — telling an
657        // operator to raise `--timeout` when the OS keyring is locked sends them to
658        // the wrong knob entirely. Matching the variant first keeps the advice true.
659        match self {
660            Self::Unavailable { .. } => {
661                return Some(
662                    "unlock or start the OS keyring, or fall back to XDG `secrets.key` \
663                     (`--secrets-key-file`); the same argv succeeds once it answers (exit 69)",
664                );
665            }
666            Self::Software { .. } => {
667                return Some(
668                    "internal failure with no user-fixable input; report it — retrying \
669                     unchanged will not help (exit 70)",
670                );
671            }
672            _ => {}
673        }
674        match self.retry_kind() {
675            RetryKind::TransientNetwork | RetryKind::TransientSsh => {
676                Some("retry at most twice with exponential full-jitter backoff (exit 74)")
677            }
678            RetryKind::TransientTimeout => {
679                Some("increase --timeout / --timeout-ms, then retry at most twice with backoff")
680            }
681            RetryKind::PermanentAuth => {
682                Some("change credentials (--key / --password-stdin) or host-key policy; do not blind-retry")
683            }
684            RetryKind::PermanentRemoteCommand => {
685                Some("inspect remote stderr; fix remote command — transport retry will not help")
686            }
687            RetryKind::PermanentClient => {
688                Some("fix CLI arguments, registry state, or schema; do not retry unchanged")
689            }
690            RetryKind::Cancelled => Some("do not retry after signal or broken pipe"),
691            RetryKind::NotRetryable => None,
692        }
693    }
694}
695
696/// Classifies `std::io::Error` kinds that are typically transient on the network path.
697#[must_use]
698pub fn io_error_is_transient_network(err: &std::io::Error) -> bool {
699    use std::io::ErrorKind;
700    matches!(
701        err.kind(),
702        ErrorKind::ConnectionRefused
703            | ErrorKind::ConnectionReset
704            | ErrorKind::ConnectionAborted
705            | ErrorKind::NotConnected
706            | ErrorKind::AddrNotAvailable
707            | ErrorKind::TimedOut
708            | ErrorKind::Interrupted
709            | ErrorKind::WouldBlock
710            | ErrorKind::UnexpectedEof
711            | ErrorKind::NetworkUnreachable
712            | ErrorKind::HostUnreachable
713    )
714}
715
716/// Result alias using [`SshCliError`].
717pub type SshCliResult<T> = std::result::Result<T, SshCliError>;
718
719/// Turns a multi-host fan-out tally into the batch outcome.
720///
721/// Single source for the "N of M failed" verdict. The same eight-line block used to
722/// be copy-pasted across the SCP, SFTP, exec and health-check batch paths, each one
723/// building a [`SshCliError::Config`] (exit 65) by hand — so a partial fan-out was
724/// indistinguishable from corrupt TOML. Callers now report the tally and let this
725/// decide.
726///
727/// `total` must count **every** target the caller was asked to reach, including ones
728/// skipped by `--fail-fast`; otherwise the ratio understates the work not done.
729///
730/// # Errors
731/// [`SshCliError::PartialFailure`] when `failed > 0`.
732///
733/// # Examples
734///
735/// ```
736/// use ssh_cli::errors::{finish_batch, SshCliError};
737///
738/// assert!(finish_batch(0, 10, "exec").is_ok());
739///
740/// let err = finish_batch(3, 10, "exec").unwrap_err();
741/// assert_eq!(err.exit_code(), ssh_cli::errors::exit_codes::EX_GENERAL);
742/// assert_eq!(err.error_code(), "partial_failure");
743/// assert!(matches!(err, SshCliError::PartialFailure { failed: 3, total: 10, .. }));
744/// ```
745pub fn finish_batch(failed: usize, total: usize, op: &'static str) -> SshCliResult<()> {
746    if failed == 0 {
747        return Ok(());
748    }
749    Err(SshCliError::PartialFailure { failed, total, op })
750}
751
752#[cfg(test)]
753#[path = "errors_tests.rs"]
754mod tests;