use std::backtrace::Backtrace;
use std::fmt;
#[derive(Debug)]
pub(crate) enum AnvilErrorKind {
Io(std::io::Error),
Ssh(russh::Error),
Keys(russh::keys::Error),
HostKeyMismatch { fingerprint: String },
AuthenticationFailed,
NoKeyFound,
InvalidConfig { message: String },
Signing { message: String },
SignatureInvalid { reason: String },
}
impl fmt::Display for AnvilErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Ssh(e) => write!(f, "SSH protocol error: {e}"),
Self::Keys(e) => write!(f, "SSH key error: {e}"),
Self::HostKeyMismatch { fingerprint } => {
write!(
f,
"host key mismatch — received fingerprint {fingerprint} \
does not match any pinned fingerprint"
)
}
Self::AuthenticationFailed => write!(f, "public-key authentication failed"),
Self::NoKeyFound => {
write!(f, "no SSH identity key found on any search path or agent")
}
Self::InvalidConfig { message } => write!(f, "invalid configuration: {message}"),
Self::Signing { message } => write!(f, "SSH signing failed: {message}"),
Self::SignatureInvalid { reason } => {
write!(f, "SSH signature verification failed: {reason}")
}
}
}
}
#[derive(Debug)]
pub struct AnvilError {
kind: AnvilErrorKind,
custom_hint: Option<String>,
backtrace: Backtrace,
}
impl AnvilError {
pub(crate) fn new(kind: AnvilErrorKind) -> Self {
Self {
kind,
custom_hint: None,
backtrace: Backtrace::capture(),
}
}
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.custom_hint = Some(hint.into());
self
}
pub fn host_key_mismatch(fingerprint: impl Into<String>) -> Self {
Self::new(AnvilErrorKind::HostKeyMismatch {
fingerprint: fingerprint.into(),
})
}
#[must_use]
pub fn authentication_failed() -> Self {
Self::new(AnvilErrorKind::AuthenticationFailed)
}
#[must_use]
pub fn no_key_found() -> Self {
Self::new(AnvilErrorKind::NoKeyFound)
}
pub fn invalid_config(message: impl Into<String>) -> Self {
Self::new(AnvilErrorKind::InvalidConfig {
message: message.into(),
})
}
pub fn signing(message: impl Into<String>) -> Self {
Self::new(AnvilErrorKind::Signing {
message: message.into(),
})
}
pub fn signature_invalid(reason: impl Into<String>) -> Self {
Self::new(AnvilErrorKind::SignatureInvalid {
reason: reason.into(),
})
}
#[must_use]
pub fn is_io(&self) -> bool {
matches!(self.kind, AnvilErrorKind::Io(_))
}
#[must_use]
pub fn io_kind(&self) -> Option<std::io::ErrorKind> {
match &self.kind {
AnvilErrorKind::Io(e) => Some(e.kind()),
_ => None,
}
}
#[must_use]
pub fn is_transient(&self) -> bool {
matches!(
crate::retry::classify(self),
crate::retry::Disposition::Retry,
)
}
#[must_use]
pub fn is_host_key_mismatch(&self) -> bool {
matches!(self.kind, AnvilErrorKind::HostKeyMismatch { .. })
}
#[must_use]
pub fn is_authentication_failed(&self) -> bool {
matches!(self.kind, AnvilErrorKind::AuthenticationFailed)
}
#[must_use]
pub fn is_no_key_found(&self) -> bool {
matches!(self.kind, AnvilErrorKind::NoKeyFound)
}
#[must_use]
pub fn is_key_encrypted(&self) -> bool {
matches!(
self.kind,
AnvilErrorKind::Keys(russh::keys::Error::KeyIsEncrypted)
)
}
#[must_use]
pub fn fingerprint(&self) -> Option<&str> {
match &self.kind {
AnvilErrorKind::HostKeyMismatch { fingerprint } => Some(fingerprint),
_ => None,
}
}
#[must_use]
pub fn error_code(&self) -> &'static str {
match &self.kind {
AnvilErrorKind::InvalidConfig { .. } => "USAGE_ERROR",
AnvilErrorKind::NoKeyFound => "NOT_FOUND",
AnvilErrorKind::HostKeyMismatch { .. }
| AnvilErrorKind::AuthenticationFailed
| AnvilErrorKind::SignatureInvalid { .. } => "PERMISSION_DENIED",
AnvilErrorKind::Io(_)
| AnvilErrorKind::Ssh(_)
| AnvilErrorKind::Keys(_)
| AnvilErrorKind::Signing { .. } => "GENERAL_ERROR",
}
}
#[must_use]
pub fn exit_code(&self) -> u32 {
match &self.kind {
AnvilErrorKind::InvalidConfig { .. } => 2,
AnvilErrorKind::NoKeyFound => 3,
AnvilErrorKind::HostKeyMismatch { .. }
| AnvilErrorKind::AuthenticationFailed
| AnvilErrorKind::SignatureInvalid { .. } => 4,
AnvilErrorKind::Io(_)
| AnvilErrorKind::Ssh(_)
| AnvilErrorKind::Keys(_)
| AnvilErrorKind::Signing { .. } => 1,
}
}
#[must_use]
pub fn hint(&self) -> &str {
if let Some(h) = self.custom_hint.as_deref() {
return h;
}
match &self.kind {
AnvilErrorKind::HostKeyMismatch { .. } => {
"The server's SSH fingerprint doesn't match what gitway trusts. \
This is either a routine key rotation by the provider or a \
possible man-in-the-middle attack. Compare the received \
fingerprint against the provider's official list; if you \
trust it, add it to ~/.config/gitway/known_hosts."
}
AnvilErrorKind::AuthenticationFailed => {
"The server rejected your SSH key. Two things to check: the \
public key is registered in the provider's account settings, \
and the private key is loaded (run `gitway-add ~/.ssh/id_ed25519`)."
}
AnvilErrorKind::NoKeyFound => {
"No SSH key was found. Generate one with `gitway keygen ed25519 \
--out ~/.ssh/id_ed25519`, or point gitway at an existing key \
via `--identity <path>`."
}
AnvilErrorKind::InvalidConfig { .. } => {
"Something in your command or config is off. Run `gitway --help` \
to see accepted flags, or re-read the error message above — \
it usually names the exact argument to fix."
}
AnvilErrorKind::Signing { .. } => {
"Signing the commit failed. If the key is encrypted, either \
load it into the agent (`gitway-add <key>`) so signing can \
use it without a passphrase, or set SSH_ASKPASS to a GUI \
helper so you can type the passphrase in a dialog."
}
AnvilErrorKind::SignatureInvalid { .. } => {
"The signature doesn't match. Either the signed data was \
changed after signing, a different key produced it, or the \
namespace (usually `git`) is different."
}
AnvilErrorKind::Io(_) | AnvilErrorKind::Ssh(_) | AnvilErrorKind::Keys(_) => {
"Something broke before the SSH session was fully set up. \
Run `gitway --test --verbose <host>` to see where it fails."
}
}
}
}
impl fmt::Display for AnvilError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.kind)?;
let bt = self.backtrace.to_string();
if !bt.is_empty() && bt != "disabled backtrace" {
write!(f, "\n\nstack backtrace:\n{bt}")?;
}
Ok(())
}
}
impl std::error::Error for AnvilError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.kind {
AnvilErrorKind::Io(e) => Some(e),
AnvilErrorKind::Ssh(e) => Some(e),
AnvilErrorKind::Keys(e) => Some(e),
_ => None,
}
}
}
impl From<russh::Error> for AnvilError {
fn from(e: russh::Error) -> Self {
Self::new(AnvilErrorKind::Ssh(e))
}
}
impl From<russh::keys::Error> for AnvilError {
fn from(e: russh::keys::Error) -> Self {
Self::new(AnvilErrorKind::Keys(e))
}
}
impl From<std::io::Error> for AnvilError {
fn from(e: std::io::Error) -> Self {
Self::new(AnvilErrorKind::Io(e))
}
}
impl From<russh::AgentAuthError> for AnvilError {
fn from(e: russh::AgentAuthError) -> Self {
match e {
russh::AgentAuthError::Send(_) => {
Self::new(AnvilErrorKind::Ssh(russh::Error::SendError))
}
russh::AgentAuthError::Key(k) => Self::new(AnvilErrorKind::Keys(k)),
}
}
}