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 [`SshCliError::Generic`].
13//! - Preserve [`std::error::Error::source`] via helpers [`SshCliError::tls_src`] /
14//!   [`SshCliError::channel_src`] instead of embedding `{e}` in a flat string.
15//! - Agents must read [`SshCliError::error_code`] + [`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    /// Generic timeout.
164    #[error("timeout exceeded after {0}ms")]
165    Timeout(u64),
166
167    /// XDG config directory unavailable.
168    #[error("configuration directory unavailable")]
169    XdgDirectory,
170
171    /// Incompatible schema version.
172    #[error("incompatible schema version: expected {expected}, found {found}")]
173    SchemaIncompatible {
174        /// Expected schema version.
175        expected: u32,
176        /// Found schema version.
177        found: u32,
178    },
179
180    /// Uncategorized error — last resort (prefer typed variants).
181    #[error("error: {0}")]
182    Generic(String),
183}
184
185/// Process exit codes aligned with sysexits.h and Unix signal conventions.
186///
187/// # Examples
188///
189/// ```
190/// use ssh_cli::errors::exit_codes;
191///
192/// assert_eq!(exit_codes::EX_OK, 0);
193/// assert_eq!(exit_codes::EX_PIPE, 141);
194/// assert_eq!(exit_codes::EX_SIGINT, 130);
195/// assert_eq!(exit_codes::EX_SIGTERM, 143);
196/// assert_eq!(exit_codes::EX_NOPERM, 77);
197/// ```
198pub mod exit_codes {
199    /// Success.
200    pub const EX_OK: i32 = 0;
201    /// Generic domain failure.
202    pub const EX_GENERAL: i32 = 1;
203    /// Incorrect CLI usage.
204    pub const EX_USAGE: i32 = 64;
205    /// Invalid input data.
206    pub const EX_DATAERR: i32 = 65;
207    /// Input not found.
208    pub const EX_NOINPUT: i32 = 66;
209    /// Cannot create output.
210    pub const EX_CANTCREAT: i32 = 73;
211    /// I/O error.
212    pub const EX_IOERR: i32 = 74;
213    /// Permission denied.
214    pub const EX_NOPERM: i32 = 77;
215    /// Terminated by SIGINT (Ctrl+C).
216    pub const EX_SIGINT: i32 = 130;
217    /// Broken pipe on stdout/stderr (128 + SIGPIPE=13) — agent/pipe consumers closed early.
218    pub const EX_PIPE: i32 = 141;
219    /// Terminated by SIGTERM.
220    pub const EX_SIGTERM: i32 = 143;
221
222    // Compile-time invariants for sysexits-aligned codes.
223    const _: () = assert!(EX_OK == 0);
224    const _: () = assert!(EX_USAGE == 64);
225    const _: () = assert!(EX_PIPE == 141);
226    const _: () = assert!(EX_SIGINT == 130);
227    const _: () = assert!(EX_SIGTERM == 143);
228}
229
230/// Returns true when `err` is a broken-pipe condition (EPIPE / SIGPIPE path).
231///
232/// # Examples
233///
234/// ```
235/// use ssh_cli::errors::is_broken_pipe;
236/// use std::io::{Error, ErrorKind};
237///
238/// assert!(is_broken_pipe(&Error::new(ErrorKind::BrokenPipe, "pipe")));
239/// assert!(!is_broken_pipe(&Error::new(ErrorKind::Other, "x")));
240/// ```
241#[must_use]
242pub fn is_broken_pipe(err: &std::io::Error) -> bool {
243    err.kind() == std::io::ErrorKind::BrokenPipe
244}
245
246/// Walks `anyhow` / nested sources looking for a broken-pipe I/O error.
247#[must_use]
248pub fn anyhow_is_broken_pipe(err: &anyhow::Error) -> bool {
249    if let Some(ioe) = err.downcast_ref::<std::io::Error>() {
250        if is_broken_pipe(ioe) {
251            return true;
252        }
253    }
254    if let Some(SshCliError::Io(ioe)) = err.downcast_ref::<SshCliError>() {
255        return is_broken_pipe(ioe);
256    }
257    // Nested chain (e.g. anyhow context wrappers).
258    for cause in err.chain() {
259        if let Some(ioe) = cause.downcast_ref::<std::io::Error>() {
260            if is_broken_pipe(ioe) {
261                return true;
262            }
263        }
264        if let Some(SshCliError::Io(ioe)) = cause.downcast_ref::<SshCliError>() {
265            if is_broken_pipe(ioe) {
266                return true;
267            }
268        }
269    }
270    false
271}
272
273/// High-level error class for agent retry policy (Rules Rust — retry/backoff).
274///
275/// Serialized as snake_case in the JSON error envelope (`error_class`).
276#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum ErrorClass {
279    /// Network / SSH transport may succeed on a fresh process re-invocation.
280    Transient,
281    /// Fix inputs, credentials, or remote state — do not blind-retry.
282    Permanent,
283    /// Signal or broken pipe — do not retry.
284    Cancelled,
285}
286
287/// Stack layer where the failure was observed (diagnostic only).
288#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum ErrorLayer {
291    /// TCP connect / dial / DNS resolution failures.
292    Network,
293    /// SSH protocol / channel after TCP is up.
294    Ssh,
295    /// Authentication or host-key policy.
296    Auth,
297    /// Local CLI args, registry, schema, paths.
298    Application,
299    /// Local filesystem / std I/O (non-network).
300    Io,
301}
302
303/// Detailed retry kind returned by [`SshCliError::retry_kind`].
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum RetryKind {
306    /// Never retry with the same argv.
307    NotRetryable,
308    /// Transient network/connect failure (exit 74).
309    TransientNetwork,
310    /// Operation deadline exceeded (exit 74).
311    TransientTimeout,
312    /// SSH session/channel flaked after connect (exit 74).
313    TransientSsh,
314    /// Auth / host-key — change credentials first.
315    PermanentAuth,
316    /// Validation / not-found / schema — fix inputs.
317    PermanentClient,
318    /// Remote command non-zero — not a transport retry.
319    PermanentRemoteCommand,
320    /// SIGINT/SIGTERM/EPIPE.
321    Cancelled,
322}
323
324impl SshCliError {
325    /// TLS error with message only (no root cause).
326    ///
327    /// Accepts `&str` / `String` via [`AsRef<str>`] to avoid `Into` ambiguity
328    /// with foreign `From<&str>` impls in the dependency graph.
329    #[must_use]
330    pub fn tls_msg(message: impl AsRef<str>) -> Self {
331        Self::Tls {
332            message: message.as_ref().to_owned(),
333            source: None,
334        }
335    }
336
337    /// TLS error preserving [`Error::source`] (G-ERR-04 / G-ERR-16 DRY).
338    #[must_use]
339    pub fn tls_src(
340        message: impl AsRef<str>,
341        source: impl std::error::Error + Send + Sync + 'static,
342    ) -> Self {
343        Self::Tls {
344            message: message.as_ref().to_owned(),
345            source: Some(Box::new(source)),
346        }
347    }
348
349    /// Channel error with message only.
350    #[must_use]
351    pub fn channel_msg(message: impl AsRef<str>) -> Self {
352        Self::ChannelFailed {
353            message: message.as_ref().to_owned(),
354            source: None,
355        }
356    }
357
358    /// Channel error preserving source (G-ERR-05 / G-ERR-16 DRY).
359    #[must_use]
360    pub fn channel_src(
361        message: impl AsRef<str>,
362        source: impl std::error::Error + Send + Sync + 'static,
363    ) -> Self {
364        Self::ChannelFailed {
365            message: message.as_ref().to_owned(),
366            source: Some(Box::new(source)),
367        }
368    }
369
370    /// Crypto / secrets failure without embedding secret material.
371    #[must_use]
372    pub fn crypto(op: &'static str) -> Self {
373        Self::Crypto { op }
374    }
375
376    /// Stable machine-oriented code for JSON envelopes (G-ERR-08).
377    #[must_use]
378    pub fn error_code(&self) -> &'static str {
379        match self {
380            Self::Io(e) if is_broken_pipe(e) => "broken_pipe",
381            Self::Io(_) => "io",
382            Self::Json(_) => "json",
383            Self::TomlDe(_) => "toml_read",
384            Self::TomlSer(_) => "toml_write",
385            Self::Domain(_) => "domain_validation",
386            Self::SshConnection(_) => "ssh_connection",
387            Self::SshAuthentication(_) => "ssh_authentication",
388            Self::ConnectionFailed(_) => "connection_failed",
389            Self::AuthenticationFailed => "authentication_failed",
390            Self::HostKeyChanged { .. } => "host_key_changed",
391            Self::CommandTooLong { .. } => "command_too_long",
392            Self::SudoDisabled => "sudo_disabled",
393            Self::SuPasswordMissing => "su_password_missing",
394            Self::ChannelFailed { .. } => "channel_failed",
395            Self::SshTimeout(_) => "ssh_timeout",
396            Self::CommandFailed { .. } => "command_failed",
397            Self::VpsNotFound(_) => "vps_not_found",
398            Self::NoActiveVps => "no_active_vps",
399            Self::VpsDuplicate(_) => "vps_duplicate",
400            Self::FileNotFound(_) => "file_not_found",
401            Self::InvalidArgument(_) => "invalid_argument",
402            Self::Tls { .. } => "tls",
403            Self::Crypto { .. } => "crypto",
404            Self::Config(_) => "config",
405            Self::Timeout(_) => "timeout",
406            Self::XdgDirectory => "xdg_directory",
407            Self::SchemaIncompatible { .. } => "schema_incompatible",
408            Self::Generic(_) => "generic",
409        }
410    }
411
412    /// Returns the sysexits.h exit code for this error.
413    #[must_use]
414    pub fn exit_code(&self) -> i32 {
415        match self {
416            // G-IO-01: EPIPE → 141 (never report as generic I/O 74).
417            Self::Io(e) if is_broken_pipe(e) => exit_codes::EX_PIPE,
418            Self::Io(_) => exit_codes::EX_IOERR,
419            Self::Json(_) => exit_codes::EX_DATAERR,
420            Self::TomlDe(_) => exit_codes::EX_DATAERR,
421            Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
422            Self::Domain(_) => exit_codes::EX_USAGE,
423            Self::SshConnection(_) => exit_codes::EX_IOERR,
424            // GAP-AUD-020: authentication failures use EX_NOPERM (77); connect/IO stay 74.
425            Self::SshAuthentication(_) => exit_codes::EX_NOPERM,
426            Self::ConnectionFailed(_) => exit_codes::EX_IOERR,
427            Self::AuthenticationFailed => exit_codes::EX_NOPERM,
428            Self::HostKeyChanged { .. } => exit_codes::EX_NOPERM,
429            Self::CommandTooLong { .. } => exit_codes::EX_USAGE,
430            Self::SudoDisabled => exit_codes::EX_NOPERM,
431            Self::SuPasswordMissing => exit_codes::EX_USAGE,
432            Self::ChannelFailed { .. } => exit_codes::EX_IOERR,
433            Self::SshTimeout(_) => exit_codes::EX_IOERR,
434            Self::CommandFailed { .. } => exit_codes::EX_GENERAL,
435            Self::VpsNotFound(_) => exit_codes::EX_NOINPUT,
436            Self::NoActiveVps => exit_codes::EX_NOINPUT,
437            Self::VpsDuplicate(_) => exit_codes::EX_USAGE,
438            Self::FileNotFound(_) => exit_codes::EX_NOINPUT,
439            Self::InvalidArgument(_) => exit_codes::EX_USAGE,
440            Self::Tls { .. } => exit_codes::EX_IOERR,
441            Self::Crypto { .. } => exit_codes::EX_NOPERM,
442            Self::Config(_) => exit_codes::EX_DATAERR,
443            Self::Timeout(_) => exit_codes::EX_IOERR,
444            Self::XdgDirectory => exit_codes::EX_CANTCREAT,
445            Self::SchemaIncompatible { .. } => exit_codes::EX_DATAERR,
446            Self::Generic(_) => exit_codes::EX_GENERAL,
447        }
448    }
449
450    /// Typed retry kind (no string matching on `Display`).
451    #[must_use]
452    pub fn retry_kind(&self) -> RetryKind {
453        match self {
454            Self::Io(e) if is_broken_pipe(e) => RetryKind::Cancelled,
455            // Non-pipe I/O maps to exit 74 — agent may re-invoke (same as transport).
456            Self::Io(_) => RetryKind::TransientNetwork,
457            Self::Json(_) | Self::TomlDe(_) | Self::TomlSer(_) | Self::Config(_) => {
458                RetryKind::PermanentClient
459            }
460            Self::Domain(_) => RetryKind::PermanentClient,
461            Self::SshConnection(_) => RetryKind::TransientSsh,
462            Self::SshAuthentication(_) | Self::AuthenticationFailed | Self::HostKeyChanged { .. } => {
463                RetryKind::PermanentAuth
464            }
465            Self::ConnectionFailed(_) | Self::Tls { .. } => RetryKind::TransientNetwork,
466            Self::CommandTooLong { .. }
467            | Self::SudoDisabled
468            | Self::SuPasswordMissing
469            | Self::VpsNotFound(_)
470            | Self::NoActiveVps
471            | Self::VpsDuplicate(_)
472            | Self::FileNotFound(_)
473            | Self::InvalidArgument(_)
474            | Self::Crypto { .. }
475            | Self::XdgDirectory
476            | Self::SchemaIncompatible { .. }
477            | Self::Generic(_) => RetryKind::PermanentClient,
478            Self::ChannelFailed { .. } => RetryKind::TransientSsh,
479            Self::SshTimeout(_) | Self::Timeout(_) => RetryKind::TransientTimeout,
480            Self::CommandFailed { .. } => RetryKind::PermanentRemoteCommand,
481        }
482    }
483
484    /// Whether an agent may re-invoke the CLI with the same argv after backoff.
485    #[must_use]
486    pub fn is_retryable(&self) -> bool {
487        matches!(
488            self.retry_kind(),
489            RetryKind::TransientNetwork | RetryKind::TransientTimeout | RetryKind::TransientSsh
490        )
491    }
492
493    /// Explicit complement of [`Self::is_retryable`].
494    #[must_use]
495    pub fn is_permanent(&self) -> bool {
496        !self.is_retryable() && !matches!(self.retry_kind(), RetryKind::Cancelled)
497    }
498
499    /// High-level class for the JSON envelope `error_class` field.
500    #[must_use]
501    pub fn classify(&self) -> ErrorClass {
502        match self.retry_kind() {
503            RetryKind::TransientNetwork
504            | RetryKind::TransientTimeout
505            | RetryKind::TransientSsh => ErrorClass::Transient,
506            RetryKind::Cancelled => ErrorClass::Cancelled,
507            RetryKind::NotRetryable
508            | RetryKind::PermanentAuth
509            | RetryKind::PermanentClient
510            | RetryKind::PermanentRemoteCommand => ErrorClass::Permanent,
511        }
512    }
513
514    /// Stack layer for diagnostics.
515    #[must_use]
516    pub fn layer(&self) -> ErrorLayer {
517        match self {
518            Self::Io(e) if is_broken_pipe(e) => ErrorLayer::Io,
519            Self::Io(e) if io_error_is_transient_network(e) => ErrorLayer::Network,
520            Self::Io(_) => ErrorLayer::Io,
521            Self::ConnectionFailed(_) | Self::Timeout(_) | Self::Tls { .. } => ErrorLayer::Network,
522            Self::SshConnection(_) | Self::ChannelFailed { .. } | Self::SshTimeout(_) => {
523                ErrorLayer::Ssh
524            }
525            Self::SshAuthentication(_)
526            | Self::AuthenticationFailed
527            | Self::HostKeyChanged { .. }
528            | Self::SudoDisabled
529            | Self::Crypto { .. } => ErrorLayer::Auth,
530            Self::Json(_)
531            | Self::TomlDe(_)
532            | Self::TomlSer(_)
533            | Self::Domain(_)
534            | Self::CommandTooLong { .. }
535            | Self::SuPasswordMissing
536            | Self::CommandFailed { .. }
537            | Self::VpsNotFound(_)
538            | Self::NoActiveVps
539            | Self::VpsDuplicate(_)
540            | Self::FileNotFound(_)
541            | Self::InvalidArgument(_)
542            | Self::Config(_)
543            | Self::XdgDirectory
544            | Self::SchemaIncompatible { .. }
545            | Self::Generic(_) => ErrorLayer::Application,
546        }
547    }
548
549    /// Optional cool-down hint (SSH has no HTTP Retry-After; always `None`).
550    #[must_use]
551    pub fn retry_after(&self) -> Option<std::time::Duration> {
552        let _ = self;
553        None
554    }
555
556    /// Short agent-facing suggestion for the JSON envelope.
557    #[must_use]
558    pub fn suggestion(&self) -> Option<&'static str> {
559        match self.retry_kind() {
560            RetryKind::TransientNetwork | RetryKind::TransientSsh => {
561                Some("retry at most twice with exponential full-jitter backoff (exit 74)")
562            }
563            RetryKind::TransientTimeout => {
564                Some("increase --timeout / --timeout-ms, then retry at most twice with backoff")
565            }
566            RetryKind::PermanentAuth => {
567                Some("change credentials (--key / --password-stdin) or host-key policy; do not blind-retry")
568            }
569            RetryKind::PermanentRemoteCommand => {
570                Some("inspect remote stderr; fix remote command — transport retry will not help")
571            }
572            RetryKind::PermanentClient => {
573                Some("fix CLI arguments, registry state, or schema; do not retry unchanged")
574            }
575            RetryKind::Cancelled => Some("do not retry after signal or broken pipe"),
576            RetryKind::NotRetryable => None,
577        }
578    }
579}
580
581/// Classifies `std::io::Error` kinds that are typically transient on the network path.
582#[must_use]
583pub fn io_error_is_transient_network(err: &std::io::Error) -> bool {
584    use std::io::ErrorKind;
585    matches!(
586        err.kind(),
587        ErrorKind::ConnectionRefused
588            | ErrorKind::ConnectionReset
589            | ErrorKind::ConnectionAborted
590            | ErrorKind::NotConnected
591            | ErrorKind::AddrNotAvailable
592            | ErrorKind::TimedOut
593            | ErrorKind::Interrupted
594            | ErrorKind::WouldBlock
595            | ErrorKind::UnexpectedEof
596            | ErrorKind::NetworkUnreachable
597            | ErrorKind::HostUnreachable
598    )
599}
600
601/// Result alias using [`SshCliError`].
602pub type SshCliResult<T> = std::result::Result<T, SshCliError>;
603
604
605#[cfg(test)]
606#[path = "errors_tests.rs"]
607mod tests;