Skip to main content

anvil_ssh/
error.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3// Updated 2026-04-12: added error_code(), exit_code(), hint() for SFRS Rule 2/5
4//! Error types for `anvil-ssh`.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use anvil_ssh::AnvilError;
10//!
11//! fn handle(err: &AnvilError) {
12//!     if err.is_host_key_mismatch() {
13//!         eprintln!("Possible MITM — host key does not match pinned fingerprints.");
14//!     }
15//! }
16//! ```
17
18use std::backtrace::Backtrace;
19use std::fmt;
20
21// ── Inner error kind ──────────────────────────────────────────────────────────
22
23/// Internal discriminant for [`AnvilError`].
24///
25/// Not part of the public API; callers use the `is_*` predicate methods.
26#[derive(Debug)]
27pub(crate) enum AnvilErrorKind {
28    /// Underlying I/O failure.
29    Io(std::io::Error),
30    /// russh protocol-level error.
31    Ssh(russh::Error),
32    /// russh key loading / parsing error.
33    Keys(russh::keys::Error),
34    /// The server's host key did not match any pinned fingerprint.
35    ///
36    /// `fingerprint` is the SHA-256 fingerprint that was actually received
37    /// (formatted as `"SHA256:<base64>"`).
38    HostKeyMismatch { fingerprint: String },
39    /// Public-key authentication was rejected by the server.
40    AuthenticationFailed,
41    /// No usable identity key was found on any search path or agent.
42    NoKeyFound,
43    /// Configuration is logically invalid.
44    InvalidConfig { message: String },
45    /// SSH signature production failed (bad key, I/O, encoding).
46    Signing { message: String },
47    /// SSH signature verification failed (tampering, wrong signer, namespace mismatch).
48    SignatureInvalid { reason: String },
49}
50
51impl fmt::Display for AnvilErrorKind {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Io(e) => write!(f, "I/O error: {e}"),
55            Self::Ssh(e) => write!(f, "SSH protocol error: {e}"),
56            Self::Keys(e) => write!(f, "SSH key error: {e}"),
57            Self::HostKeyMismatch { fingerprint } => {
58                write!(
59                    f,
60                    "host key mismatch — received fingerprint {fingerprint} \
61                     does not match any pinned fingerprint"
62                )
63            }
64            Self::AuthenticationFailed => write!(f, "public-key authentication failed"),
65            Self::NoKeyFound => {
66                write!(f, "no SSH identity key found on any search path or agent")
67            }
68            Self::InvalidConfig { message } => write!(f, "invalid configuration: {message}"),
69            Self::Signing { message } => write!(f, "SSH signing failed: {message}"),
70            Self::SignatureInvalid { reason } => {
71                write!(f, "SSH signature verification failed: {reason}")
72            }
73        }
74    }
75}
76
77// ── Public error type ─────────────────────────────────────────────────────────
78
79/// The single error type returned by all `anvil-ssh` operations.
80///
81/// Provides `is_*` predicate methods so callers can branch on error categories
82/// without depending on internal representation. A [`Backtrace`] is captured
83/// automatically; it is rendered via [`std::fmt::Display`] when
84/// `RUST_BACKTRACE=1` is set.
85///
86/// # Predicates
87///
88/// | Method | Condition |
89/// |---|---|
90/// | [`is_io`](AnvilError::is_io) | Underlying I/O failure |
91/// | [`is_host_key_mismatch`](AnvilError::is_host_key_mismatch) | Server key does not match pinned fingerprints |
92/// | [`is_authentication_failed`](AnvilError::is_authentication_failed) | Server rejected our key |
93/// | [`is_no_key_found`](AnvilError::is_no_key_found) | No identity key available |
94/// | [`is_key_encrypted`](AnvilError::is_key_encrypted) | Key file needs a passphrase |
95#[derive(Debug)]
96pub struct AnvilError {
97    kind: AnvilErrorKind,
98    /// Optional per-instance hint override.  When set, [`hint`](AnvilError::hint)
99    /// returns this string instead of the static default chosen from
100    /// [`AnvilErrorKind`].
101    ///
102    /// Context-specific hints fire much more precisely than the kind-level
103    /// defaults: an `InvalidConfig` error from the `-E` flag parser can
104    /// say "pass `sha256` or `sha512` to `-E`", while an `InvalidConfig`
105    /// error from the sign path can say "load the key into the agent".
106    /// The kind-level default stays as the catch-all fallback.
107    custom_hint: Option<String>,
108    backtrace: Backtrace,
109}
110
111impl AnvilError {
112    /// Constructs a new [`AnvilError`] capturing the current backtrace.
113    pub(crate) fn new(kind: AnvilErrorKind) -> Self {
114        Self {
115            kind,
116            custom_hint: None,
117            backtrace: Backtrace::capture(),
118        }
119    }
120
121    /// Attaches a context-specific hint that supersedes the kind-level
122    /// default returned by [`hint`](AnvilError::hint).
123    ///
124    /// Use this at call sites where the caller knows exactly what the
125    /// user should do next — much more useful than a generic "run
126    /// `gitway --help`".
127    ///
128    /// # Example
129    ///
130    /// ```rust
131    /// use anvil_ssh::AnvilError;
132    ///
133    /// let e = AnvilError::invalid_config("no such host: github.com.invalid")
134    ///     .with_hint("Check the hostname for typos, or run `gitway --test <host>` to confirm reachability");
135    /// assert!(e.hint().contains("typos"));
136    /// ```
137    #[must_use]
138    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
139        self.custom_hint = Some(hint.into());
140        self
141    }
142
143    // ── Constructors for common variants ─────────────────────────────────────
144
145    pub fn host_key_mismatch(fingerprint: impl Into<String>) -> Self {
146        Self::new(AnvilErrorKind::HostKeyMismatch {
147            fingerprint: fingerprint.into(),
148        })
149    }
150
151    #[must_use]
152    pub fn authentication_failed() -> Self {
153        Self::new(AnvilErrorKind::AuthenticationFailed)
154    }
155
156    #[must_use]
157    pub fn no_key_found() -> Self {
158        Self::new(AnvilErrorKind::NoKeyFound)
159    }
160
161    pub fn invalid_config(message: impl Into<String>) -> Self {
162        Self::new(AnvilErrorKind::InvalidConfig {
163            message: message.into(),
164        })
165    }
166
167    /// Signals that SSH signature production failed.
168    ///
169    /// Mapped to exit code 1 (`GENERAL_ERROR`).
170    pub fn signing(message: impl Into<String>) -> Self {
171        Self::new(AnvilErrorKind::Signing {
172            message: message.into(),
173        })
174    }
175
176    /// Signals that SSH signature verification failed.
177    ///
178    /// Mapped to exit code 4 (`PERMISSION_DENIED`) to match git's treatment
179    /// of a non-zero `ssh-keygen -Y verify` as an authentication-class failure.
180    pub fn signature_invalid(reason: impl Into<String>) -> Self {
181        Self::new(AnvilErrorKind::SignatureInvalid {
182            reason: reason.into(),
183        })
184    }
185
186    // ── Predicates ────────────────────────────────────────────────────────────
187
188    /// Returns `true` if this error originated from an I/O failure.
189    #[must_use]
190    pub fn is_io(&self) -> bool {
191        matches!(self.kind, AnvilErrorKind::Io(_))
192    }
193
194    /// Returns the underlying [`std::io::ErrorKind`] when this error
195    /// is an I/O variant, or `None` otherwise.
196    ///
197    /// Used by [`crate::retry::classify`] (M18, FR-82) to distinguish
198    /// transient connection failures (`ConnectionRefused`,
199    /// `TimedOut`, `HostUnreachable`, …) from fatal ones
200    /// (`PermissionDenied`, etc.).
201    #[must_use]
202    pub fn io_kind(&self) -> Option<std::io::ErrorKind> {
203        match &self.kind {
204            AnvilErrorKind::Io(e) => Some(e.kind()),
205            _ => None,
206        }
207    }
208
209    /// Returns `true` when this error is classified as transient by
210    /// [`crate::retry::classify`] — i.e. the [`crate::retry::run`]
211    /// loop would retry it (M18, FR-82).
212    ///
213    /// Useful for callers that want to short-circuit before reaching
214    /// the retry loop, e.g. log-aggregation pipelines deciding
215    /// whether a single failure should page on-call.
216    #[must_use]
217    pub fn is_transient(&self) -> bool {
218        matches!(
219            crate::retry::classify(self),
220            crate::retry::Disposition::Retry,
221        )
222    }
223
224    /// Returns `true` if the server's host key did not match any pinned fingerprint.
225    #[must_use]
226    pub fn is_host_key_mismatch(&self) -> bool {
227        matches!(self.kind, AnvilErrorKind::HostKeyMismatch { .. })
228    }
229
230    /// Returns `true` if the server rejected our public-key authentication attempt.
231    #[must_use]
232    pub fn is_authentication_failed(&self) -> bool {
233        matches!(self.kind, AnvilErrorKind::AuthenticationFailed)
234    }
235
236    /// Returns `true` if no usable identity key was found.
237    #[must_use]
238    pub fn is_no_key_found(&self) -> bool {
239        matches!(self.kind, AnvilErrorKind::NoKeyFound)
240    }
241
242    /// Returns `true` if a key file was found but requires a passphrase to decrypt.
243    #[must_use]
244    pub fn is_key_encrypted(&self) -> bool {
245        matches!(
246            self.kind,
247            AnvilErrorKind::Keys(russh::keys::Error::KeyIsEncrypted)
248        )
249    }
250
251    /// Returns the path at which an encrypted key was found, if applicable.
252    #[must_use]
253    pub fn fingerprint(&self) -> Option<&str> {
254        match &self.kind {
255            AnvilErrorKind::HostKeyMismatch { fingerprint } => Some(fingerprint),
256            _ => None,
257        }
258    }
259
260    /// Returns an upper-snake-case error code for structured JSON output (SFRS Rule 5).
261    ///
262    /// | Code | Exit code | Condition |
263    /// |------|-----------|-----------|
264    /// | `GENERAL_ERROR` | 1 | I/O, SSH protocol, or key-parsing failure |
265    /// | `USAGE_ERROR` | 2 | Invalid configuration or bad arguments |
266    /// | `NOT_FOUND` | 3 | No identity key found |
267    /// | `PERMISSION_DENIED` | 4 | Host key mismatch or authentication failure |
268    #[must_use]
269    pub fn error_code(&self) -> &'static str {
270        match &self.kind {
271            AnvilErrorKind::InvalidConfig { .. } => "USAGE_ERROR",
272            AnvilErrorKind::NoKeyFound => "NOT_FOUND",
273            AnvilErrorKind::HostKeyMismatch { .. }
274            | AnvilErrorKind::AuthenticationFailed
275            | AnvilErrorKind::SignatureInvalid { .. } => "PERMISSION_DENIED",
276            AnvilErrorKind::Io(_)
277            | AnvilErrorKind::Ssh(_)
278            | AnvilErrorKind::Keys(_)
279            | AnvilErrorKind::Signing { .. } => "GENERAL_ERROR",
280        }
281    }
282
283    /// Returns the numeric process exit code for this error (SFRS Rule 2).
284    ///
285    /// | Code | Meaning |
286    /// |------|---------|
287    /// | 1 | General / unexpected error |
288    /// | 2 | Usage error (bad arguments, invalid configuration) |
289    /// | 3 | Not found (no identity key, unknown host) |
290    /// | 4 | Permission denied (authentication failure, host key mismatch) |
291    #[must_use]
292    pub fn exit_code(&self) -> u32 {
293        match &self.kind {
294            AnvilErrorKind::InvalidConfig { .. } => 2,
295            AnvilErrorKind::NoKeyFound => 3,
296            AnvilErrorKind::HostKeyMismatch { .. }
297            | AnvilErrorKind::AuthenticationFailed
298            | AnvilErrorKind::SignatureInvalid { .. } => 4,
299            AnvilErrorKind::Io(_)
300            | AnvilErrorKind::Ssh(_)
301            | AnvilErrorKind::Keys(_)
302            | AnvilErrorKind::Signing { .. } => 1,
303        }
304    }
305
306    /// Returns a short "what to do next" line for the user.
307    ///
308    /// Call-site-specific hints attached via [`with_hint`](Self::with_hint)
309    /// take priority.  Otherwise the kind-level default is returned —
310    /// these are deliberately phrased in plain English and prescriptive
311    /// voice (tell the reader what to type, not what went wrong; the
312    /// [`Display`](std::fmt::Display) output already says what went wrong).
313    ///
314    /// Emitted on stderr after the error message in human mode, and
315    /// carried as the `hint` field in `--json` output (SFRS Rule 5).
316    #[must_use]
317    pub fn hint(&self) -> &str {
318        if let Some(h) = self.custom_hint.as_deref() {
319            return h;
320        }
321        match &self.kind {
322            AnvilErrorKind::HostKeyMismatch { .. } => {
323                "The server's SSH fingerprint doesn't match what gitway trusts. \
324                 This is either a routine key rotation by the provider or a \
325                 possible man-in-the-middle attack. Compare the received \
326                 fingerprint against the provider's official list; if you \
327                 trust it, add it to ~/.config/gitway/known_hosts."
328            }
329            AnvilErrorKind::AuthenticationFailed => {
330                "The server rejected your SSH key. Two things to check: the \
331                 public key is registered in the provider's account settings, \
332                 and the private key is loaded (run `gitway-add ~/.ssh/id_ed25519`)."
333            }
334            AnvilErrorKind::NoKeyFound => {
335                "No SSH key was found. Generate one with `gitway keygen ed25519 \
336                 --out ~/.ssh/id_ed25519`, or point gitway at an existing key \
337                 via `--identity <path>`."
338            }
339            AnvilErrorKind::InvalidConfig { .. } => {
340                "Something in your command or config is off. Run `gitway --help` \
341                 to see accepted flags, or re-read the error message above — \
342                 it usually names the exact argument to fix."
343            }
344            AnvilErrorKind::Signing { .. } => {
345                "Signing the commit failed. If the key is encrypted, either \
346                 load it into the agent (`gitway-add <key>`) so signing can \
347                 use it without a passphrase, or set SSH_ASKPASS to a GUI \
348                 helper so you can type the passphrase in a dialog."
349            }
350            AnvilErrorKind::SignatureInvalid { .. } => {
351                "The signature doesn't match. Either the signed data was \
352                 changed after signing, a different key produced it, or the \
353                 namespace (usually `git`) is different."
354            }
355            AnvilErrorKind::Io(_) | AnvilErrorKind::Ssh(_) | AnvilErrorKind::Keys(_) => {
356                "Something broke before the SSH session was fully set up. \
357                 Run `gitway --test --verbose <host>` to see where it fails."
358            }
359        }
360    }
361}
362
363// ── Trait implementations ─────────────────────────────────────────────────────
364
365impl fmt::Display for AnvilError {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        write!(f, "{}", self.kind)?;
368        let bt = self.backtrace.to_string();
369        if !bt.is_empty() && bt != "disabled backtrace" {
370            write!(f, "\n\nstack backtrace:\n{bt}")?;
371        }
372        Ok(())
373    }
374}
375
376impl std::error::Error for AnvilError {
377    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
378        match &self.kind {
379            AnvilErrorKind::Io(e) => Some(e),
380            AnvilErrorKind::Ssh(e) => Some(e),
381            AnvilErrorKind::Keys(e) => Some(e),
382            _ => None,
383        }
384    }
385}
386
387impl From<russh::Error> for AnvilError {
388    fn from(e: russh::Error) -> Self {
389        Self::new(AnvilErrorKind::Ssh(e))
390    }
391}
392
393impl From<russh::keys::Error> for AnvilError {
394    fn from(e: russh::keys::Error) -> Self {
395        Self::new(AnvilErrorKind::Keys(e))
396    }
397}
398
399impl From<std::io::Error> for AnvilError {
400    fn from(e: std::io::Error) -> Self {
401        Self::new(AnvilErrorKind::Io(e))
402    }
403}
404
405impl From<russh::AgentAuthError> for AnvilError {
406    fn from(e: russh::AgentAuthError) -> Self {
407        match e {
408            russh::AgentAuthError::Send(_) => {
409                Self::new(AnvilErrorKind::Ssh(russh::Error::SendError))
410            }
411            russh::AgentAuthError::Key(k) => Self::new(AnvilErrorKind::Keys(k)),
412        }
413    }
414}