1#![forbid(unsafe_code)]
4use thiserror::Error;
18
19pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
21
22#[derive(Debug, Error)]
27#[non_exhaustive]
28pub enum SshCliError {
29 #[error("i/o error: {0}")]
31 Io(#[from] std::io::Error),
32
33 #[error("json error: {0}")]
35 Json(#[from] serde_json::Error),
36
37 #[error("toml read error: {0}")]
39 TomlDe(#[from] toml::de::Error),
40
41 #[error("toml write error: {0}")]
43 TomlSer(#[from] toml::ser::Error),
44
45 #[error("{0}")]
47 Domain(#[from] crate::domain::DomainError),
48
49 #[error("ssh connection error: {0}")]
51 SshConnection(String),
52
53 #[error("ssh authentication error: {0}")]
55 SshAuthentication(String),
56
57 #[error("ssh connection failed: {0}")]
59 ConnectionFailed(String),
60
61 #[error(
63 "ssh authentication failed; try --password-stdin, --key PATH, --key-passphrase-stdin, or verify the user"
64 )]
65 AuthenticationFailed,
66
67 #[error(
69 "host key changed for {host}:{port}: expected {expected}, got {obtained} (use --replace-host-key if legitimate)"
70 )]
71 HostKeyChanged {
72 host: String,
74 port: u16,
76 expected: String,
78 obtained: String,
80 },
81
82 #[error("command exceeds max_command_chars ({max}): {len} characters")]
84 CommandTooLong {
85 max: usize,
87 len: usize,
89 },
90
91 #[error("sudo/su disabled for this host (disable_sudo)")]
93 SudoDisabled,
94
95 #[error("su_password not configured; use vps edit --su-password or --su-password-stdin")]
97 SuPasswordMissing,
98
99 #[error("ssh channel failed: {message}")]
101 ChannelFailed {
102 message: String,
104 #[source]
106 source: Option<ErrorSource>,
107 },
108
109 #[error("ssh timeout after {0}ms")]
111 SshTimeout(u64),
112
113 #[error("command failed with exit code {exit_code}: {stderr}")]
115 CommandFailed {
116 exit_code: i32,
118 stderr: String,
120 },
121
122 #[error("vps '{0}' not found in registry")]
124 VpsNotFound(String),
125
126 #[error("no active vps; run 'ssh-cli connect <NAME>' first")]
128 NoActiveVps,
129
130 #[error("vps '{0}' already exists in registry")]
132 VpsDuplicate(String),
133
134 #[error("file not found: {0}")]
136 FileNotFound(String),
137
138 #[error("invalid argument: {0}")]
140 InvalidArgument(String),
141
142 #[error("tls: {message}")]
144 Tls {
145 message: String,
147 #[source]
149 source: Option<ErrorSource>,
150 },
151
152 #[error("crypto operation failed: {op}")]
154 Crypto {
155 op: &'static str,
157 },
158
159 #[error("configuration error: {0}")]
161 Config(String),
162
163 #[error("timeout exceeded after {0}ms")]
165 Timeout(u64),
166
167 #[error("configuration directory unavailable")]
169 XdgDirectory,
170
171 #[error("incompatible schema version: expected {expected}, found {found}")]
173 SchemaIncompatible {
174 expected: u32,
176 found: u32,
178 },
179
180 #[error("error: {0}")]
182 Generic(String),
183}
184
185pub mod exit_codes {
199 pub const EX_OK: i32 = 0;
201 pub const EX_GENERAL: i32 = 1;
203 pub const EX_USAGE: i32 = 64;
205 pub const EX_DATAERR: i32 = 65;
207 pub const EX_NOINPUT: i32 = 66;
209 pub const EX_CANTCREAT: i32 = 73;
211 pub const EX_IOERR: i32 = 74;
213 pub const EX_NOPERM: i32 = 77;
215 pub const EX_SIGINT: i32 = 130;
217 pub const EX_PIPE: i32 = 141;
219 pub const EX_SIGTERM: i32 = 143;
221
222 const _: () = assert!(EX_OK == 0);
224 const _: () = assert!(EX_USAGE == 64);
225 const _: () = assert!(EX_PIPE == 141);
226 const _: () = assert!(EX_SIGINT == 130);
227 const _: () = assert!(EX_SIGTERM == 143);
228}
229
230#[must_use]
242pub fn is_broken_pipe(err: &std::io::Error) -> bool {
243 err.kind() == std::io::ErrorKind::BrokenPipe
244}
245
246#[must_use]
248pub fn anyhow_is_broken_pipe(err: &anyhow::Error) -> bool {
249 if let Some(ioe) = err.downcast_ref::<std::io::Error>() {
250 if is_broken_pipe(ioe) {
251 return true;
252 }
253 }
254 if let Some(SshCliError::Io(ioe)) = err.downcast_ref::<SshCliError>() {
255 return is_broken_pipe(ioe);
256 }
257 for cause in err.chain() {
259 if let Some(ioe) = cause.downcast_ref::<std::io::Error>() {
260 if is_broken_pipe(ioe) {
261 return true;
262 }
263 }
264 if let Some(SshCliError::Io(ioe)) = cause.downcast_ref::<SshCliError>() {
265 if is_broken_pipe(ioe) {
266 return true;
267 }
268 }
269 }
270 false
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum ErrorClass {
279 Transient,
281 Permanent,
283 Cancelled,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum ErrorLayer {
291 Network,
293 Ssh,
295 Auth,
297 Application,
299 Io,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum RetryKind {
306 NotRetryable,
308 TransientNetwork,
310 TransientTimeout,
312 TransientSsh,
314 PermanentAuth,
316 PermanentClient,
318 PermanentRemoteCommand,
320 Cancelled,
322}
323
324impl SshCliError {
325 #[must_use]
330 pub fn tls_msg(message: impl AsRef<str>) -> Self {
331 Self::Tls {
332 message: message.as_ref().to_owned(),
333 source: None,
334 }
335 }
336
337 #[must_use]
339 pub fn tls_src(
340 message: impl AsRef<str>,
341 source: impl std::error::Error + Send + Sync + 'static,
342 ) -> Self {
343 Self::Tls {
344 message: message.as_ref().to_owned(),
345 source: Some(Box::new(source)),
346 }
347 }
348
349 #[must_use]
351 pub fn channel_msg(message: impl AsRef<str>) -> Self {
352 Self::ChannelFailed {
353 message: message.as_ref().to_owned(),
354 source: None,
355 }
356 }
357
358 #[must_use]
360 pub fn channel_src(
361 message: impl AsRef<str>,
362 source: impl std::error::Error + Send + Sync + 'static,
363 ) -> Self {
364 Self::ChannelFailed {
365 message: message.as_ref().to_owned(),
366 source: Some(Box::new(source)),
367 }
368 }
369
370 #[must_use]
372 pub fn crypto(op: &'static str) -> Self {
373 Self::Crypto { op }
374 }
375
376 #[must_use]
378 pub fn error_code(&self) -> &'static str {
379 match self {
380 Self::Io(e) if is_broken_pipe(e) => "broken_pipe",
381 Self::Io(_) => "io",
382 Self::Json(_) => "json",
383 Self::TomlDe(_) => "toml_read",
384 Self::TomlSer(_) => "toml_write",
385 Self::Domain(_) => "domain_validation",
386 Self::SshConnection(_) => "ssh_connection",
387 Self::SshAuthentication(_) => "ssh_authentication",
388 Self::ConnectionFailed(_) => "connection_failed",
389 Self::AuthenticationFailed => "authentication_failed",
390 Self::HostKeyChanged { .. } => "host_key_changed",
391 Self::CommandTooLong { .. } => "command_too_long",
392 Self::SudoDisabled => "sudo_disabled",
393 Self::SuPasswordMissing => "su_password_missing",
394 Self::ChannelFailed { .. } => "channel_failed",
395 Self::SshTimeout(_) => "ssh_timeout",
396 Self::CommandFailed { .. } => "command_failed",
397 Self::VpsNotFound(_) => "vps_not_found",
398 Self::NoActiveVps => "no_active_vps",
399 Self::VpsDuplicate(_) => "vps_duplicate",
400 Self::FileNotFound(_) => "file_not_found",
401 Self::InvalidArgument(_) => "invalid_argument",
402 Self::Tls { .. } => "tls",
403 Self::Crypto { .. } => "crypto",
404 Self::Config(_) => "config",
405 Self::Timeout(_) => "timeout",
406 Self::XdgDirectory => "xdg_directory",
407 Self::SchemaIncompatible { .. } => "schema_incompatible",
408 Self::Generic(_) => "generic",
409 }
410 }
411
412 #[must_use]
414 pub fn exit_code(&self) -> i32 {
415 match self {
416 Self::Io(e) if is_broken_pipe(e) => exit_codes::EX_PIPE,
418 Self::Io(_) => exit_codes::EX_IOERR,
419 Self::Json(_) => exit_codes::EX_DATAERR,
420 Self::TomlDe(_) => exit_codes::EX_DATAERR,
421 Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
422 Self::Domain(_) => exit_codes::EX_USAGE,
423 Self::SshConnection(_) => exit_codes::EX_IOERR,
424 Self::SshAuthentication(_) => exit_codes::EX_NOPERM,
426 Self::ConnectionFailed(_) => exit_codes::EX_IOERR,
427 Self::AuthenticationFailed => exit_codes::EX_NOPERM,
428 Self::HostKeyChanged { .. } => exit_codes::EX_NOPERM,
429 Self::CommandTooLong { .. } => exit_codes::EX_USAGE,
430 Self::SudoDisabled => exit_codes::EX_NOPERM,
431 Self::SuPasswordMissing => exit_codes::EX_USAGE,
432 Self::ChannelFailed { .. } => exit_codes::EX_IOERR,
433 Self::SshTimeout(_) => exit_codes::EX_IOERR,
434 Self::CommandFailed { .. } => exit_codes::EX_GENERAL,
435 Self::VpsNotFound(_) => exit_codes::EX_NOINPUT,
436 Self::NoActiveVps => exit_codes::EX_NOINPUT,
437 Self::VpsDuplicate(_) => exit_codes::EX_USAGE,
438 Self::FileNotFound(_) => exit_codes::EX_NOINPUT,
439 Self::InvalidArgument(_) => exit_codes::EX_USAGE,
440 Self::Tls { .. } => exit_codes::EX_IOERR,
441 Self::Crypto { .. } => exit_codes::EX_NOPERM,
442 Self::Config(_) => exit_codes::EX_DATAERR,
443 Self::Timeout(_) => exit_codes::EX_IOERR,
444 Self::XdgDirectory => exit_codes::EX_CANTCREAT,
445 Self::SchemaIncompatible { .. } => exit_codes::EX_DATAERR,
446 Self::Generic(_) => exit_codes::EX_GENERAL,
447 }
448 }
449
450 #[must_use]
452 pub fn retry_kind(&self) -> RetryKind {
453 match self {
454 Self::Io(e) if is_broken_pipe(e) => RetryKind::Cancelled,
455 Self::Io(_) => RetryKind::TransientNetwork,
457 Self::Json(_) | Self::TomlDe(_) | Self::TomlSer(_) | Self::Config(_) => {
458 RetryKind::PermanentClient
459 }
460 Self::Domain(_) => RetryKind::PermanentClient,
461 Self::SshConnection(_) => RetryKind::TransientSsh,
462 Self::SshAuthentication(_) | Self::AuthenticationFailed | Self::HostKeyChanged { .. } => {
463 RetryKind::PermanentAuth
464 }
465 Self::ConnectionFailed(_) | Self::Tls { .. } => RetryKind::TransientNetwork,
466 Self::CommandTooLong { .. }
467 | Self::SudoDisabled
468 | Self::SuPasswordMissing
469 | Self::VpsNotFound(_)
470 | Self::NoActiveVps
471 | Self::VpsDuplicate(_)
472 | Self::FileNotFound(_)
473 | Self::InvalidArgument(_)
474 | Self::Crypto { .. }
475 | Self::XdgDirectory
476 | Self::SchemaIncompatible { .. }
477 | Self::Generic(_) => RetryKind::PermanentClient,
478 Self::ChannelFailed { .. } => RetryKind::TransientSsh,
479 Self::SshTimeout(_) | Self::Timeout(_) => RetryKind::TransientTimeout,
480 Self::CommandFailed { .. } => RetryKind::PermanentRemoteCommand,
481 }
482 }
483
484 #[must_use]
486 pub fn is_retryable(&self) -> bool {
487 matches!(
488 self.retry_kind(),
489 RetryKind::TransientNetwork | RetryKind::TransientTimeout | RetryKind::TransientSsh
490 )
491 }
492
493 #[must_use]
495 pub fn is_permanent(&self) -> bool {
496 !self.is_retryable() && !matches!(self.retry_kind(), RetryKind::Cancelled)
497 }
498
499 #[must_use]
501 pub fn classify(&self) -> ErrorClass {
502 match self.retry_kind() {
503 RetryKind::TransientNetwork
504 | RetryKind::TransientTimeout
505 | RetryKind::TransientSsh => ErrorClass::Transient,
506 RetryKind::Cancelled => ErrorClass::Cancelled,
507 RetryKind::NotRetryable
508 | RetryKind::PermanentAuth
509 | RetryKind::PermanentClient
510 | RetryKind::PermanentRemoteCommand => ErrorClass::Permanent,
511 }
512 }
513
514 #[must_use]
516 pub fn layer(&self) -> ErrorLayer {
517 match self {
518 Self::Io(e) if is_broken_pipe(e) => ErrorLayer::Io,
519 Self::Io(e) if io_error_is_transient_network(e) => ErrorLayer::Network,
520 Self::Io(_) => ErrorLayer::Io,
521 Self::ConnectionFailed(_) | Self::Timeout(_) | Self::Tls { .. } => ErrorLayer::Network,
522 Self::SshConnection(_) | Self::ChannelFailed { .. } | Self::SshTimeout(_) => {
523 ErrorLayer::Ssh
524 }
525 Self::SshAuthentication(_)
526 | Self::AuthenticationFailed
527 | Self::HostKeyChanged { .. }
528 | Self::SudoDisabled
529 | Self::Crypto { .. } => ErrorLayer::Auth,
530 Self::Json(_)
531 | Self::TomlDe(_)
532 | Self::TomlSer(_)
533 | Self::Domain(_)
534 | Self::CommandTooLong { .. }
535 | Self::SuPasswordMissing
536 | Self::CommandFailed { .. }
537 | Self::VpsNotFound(_)
538 | Self::NoActiveVps
539 | Self::VpsDuplicate(_)
540 | Self::FileNotFound(_)
541 | Self::InvalidArgument(_)
542 | Self::Config(_)
543 | Self::XdgDirectory
544 | Self::SchemaIncompatible { .. }
545 | Self::Generic(_) => ErrorLayer::Application,
546 }
547 }
548
549 #[must_use]
551 pub fn retry_after(&self) -> Option<std::time::Duration> {
552 let _ = self;
553 None
554 }
555
556 #[must_use]
558 pub fn suggestion(&self) -> Option<&'static str> {
559 match self.retry_kind() {
560 RetryKind::TransientNetwork | RetryKind::TransientSsh => {
561 Some("retry at most twice with exponential full-jitter backoff (exit 74)")
562 }
563 RetryKind::TransientTimeout => {
564 Some("increase --timeout / --timeout-ms, then retry at most twice with backoff")
565 }
566 RetryKind::PermanentAuth => {
567 Some("change credentials (--key / --password-stdin) or host-key policy; do not blind-retry")
568 }
569 RetryKind::PermanentRemoteCommand => {
570 Some("inspect remote stderr; fix remote command — transport retry will not help")
571 }
572 RetryKind::PermanentClient => {
573 Some("fix CLI arguments, registry state, or schema; do not retry unchanged")
574 }
575 RetryKind::Cancelled => Some("do not retry after signal or broken pipe"),
576 RetryKind::NotRetryable => None,
577 }
578 }
579}
580
581#[must_use]
583pub fn io_error_is_transient_network(err: &std::io::Error) -> bool {
584 use std::io::ErrorKind;
585 matches!(
586 err.kind(),
587 ErrorKind::ConnectionRefused
588 | ErrorKind::ConnectionReset
589 | ErrorKind::ConnectionAborted
590 | ErrorKind::NotConnected
591 | ErrorKind::AddrNotAvailable
592 | ErrorKind::TimedOut
593 | ErrorKind::Interrupted
594 | ErrorKind::WouldBlock
595 | ErrorKind::UnexpectedEof
596 | ErrorKind::NetworkUnreachable
597 | ErrorKind::HostUnreachable
598 )
599}
600
601pub type SshCliResult<T> = std::result::Result<T, SshCliError>;
603
604
605#[cfg(test)]
606#[path = "errors_tests.rs"]
607mod tests;