#![forbid(unsafe_code)]
use thiserror::Error;
pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SshCliError {
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("toml read error: {0}")]
TomlDe(#[from] toml::de::Error),
#[error("toml write error: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("{0}")]
Domain(#[from] crate::domain::DomainError),
#[error("ssh connection error: {0}")]
SshConnection(String),
#[error("ssh authentication error: {0}")]
SshAuthentication(String),
#[error("ssh connection failed: {0}")]
ConnectionFailed(String),
#[error(
"ssh authentication failed; try --password-stdin, --key PATH, --key-passphrase-stdin, or verify the user"
)]
AuthenticationFailed,
#[error(
"host key changed for {host}:{port}: expected {expected}, got {obtained} (use --replace-host-key if legitimate)"
)]
HostKeyChanged {
host: String,
port: u16,
expected: String,
obtained: String,
},
#[error("command exceeds max_command_chars ({max}): {len} characters")]
CommandTooLong {
max: usize,
len: usize,
},
#[error("sudo/su disabled for this host (disable_sudo)")]
SudoDisabled,
#[error("su_password not configured; use vps edit --su-password or --su-password-stdin")]
SuPasswordMissing,
#[error("ssh channel failed: {message}")]
ChannelFailed {
message: String,
#[source]
source: Option<ErrorSource>,
},
#[error("ssh timeout after {0}ms")]
SshTimeout(u64),
#[error("command failed with exit code {exit_code}: {stderr}")]
CommandFailed {
exit_code: i32,
stderr: String,
},
#[error("vps '{0}' not found in registry")]
VpsNotFound(String),
#[error("no active vps; run 'ssh-cli connect <NAME>' first")]
NoActiveVps,
#[error("vps '{0}' already exists in registry")]
VpsDuplicate(String),
#[error("file not found: {0}")]
FileNotFound(String),
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("tls: {message}")]
Tls {
message: String,
#[source]
source: Option<ErrorSource>,
},
#[error("crypto operation failed: {op}")]
Crypto {
op: &'static str,
},
#[error("configuration error: {0}")]
Config(String),
#[error("timeout exceeded after {0}ms")]
Timeout(u64),
#[error("configuration directory unavailable")]
XdgDirectory,
#[error("incompatible schema version: expected {expected}, found {found}")]
SchemaIncompatible {
expected: u32,
found: u32,
},
#[error("error: {0}")]
Generic(String),
}
pub mod exit_codes {
pub const EX_OK: i32 = 0;
pub const EX_GENERAL: i32 = 1;
pub const EX_USAGE: i32 = 64;
pub const EX_DATAERR: i32 = 65;
pub const EX_NOINPUT: i32 = 66;
pub const EX_CANTCREAT: i32 = 73;
pub const EX_IOERR: i32 = 74;
pub const EX_NOPERM: i32 = 77;
pub const EX_SIGINT: i32 = 130;
pub const EX_PIPE: i32 = 141;
pub const EX_SIGTERM: i32 = 143;
const _: () = assert!(EX_OK == 0);
const _: () = assert!(EX_USAGE == 64);
const _: () = assert!(EX_PIPE == 141);
const _: () = assert!(EX_SIGINT == 130);
const _: () = assert!(EX_SIGTERM == 143);
}
#[must_use]
pub fn is_broken_pipe(err: &std::io::Error) -> bool {
err.kind() == std::io::ErrorKind::BrokenPipe
}
#[must_use]
pub fn anyhow_is_broken_pipe(err: &anyhow::Error) -> bool {
if let Some(ioe) = err.downcast_ref::<std::io::Error>() {
if is_broken_pipe(ioe) {
return true;
}
}
if let Some(SshCliError::Io(ioe)) = err.downcast_ref::<SshCliError>() {
return is_broken_pipe(ioe);
}
for cause in err.chain() {
if let Some(ioe) = cause.downcast_ref::<std::io::Error>() {
if is_broken_pipe(ioe) {
return true;
}
}
if let Some(SshCliError::Io(ioe)) = cause.downcast_ref::<SshCliError>() {
if is_broken_pipe(ioe) {
return true;
}
}
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorClass {
Transient,
Permanent,
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorLayer {
Network,
Ssh,
Auth,
Application,
Io,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryKind {
NotRetryable,
TransientNetwork,
TransientTimeout,
TransientSsh,
PermanentAuth,
PermanentClient,
PermanentRemoteCommand,
Cancelled,
}
impl SshCliError {
#[must_use]
pub fn tls_msg(message: impl AsRef<str>) -> Self {
Self::Tls {
message: message.as_ref().to_owned(),
source: None,
}
}
#[must_use]
pub fn tls_src(
message: impl AsRef<str>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Tls {
message: message.as_ref().to_owned(),
source: Some(Box::new(source)),
}
}
#[must_use]
pub fn channel_msg(message: impl AsRef<str>) -> Self {
Self::ChannelFailed {
message: message.as_ref().to_owned(),
source: None,
}
}
#[must_use]
pub fn channel_src(
message: impl AsRef<str>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::ChannelFailed {
message: message.as_ref().to_owned(),
source: Some(Box::new(source)),
}
}
#[must_use]
pub fn crypto(op: &'static str) -> Self {
Self::Crypto { op }
}
#[must_use]
pub fn error_code(&self) -> &'static str {
match self {
Self::Io(e) if is_broken_pipe(e) => "broken_pipe",
Self::Io(_) => "io",
Self::Json(_) => "json",
Self::TomlDe(_) => "toml_read",
Self::TomlSer(_) => "toml_write",
Self::Domain(_) => "domain_validation",
Self::SshConnection(_) => "ssh_connection",
Self::SshAuthentication(_) => "ssh_authentication",
Self::ConnectionFailed(_) => "connection_failed",
Self::AuthenticationFailed => "authentication_failed",
Self::HostKeyChanged { .. } => "host_key_changed",
Self::CommandTooLong { .. } => "command_too_long",
Self::SudoDisabled => "sudo_disabled",
Self::SuPasswordMissing => "su_password_missing",
Self::ChannelFailed { .. } => "channel_failed",
Self::SshTimeout(_) => "ssh_timeout",
Self::CommandFailed { .. } => "command_failed",
Self::VpsNotFound(_) => "vps_not_found",
Self::NoActiveVps => "no_active_vps",
Self::VpsDuplicate(_) => "vps_duplicate",
Self::FileNotFound(_) => "file_not_found",
Self::InvalidArgument(_) => "invalid_argument",
Self::Tls { .. } => "tls",
Self::Crypto { .. } => "crypto",
Self::Config(_) => "config",
Self::Timeout(_) => "timeout",
Self::XdgDirectory => "xdg_directory",
Self::SchemaIncompatible { .. } => "schema_incompatible",
Self::Generic(_) => "generic",
}
}
#[must_use]
pub fn exit_code(&self) -> i32 {
match self {
Self::Io(e) if is_broken_pipe(e) => exit_codes::EX_PIPE,
Self::Io(_) => exit_codes::EX_IOERR,
Self::Json(_) => exit_codes::EX_DATAERR,
Self::TomlDe(_) => exit_codes::EX_DATAERR,
Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
Self::Domain(_) => exit_codes::EX_USAGE,
Self::SshConnection(_) => exit_codes::EX_IOERR,
Self::SshAuthentication(_) => exit_codes::EX_NOPERM,
Self::ConnectionFailed(_) => exit_codes::EX_IOERR,
Self::AuthenticationFailed => exit_codes::EX_NOPERM,
Self::HostKeyChanged { .. } => exit_codes::EX_NOPERM,
Self::CommandTooLong { .. } => exit_codes::EX_USAGE,
Self::SudoDisabled => exit_codes::EX_NOPERM,
Self::SuPasswordMissing => exit_codes::EX_USAGE,
Self::ChannelFailed { .. } => exit_codes::EX_IOERR,
Self::SshTimeout(_) => exit_codes::EX_IOERR,
Self::CommandFailed { .. } => exit_codes::EX_GENERAL,
Self::VpsNotFound(_) => exit_codes::EX_NOINPUT,
Self::NoActiveVps => exit_codes::EX_NOINPUT,
Self::VpsDuplicate(_) => exit_codes::EX_USAGE,
Self::FileNotFound(_) => exit_codes::EX_NOINPUT,
Self::InvalidArgument(_) => exit_codes::EX_USAGE,
Self::Tls { .. } => exit_codes::EX_IOERR,
Self::Crypto { .. } => exit_codes::EX_NOPERM,
Self::Config(_) => exit_codes::EX_DATAERR,
Self::Timeout(_) => exit_codes::EX_IOERR,
Self::XdgDirectory => exit_codes::EX_CANTCREAT,
Self::SchemaIncompatible { .. } => exit_codes::EX_DATAERR,
Self::Generic(_) => exit_codes::EX_GENERAL,
}
}
#[must_use]
pub fn retry_kind(&self) -> RetryKind {
match self {
Self::Io(e) if is_broken_pipe(e) => RetryKind::Cancelled,
Self::Io(_) => RetryKind::TransientNetwork,
Self::Json(_) | Self::TomlDe(_) | Self::TomlSer(_) | Self::Config(_) => {
RetryKind::PermanentClient
}
Self::Domain(_) => RetryKind::PermanentClient,
Self::SshConnection(_) => RetryKind::TransientSsh,
Self::SshAuthentication(_) | Self::AuthenticationFailed | Self::HostKeyChanged { .. } => {
RetryKind::PermanentAuth
}
Self::ConnectionFailed(_) | Self::Tls { .. } => RetryKind::TransientNetwork,
Self::CommandTooLong { .. }
| Self::SudoDisabled
| Self::SuPasswordMissing
| Self::VpsNotFound(_)
| Self::NoActiveVps
| Self::VpsDuplicate(_)
| Self::FileNotFound(_)
| Self::InvalidArgument(_)
| Self::Crypto { .. }
| Self::XdgDirectory
| Self::SchemaIncompatible { .. }
| Self::Generic(_) => RetryKind::PermanentClient,
Self::ChannelFailed { .. } => RetryKind::TransientSsh,
Self::SshTimeout(_) | Self::Timeout(_) => RetryKind::TransientTimeout,
Self::CommandFailed { .. } => RetryKind::PermanentRemoteCommand,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(
self.retry_kind(),
RetryKind::TransientNetwork | RetryKind::TransientTimeout | RetryKind::TransientSsh
)
}
#[must_use]
pub fn is_permanent(&self) -> bool {
!self.is_retryable() && !matches!(self.retry_kind(), RetryKind::Cancelled)
}
#[must_use]
pub fn classify(&self) -> ErrorClass {
match self.retry_kind() {
RetryKind::TransientNetwork
| RetryKind::TransientTimeout
| RetryKind::TransientSsh => ErrorClass::Transient,
RetryKind::Cancelled => ErrorClass::Cancelled,
RetryKind::NotRetryable
| RetryKind::PermanentAuth
| RetryKind::PermanentClient
| RetryKind::PermanentRemoteCommand => ErrorClass::Permanent,
}
}
#[must_use]
pub fn layer(&self) -> ErrorLayer {
match self {
Self::Io(e) if is_broken_pipe(e) => ErrorLayer::Io,
Self::Io(e) if io_error_is_transient_network(e) => ErrorLayer::Network,
Self::Io(_) => ErrorLayer::Io,
Self::ConnectionFailed(_) | Self::Timeout(_) | Self::Tls { .. } => ErrorLayer::Network,
Self::SshConnection(_) | Self::ChannelFailed { .. } | Self::SshTimeout(_) => {
ErrorLayer::Ssh
}
Self::SshAuthentication(_)
| Self::AuthenticationFailed
| Self::HostKeyChanged { .. }
| Self::SudoDisabled
| Self::Crypto { .. } => ErrorLayer::Auth,
Self::Json(_)
| Self::TomlDe(_)
| Self::TomlSer(_)
| Self::Domain(_)
| Self::CommandTooLong { .. }
| Self::SuPasswordMissing
| Self::CommandFailed { .. }
| Self::VpsNotFound(_)
| Self::NoActiveVps
| Self::VpsDuplicate(_)
| Self::FileNotFound(_)
| Self::InvalidArgument(_)
| Self::Config(_)
| Self::XdgDirectory
| Self::SchemaIncompatible { .. }
| Self::Generic(_) => ErrorLayer::Application,
}
}
#[must_use]
pub fn retry_after(&self) -> Option<std::time::Duration> {
let _ = self;
None
}
#[must_use]
pub fn suggestion(&self) -> Option<&'static str> {
match self.retry_kind() {
RetryKind::TransientNetwork | RetryKind::TransientSsh => {
Some("retry at most twice with exponential full-jitter backoff (exit 74)")
}
RetryKind::TransientTimeout => {
Some("increase --timeout / --timeout-ms, then retry at most twice with backoff")
}
RetryKind::PermanentAuth => {
Some("change credentials (--key / --password-stdin) or host-key policy; do not blind-retry")
}
RetryKind::PermanentRemoteCommand => {
Some("inspect remote stderr; fix remote command — transport retry will not help")
}
RetryKind::PermanentClient => {
Some("fix CLI arguments, registry state, or schema; do not retry unchanged")
}
RetryKind::Cancelled => Some("do not retry after signal or broken pipe"),
RetryKind::NotRetryable => None,
}
}
}
#[must_use]
pub fn io_error_is_transient_network(err: &std::io::Error) -> bool {
use std::io::ErrorKind;
matches!(
err.kind(),
ErrorKind::ConnectionRefused
| ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::NotConnected
| ErrorKind::AddrNotAvailable
| ErrorKind::TimedOut
| ErrorKind::Interrupted
| ErrorKind::WouldBlock
| ErrorKind::UnexpectedEof
| ErrorKind::NetworkUnreachable
| ErrorKind::HostUnreachable
)
}
pub type SshCliResult<T> = std::result::Result<T, SshCliError>;
#[cfg(test)]
#[path = "errors_tests.rs"]
mod tests;