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}