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}