Skip to main content

gitway_lib/
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 `gitway-lib`.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use gitway_lib::GitwayError;
10//!
11//! fn handle(err: &GitwayError) {
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 [`GitwayError`].
24///
25/// Not part of the public API; callers use the `is_*` predicate methods.
26#[derive(Debug)]
27pub(crate) enum GitwayErrorKind {
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 GitwayErrorKind {
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 `gitway-lib` 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`](GitwayError::is_io) | Underlying I/O failure |
91/// | [`is_host_key_mismatch`](GitwayError::is_host_key_mismatch) | Server key does not match pinned fingerprints |
92/// | [`is_authentication_failed`](GitwayError::is_authentication_failed) | Server rejected our key |
93/// | [`is_no_key_found`](GitwayError::is_no_key_found) | No identity key available |
94/// | [`is_key_encrypted`](GitwayError::is_key_encrypted) | Key file needs a passphrase |
95#[derive(Debug)]
96pub struct GitwayError {
97    kind: GitwayErrorKind,
98    /// Optional per-instance hint override.  When set, [`hint`](GitwayError::hint)
99    /// returns this string instead of the static default chosen from
100    /// [`GitwayErrorKind`].
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 GitwayError {
112    /// Constructs a new [`GitwayError`] capturing the current backtrace.
113    pub(crate) fn new(kind: GitwayErrorKind) -> 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`](GitwayError::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 gitway_lib::GitwayError;
132    ///
133    /// let e = GitwayError::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(GitwayErrorKind::HostKeyMismatch {
147            fingerprint: fingerprint.into(),
148        })
149    }
150
151    #[must_use]
152    pub fn authentication_failed() -> Self {
153        Self::new(GitwayErrorKind::AuthenticationFailed)
154    }
155
156    #[must_use]
157    pub fn no_key_found() -> Self {
158        Self::new(GitwayErrorKind::NoKeyFound)
159    }
160
161    pub fn invalid_config(message: impl Into<String>) -> Self {
162        Self::new(GitwayErrorKind::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(GitwayErrorKind::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(GitwayErrorKind::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, GitwayErrorKind::Io(_))
192    }
193
194    /// Returns `true` if the server's host key did not match any pinned fingerprint.
195    #[must_use]
196    pub fn is_host_key_mismatch(&self) -> bool {
197        matches!(self.kind, GitwayErrorKind::HostKeyMismatch { .. })
198    }
199
200    /// Returns `true` if the server rejected our public-key authentication attempt.
201    #[must_use]
202    pub fn is_authentication_failed(&self) -> bool {
203        matches!(self.kind, GitwayErrorKind::AuthenticationFailed)
204    }
205
206    /// Returns `true` if no usable identity key was found.
207    #[must_use]
208    pub fn is_no_key_found(&self) -> bool {
209        matches!(self.kind, GitwayErrorKind::NoKeyFound)
210    }
211
212    /// Returns `true` if a key file was found but requires a passphrase to decrypt.
213    #[must_use]
214    pub fn is_key_encrypted(&self) -> bool {
215        matches!(
216            self.kind,
217            GitwayErrorKind::Keys(russh::keys::Error::KeyIsEncrypted)
218        )
219    }
220
221    /// Returns the path at which an encrypted key was found, if applicable.
222    #[must_use]
223    pub fn fingerprint(&self) -> Option<&str> {
224        match &self.kind {
225            GitwayErrorKind::HostKeyMismatch { fingerprint } => Some(fingerprint),
226            _ => None,
227        }
228    }
229
230    /// Returns an upper-snake-case error code for structured JSON output (SFRS Rule 5).
231    ///
232    /// | Code | Exit code | Condition |
233    /// |------|-----------|-----------|
234    /// | `GENERAL_ERROR` | 1 | I/O, SSH protocol, or key-parsing failure |
235    /// | `USAGE_ERROR` | 2 | Invalid configuration or bad arguments |
236    /// | `NOT_FOUND` | 3 | No identity key found |
237    /// | `PERMISSION_DENIED` | 4 | Host key mismatch or authentication failure |
238    #[must_use]
239    pub fn error_code(&self) -> &'static str {
240        match &self.kind {
241            GitwayErrorKind::InvalidConfig { .. } => "USAGE_ERROR",
242            GitwayErrorKind::NoKeyFound => "NOT_FOUND",
243            GitwayErrorKind::HostKeyMismatch { .. }
244            | GitwayErrorKind::AuthenticationFailed
245            | GitwayErrorKind::SignatureInvalid { .. } => "PERMISSION_DENIED",
246            GitwayErrorKind::Io(_)
247            | GitwayErrorKind::Ssh(_)
248            | GitwayErrorKind::Keys(_)
249            | GitwayErrorKind::Signing { .. } => "GENERAL_ERROR",
250        }
251    }
252
253    /// Returns the numeric process exit code for this error (SFRS Rule 2).
254    ///
255    /// | Code | Meaning |
256    /// |------|---------|
257    /// | 1 | General / unexpected error |
258    /// | 2 | Usage error (bad arguments, invalid configuration) |
259    /// | 3 | Not found (no identity key, unknown host) |
260    /// | 4 | Permission denied (authentication failure, host key mismatch) |
261    #[must_use]
262    pub fn exit_code(&self) -> u32 {
263        match &self.kind {
264            GitwayErrorKind::InvalidConfig { .. } => 2,
265            GitwayErrorKind::NoKeyFound => 3,
266            GitwayErrorKind::HostKeyMismatch { .. }
267            | GitwayErrorKind::AuthenticationFailed
268            | GitwayErrorKind::SignatureInvalid { .. } => 4,
269            GitwayErrorKind::Io(_)
270            | GitwayErrorKind::Ssh(_)
271            | GitwayErrorKind::Keys(_)
272            | GitwayErrorKind::Signing { .. } => 1,
273        }
274    }
275
276    /// Returns a short "what to do next" line for the user.
277    ///
278    /// Call-site-specific hints attached via [`with_hint`](Self::with_hint)
279    /// take priority.  Otherwise the kind-level default is returned —
280    /// these are deliberately phrased in plain English and prescriptive
281    /// voice (tell the reader what to type, not what went wrong; the
282    /// [`Display`](std::fmt::Display) output already says what went wrong).
283    ///
284    /// Emitted on stderr after the error message in human mode, and
285    /// carried as the `hint` field in `--json` output (SFRS Rule 5).
286    #[must_use]
287    pub fn hint(&self) -> &str {
288        if let Some(h) = self.custom_hint.as_deref() {
289            return h;
290        }
291        match &self.kind {
292            GitwayErrorKind::HostKeyMismatch { .. } => {
293                "The server's SSH fingerprint doesn't match what gitway trusts. \
294                 This is either a routine key rotation by the provider or a \
295                 possible man-in-the-middle attack. Compare the received \
296                 fingerprint against the provider's official list; if you \
297                 trust it, add it to ~/.config/gitway/known_hosts."
298            }
299            GitwayErrorKind::AuthenticationFailed => {
300                "The server rejected your SSH key. Two things to check: the \
301                 public key is registered in the provider's account settings, \
302                 and the private key is loaded (run `gitway-add ~/.ssh/id_ed25519`)."
303            }
304            GitwayErrorKind::NoKeyFound => {
305                "No SSH key was found. Generate one with `gitway keygen ed25519 \
306                 --out ~/.ssh/id_ed25519`, or point gitway at an existing key \
307                 via `--identity <path>`."
308            }
309            GitwayErrorKind::InvalidConfig { .. } => {
310                "Something in your command or config is off. Run `gitway --help` \
311                 to see accepted flags, or re-read the error message above — \
312                 it usually names the exact argument to fix."
313            }
314            GitwayErrorKind::Signing { .. } => {
315                "Signing the commit failed. If the key is encrypted, either \
316                 load it into the agent (`gitway-add <key>`) so signing can \
317                 use it without a passphrase, or set SSH_ASKPASS to a GUI \
318                 helper so you can type the passphrase in a dialog."
319            }
320            GitwayErrorKind::SignatureInvalid { .. } => {
321                "The signature doesn't match. Either the signed data was \
322                 changed after signing, a different key produced it, or the \
323                 namespace (usually `git`) is different."
324            }
325            GitwayErrorKind::Io(_) | GitwayErrorKind::Ssh(_) | GitwayErrorKind::Keys(_) => {
326                "Something broke before the SSH session was fully set up. \
327                 Run `gitway --test --verbose <host>` to see where it fails."
328            }
329        }
330    }
331}
332
333// ── Trait implementations ─────────────────────────────────────────────────────
334
335impl fmt::Display for GitwayError {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        write!(f, "{}", self.kind)?;
338        let bt = self.backtrace.to_string();
339        if !bt.is_empty() && bt != "disabled backtrace" {
340            write!(f, "\n\nstack backtrace:\n{bt}")?;
341        }
342        Ok(())
343    }
344}
345
346impl std::error::Error for GitwayError {
347    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
348        match &self.kind {
349            GitwayErrorKind::Io(e) => Some(e),
350            GitwayErrorKind::Ssh(e) => Some(e),
351            GitwayErrorKind::Keys(e) => Some(e),
352            _ => None,
353        }
354    }
355}
356
357impl From<russh::Error> for GitwayError {
358    fn from(e: russh::Error) -> Self {
359        Self::new(GitwayErrorKind::Ssh(e))
360    }
361}
362
363impl From<russh::keys::Error> for GitwayError {
364    fn from(e: russh::keys::Error) -> Self {
365        Self::new(GitwayErrorKind::Keys(e))
366    }
367}
368
369impl From<std::io::Error> for GitwayError {
370    fn from(e: std::io::Error) -> Self {
371        Self::new(GitwayErrorKind::Io(e))
372    }
373}
374
375impl From<russh::AgentAuthError> for GitwayError {
376    fn from(e: russh::AgentAuthError) -> Self {
377        match e {
378            russh::AgentAuthError::Send(_) => {
379                Self::new(GitwayErrorKind::Ssh(russh::Error::SendError))
380            }
381            russh::AgentAuthError::Key(k) => Self::new(GitwayErrorKind::Keys(k)),
382        }
383    }
384}