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    backtrace: Backtrace,
99}
100
101impl GitwayError {
102    /// Constructs a new [`GitwayError`] capturing the current backtrace.
103    pub(crate) fn new(kind: GitwayErrorKind) -> Self {
104        Self {
105            kind,
106            backtrace: Backtrace::capture(),
107        }
108    }
109
110    // ── Constructors for common variants ─────────────────────────────────────
111
112    pub fn host_key_mismatch(fingerprint: impl Into<String>) -> Self {
113        Self::new(GitwayErrorKind::HostKeyMismatch {
114            fingerprint: fingerprint.into(),
115        })
116    }
117
118    #[must_use]
119    pub fn authentication_failed() -> Self {
120        Self::new(GitwayErrorKind::AuthenticationFailed)
121    }
122
123    #[must_use]
124    pub fn no_key_found() -> Self {
125        Self::new(GitwayErrorKind::NoKeyFound)
126    }
127
128    pub fn invalid_config(message: impl Into<String>) -> Self {
129        Self::new(GitwayErrorKind::InvalidConfig {
130            message: message.into(),
131        })
132    }
133
134    /// Signals that SSH signature production failed.
135    ///
136    /// Mapped to exit code 1 (`GENERAL_ERROR`).
137    pub fn signing(message: impl Into<String>) -> Self {
138        Self::new(GitwayErrorKind::Signing {
139            message: message.into(),
140        })
141    }
142
143    /// Signals that SSH signature verification failed.
144    ///
145    /// Mapped to exit code 4 (`PERMISSION_DENIED`) to match git's treatment
146    /// of a non-zero `ssh-keygen -Y verify` as an authentication-class failure.
147    pub fn signature_invalid(reason: impl Into<String>) -> Self {
148        Self::new(GitwayErrorKind::SignatureInvalid {
149            reason: reason.into(),
150        })
151    }
152
153    // ── Predicates ────────────────────────────────────────────────────────────
154
155    /// Returns `true` if this error originated from an I/O failure.
156    #[must_use]
157    pub fn is_io(&self) -> bool {
158        matches!(self.kind, GitwayErrorKind::Io(_))
159    }
160
161    /// Returns `true` if the server's host key did not match any pinned fingerprint.
162    #[must_use]
163    pub fn is_host_key_mismatch(&self) -> bool {
164        matches!(self.kind, GitwayErrorKind::HostKeyMismatch { .. })
165    }
166
167    /// Returns `true` if the server rejected our public-key authentication attempt.
168    #[must_use]
169    pub fn is_authentication_failed(&self) -> bool {
170        matches!(self.kind, GitwayErrorKind::AuthenticationFailed)
171    }
172
173    /// Returns `true` if no usable identity key was found.
174    #[must_use]
175    pub fn is_no_key_found(&self) -> bool {
176        matches!(self.kind, GitwayErrorKind::NoKeyFound)
177    }
178
179    /// Returns `true` if a key file was found but requires a passphrase to decrypt.
180    #[must_use]
181    pub fn is_key_encrypted(&self) -> bool {
182        matches!(
183            self.kind,
184            GitwayErrorKind::Keys(russh::keys::Error::KeyIsEncrypted)
185        )
186    }
187
188    /// Returns the path at which an encrypted key was found, if applicable.
189    #[must_use]
190    pub fn fingerprint(&self) -> Option<&str> {
191        match &self.kind {
192            GitwayErrorKind::HostKeyMismatch { fingerprint } => Some(fingerprint),
193            _ => None,
194        }
195    }
196
197    /// Returns an upper-snake-case error code for structured JSON output (SFRS Rule 5).
198    ///
199    /// | Code | Exit code | Condition |
200    /// |------|-----------|-----------|
201    /// | `GENERAL_ERROR` | 1 | I/O, SSH protocol, or key-parsing failure |
202    /// | `USAGE_ERROR` | 2 | Invalid configuration or bad arguments |
203    /// | `NOT_FOUND` | 3 | No identity key found |
204    /// | `PERMISSION_DENIED` | 4 | Host key mismatch or authentication failure |
205    #[must_use]
206    pub fn error_code(&self) -> &'static str {
207        match &self.kind {
208            GitwayErrorKind::InvalidConfig { .. } => "USAGE_ERROR",
209            GitwayErrorKind::NoKeyFound => "NOT_FOUND",
210            GitwayErrorKind::HostKeyMismatch { .. }
211            | GitwayErrorKind::AuthenticationFailed
212            | GitwayErrorKind::SignatureInvalid { .. } => "PERMISSION_DENIED",
213            GitwayErrorKind::Io(_)
214            | GitwayErrorKind::Ssh(_)
215            | GitwayErrorKind::Keys(_)
216            | GitwayErrorKind::Signing { .. } => "GENERAL_ERROR",
217        }
218    }
219
220    /// Returns the numeric process exit code for this error (SFRS Rule 2).
221    ///
222    /// | Code | Meaning |
223    /// |------|---------|
224    /// | 1 | General / unexpected error |
225    /// | 2 | Usage error (bad arguments, invalid configuration) |
226    /// | 3 | Not found (no identity key, unknown host) |
227    /// | 4 | Permission denied (authentication failure, host key mismatch) |
228    #[must_use]
229    pub fn exit_code(&self) -> u32 {
230        match &self.kind {
231            GitwayErrorKind::InvalidConfig { .. } => 2,
232            GitwayErrorKind::NoKeyFound => 3,
233            GitwayErrorKind::HostKeyMismatch { .. }
234            | GitwayErrorKind::AuthenticationFailed
235            | GitwayErrorKind::SignatureInvalid { .. } => 4,
236            GitwayErrorKind::Io(_)
237            | GitwayErrorKind::Ssh(_)
238            | GitwayErrorKind::Keys(_)
239            | GitwayErrorKind::Signing { .. } => 1,
240        }
241    }
242
243    /// Returns a short diagnostic hint for structured JSON output (SFRS Rule 5).
244    #[must_use]
245    pub fn hint(&self) -> &'static str {
246        match &self.kind {
247            GitwayErrorKind::HostKeyMismatch { .. } => {
248                "Run 'gitway --test --verbose' to diagnose, \
249                 or check ~/.config/gitway/known_hosts"
250            }
251            GitwayErrorKind::AuthenticationFailed => {
252                "Ensure your SSH public key is registered with the Git hosting service, \
253                 or run 'ssh-add' to load a key into the agent"
254            }
255            GitwayErrorKind::NoKeyFound => {
256                "Run 'ssh-keygen -t ed25519' to generate a key, or use --identity to specify one"
257            }
258            GitwayErrorKind::InvalidConfig { .. } => "Run 'gitway --help' for usage information",
259            GitwayErrorKind::Signing { .. } => {
260                "Ensure the private key is readable and the passphrase is correct; \
261                 run with --verbose to see the underlying cryptographic error"
262            }
263            GitwayErrorKind::SignatureInvalid { .. } => {
264                "The signature is invalid, was signed with a different key, \
265                 or uses a different namespace than expected"
266            }
267            GitwayErrorKind::Io(_) | GitwayErrorKind::Ssh(_) | GitwayErrorKind::Keys(_) => {
268                "Run 'gitway --test --verbose' to diagnose the connection"
269            }
270        }
271    }
272}
273
274// ── Trait implementations ─────────────────────────────────────────────────────
275
276impl fmt::Display for GitwayError {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        write!(f, "{}", self.kind)?;
279        let bt = self.backtrace.to_string();
280        if !bt.is_empty() && bt != "disabled backtrace" {
281            write!(f, "\n\nstack backtrace:\n{bt}")?;
282        }
283        Ok(())
284    }
285}
286
287impl std::error::Error for GitwayError {
288    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
289        match &self.kind {
290            GitwayErrorKind::Io(e) => Some(e),
291            GitwayErrorKind::Ssh(e) => Some(e),
292            GitwayErrorKind::Keys(e) => Some(e),
293            _ => None,
294        }
295    }
296}
297
298impl From<russh::Error> for GitwayError {
299    fn from(e: russh::Error) -> Self {
300        Self::new(GitwayErrorKind::Ssh(e))
301    }
302}
303
304impl From<russh::keys::Error> for GitwayError {
305    fn from(e: russh::keys::Error) -> Self {
306        Self::new(GitwayErrorKind::Keys(e))
307    }
308}
309
310impl From<std::io::Error> for GitwayError {
311    fn from(e: std::io::Error) -> Self {
312        Self::new(GitwayErrorKind::Io(e))
313    }
314}
315
316impl From<russh::AgentAuthError> for GitwayError {
317    fn from(e: russh::AgentAuthError) -> Self {
318        match e {
319            russh::AgentAuthError::Send(_) => {
320                Self::new(GitwayErrorKind::Ssh(russh::Error::SendError))
321            }
322            russh::AgentAuthError::Key(k) => Self::new(GitwayErrorKind::Keys(k)),
323        }
324    }
325}