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}
46
47impl fmt::Display for GitwayErrorKind {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::Io(e) => write!(f, "I/O error: {e}"),
51            Self::Ssh(e) => write!(f, "SSH protocol error: {e}"),
52            Self::Keys(e) => write!(f, "SSH key error: {e}"),
53            Self::HostKeyMismatch { fingerprint } => {
54                write!(
55                    f,
56                    "host key mismatch — received fingerprint {fingerprint} \
57                     does not match any pinned fingerprint"
58                )
59            }
60            Self::AuthenticationFailed => write!(f, "public-key authentication failed"),
61            Self::NoKeyFound => {
62                write!(f, "no SSH identity key found on any search path or agent")
63            }
64            Self::InvalidConfig { message } => write!(f, "invalid configuration: {message}"),
65        }
66    }
67}
68
69// ── Public error type ─────────────────────────────────────────────────────────
70
71/// The single error type returned by all `gitway-lib` operations.
72///
73/// Provides `is_*` predicate methods so callers can branch on error categories
74/// without depending on internal representation. A [`Backtrace`] is captured
75/// automatically; it is rendered via [`std::fmt::Display`] when
76/// `RUST_BACKTRACE=1` is set.
77///
78/// # Predicates
79///
80/// | Method | Condition |
81/// |---|---|
82/// | [`is_io`](GitwayError::is_io) | Underlying I/O failure |
83/// | [`is_host_key_mismatch`](GitwayError::is_host_key_mismatch) | Server key does not match pinned fingerprints |
84/// | [`is_authentication_failed`](GitwayError::is_authentication_failed) | Server rejected our key |
85/// | [`is_no_key_found`](GitwayError::is_no_key_found) | No identity key available |
86/// | [`is_key_encrypted`](GitwayError::is_key_encrypted) | Key file needs a passphrase |
87#[derive(Debug)]
88pub struct GitwayError {
89    kind: GitwayErrorKind,
90    backtrace: Backtrace,
91}
92
93impl GitwayError {
94    /// Constructs a new [`GitwayError`] capturing the current backtrace.
95    pub(crate) fn new(kind: GitwayErrorKind) -> Self {
96        Self {
97            kind,
98            backtrace: Backtrace::capture(),
99        }
100    }
101
102    // ── Constructors for common variants ─────────────────────────────────────
103
104    pub fn host_key_mismatch(fingerprint: impl Into<String>) -> Self {
105        Self::new(GitwayErrorKind::HostKeyMismatch {
106            fingerprint: fingerprint.into(),
107        })
108    }
109
110    #[must_use]
111    pub fn authentication_failed() -> Self {
112        Self::new(GitwayErrorKind::AuthenticationFailed)
113    }
114
115    #[must_use]
116    pub fn no_key_found() -> Self {
117        Self::new(GitwayErrorKind::NoKeyFound)
118    }
119
120    pub fn invalid_config(message: impl Into<String>) -> Self {
121        Self::new(GitwayErrorKind::InvalidConfig {
122            message: message.into(),
123        })
124    }
125
126    // ── Predicates ────────────────────────────────────────────────────────────
127
128    /// Returns `true` if this error originated from an I/O failure.
129    #[must_use]
130    pub fn is_io(&self) -> bool {
131        matches!(self.kind, GitwayErrorKind::Io(_))
132    }
133
134    /// Returns `true` if the server's host key did not match any pinned fingerprint.
135    #[must_use]
136    pub fn is_host_key_mismatch(&self) -> bool {
137        matches!(self.kind, GitwayErrorKind::HostKeyMismatch { .. })
138    }
139
140    /// Returns `true` if the server rejected our public-key authentication attempt.
141    #[must_use]
142    pub fn is_authentication_failed(&self) -> bool {
143        matches!(self.kind, GitwayErrorKind::AuthenticationFailed)
144    }
145
146    /// Returns `true` if no usable identity key was found.
147    #[must_use]
148    pub fn is_no_key_found(&self) -> bool {
149        matches!(self.kind, GitwayErrorKind::NoKeyFound)
150    }
151
152    /// Returns `true` if a key file was found but requires a passphrase to decrypt.
153    #[must_use]
154    pub fn is_key_encrypted(&self) -> bool {
155        matches!(
156            self.kind,
157            GitwayErrorKind::Keys(russh::keys::Error::KeyIsEncrypted)
158        )
159    }
160
161    /// Returns the path at which an encrypted key was found, if applicable.
162    #[must_use]
163    pub fn fingerprint(&self) -> Option<&str> {
164        match &self.kind {
165            GitwayErrorKind::HostKeyMismatch { fingerprint } => Some(fingerprint),
166            _ => None,
167        }
168    }
169
170    /// Returns an upper-snake-case error code for structured JSON output (SFRS Rule 5).
171    ///
172    /// | Code | Exit code | Condition |
173    /// |------|-----------|-----------|
174    /// | `GENERAL_ERROR` | 1 | I/O, SSH protocol, or key-parsing failure |
175    /// | `USAGE_ERROR` | 2 | Invalid configuration or bad arguments |
176    /// | `NOT_FOUND` | 3 | No identity key found |
177    /// | `PERMISSION_DENIED` | 4 | Host key mismatch or authentication failure |
178    #[must_use]
179    pub fn error_code(&self) -> &'static str {
180        match &self.kind {
181            GitwayErrorKind::InvalidConfig { .. } => "USAGE_ERROR",
182            GitwayErrorKind::NoKeyFound => "NOT_FOUND",
183            GitwayErrorKind::HostKeyMismatch { .. } | GitwayErrorKind::AuthenticationFailed => {
184                "PERMISSION_DENIED"
185            }
186            GitwayErrorKind::Io(_) | GitwayErrorKind::Ssh(_) | GitwayErrorKind::Keys(_) => {
187                "GENERAL_ERROR"
188            }
189        }
190    }
191
192    /// Returns the numeric process exit code for this error (SFRS Rule 2).
193    ///
194    /// | Code | Meaning |
195    /// |------|---------|
196    /// | 1 | General / unexpected error |
197    /// | 2 | Usage error (bad arguments, invalid configuration) |
198    /// | 3 | Not found (no identity key, unknown host) |
199    /// | 4 | Permission denied (authentication failure, host key mismatch) |
200    #[must_use]
201    pub fn exit_code(&self) -> u32 {
202        match &self.kind {
203            GitwayErrorKind::InvalidConfig { .. } => 2,
204            GitwayErrorKind::NoKeyFound => 3,
205            GitwayErrorKind::HostKeyMismatch { .. } | GitwayErrorKind::AuthenticationFailed => 4,
206            GitwayErrorKind::Io(_) | GitwayErrorKind::Ssh(_) | GitwayErrorKind::Keys(_) => 1,
207        }
208    }
209
210    /// Returns a short diagnostic hint for structured JSON output (SFRS Rule 5).
211    #[must_use]
212    pub fn hint(&self) -> &'static str {
213        match &self.kind {
214            GitwayErrorKind::HostKeyMismatch { .. } => {
215                "Run 'gitway --test --verbose' to diagnose, \
216                 or check ~/.config/gitway/known_hosts"
217            }
218            GitwayErrorKind::AuthenticationFailed => {
219                "Ensure your SSH public key is registered with the Git hosting service, \
220                 or run 'ssh-add' to load a key into the agent"
221            }
222            GitwayErrorKind::NoKeyFound => {
223                "Run 'ssh-keygen -t ed25519' to generate a key, or use --identity to specify one"
224            }
225            GitwayErrorKind::InvalidConfig { .. } => {
226                "Run 'gitway --help' for usage information"
227            }
228            GitwayErrorKind::Io(_) | GitwayErrorKind::Ssh(_) | GitwayErrorKind::Keys(_) => {
229                "Run 'gitway --test --verbose' to diagnose the connection"
230            }
231        }
232    }
233}
234
235// ── Trait implementations ─────────────────────────────────────────────────────
236
237impl fmt::Display for GitwayError {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        write!(f, "{}", self.kind)?;
240        let bt = self.backtrace.to_string();
241        if !bt.is_empty() && bt != "disabled backtrace" {
242            write!(f, "\n\nstack backtrace:\n{bt}")?;
243        }
244        Ok(())
245    }
246}
247
248impl std::error::Error for GitwayError {
249    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
250        match &self.kind {
251            GitwayErrorKind::Io(e) => Some(e),
252            GitwayErrorKind::Ssh(e) => Some(e),
253            GitwayErrorKind::Keys(e) => Some(e),
254            _ => None,
255        }
256    }
257
258}
259
260impl From<russh::Error> for GitwayError {
261    fn from(e: russh::Error) -> Self {
262        Self::new(GitwayErrorKind::Ssh(e))
263    }
264}
265
266impl From<russh::keys::Error> for GitwayError {
267    fn from(e: russh::keys::Error) -> Self {
268        Self::new(GitwayErrorKind::Keys(e))
269    }
270}
271
272impl From<std::io::Error> for GitwayError {
273    fn from(e: std::io::Error) -> Self {
274        Self::new(GitwayErrorKind::Io(e))
275    }
276}
277
278impl From<russh::AgentAuthError> for GitwayError {
279    fn from(e: russh::AgentAuthError) -> Self {
280        match e {
281            russh::AgentAuthError::Send(_) => Self::new(GitwayErrorKind::Ssh(russh::Error::SendError)),
282            russh::AgentAuthError::Key(k) => Self::new(GitwayErrorKind::Keys(k)),
283        }
284    }
285}