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("service unavailable: {service}")]
170 Unavailable {
171 service: &'static str,
173 },
174
175 #[error("internal failure: {op}")]
181 Software {
182 op: &'static str,
184 },
185
186 #[error("timeout exceeded after {0}ms")]
188 Timeout(u64),
189
190 #[error("configuration directory unavailable")]
192 XdgDirectory,
193
194 #[error("incompatible schema version: expected {expected}, found {found}")]
196 SchemaIncompatible {
197 expected: u32,
199 found: u32,
201 },
202
203 #[error("{failed}/{total} hosts failed during {op}")]
211 PartialFailure {
212 failed: usize,
214 total: usize,
216 op: &'static str,
218 },
219
220 #[error("error: {0}")]
222 Generic(String),
223}
224
225pub mod exit_codes {
239 pub const EX_OK: i32 = 0;
241 pub const EX_GENERAL: i32 = 1;
243 pub const EX_USAGE: i32 = 64;
245 pub const EX_DATAERR: i32 = 65;
247 pub const EX_NOINPUT: i32 = 66;
249 pub const EX_UNAVAILABLE: i32 = 69;
256 pub const EX_SOFTWARE: i32 = 70;
261 pub const EX_CANTCREAT: i32 = 73;
263 pub const EX_IOERR: i32 = 74;
265 pub const EX_NOPERM: i32 = 77;
267 pub const EX_SIGINT: i32 = 130;
269 pub const EX_PIPE: i32 = 141;
271 pub const EX_SIGTERM: i32 = 143;
273
274 const _: () = assert!(EX_OK == 0);
276 const _: () = assert!(EX_USAGE == 64);
277 const _: () = assert!(EX_UNAVAILABLE == 69);
278 const _: () = assert!(EX_SOFTWARE == 70);
279 const _: () = assert!(EX_PIPE == 141);
280 const _: () = assert!(EX_SIGINT == 130);
281 const _: () = assert!(EX_SIGTERM == 143);
282}
283
284#[must_use]
296pub fn is_broken_pipe(err: &std::io::Error) -> bool {
297 err.kind() == std::io::ErrorKind::BrokenPipe
298}
299
300#[must_use]
302pub fn anyhow_is_broken_pipe(err: &anyhow::Error) -> bool {
303 if let Some(ioe) = err.downcast_ref::<std::io::Error>() {
304 if is_broken_pipe(ioe) {
305 return true;
306 }
307 }
308 if let Some(SshCliError::Io(ioe)) = err.downcast_ref::<SshCliError>() {
309 return is_broken_pipe(ioe);
310 }
311 for cause in err.chain() {
313 if let Some(ioe) = cause.downcast_ref::<std::io::Error>() {
314 if is_broken_pipe(ioe) {
315 return true;
316 }
317 }
318 if let Some(SshCliError::Io(ioe)) = cause.downcast_ref::<SshCliError>() {
319 if is_broken_pipe(ioe) {
320 return true;
321 }
322 }
323 }
324 false
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum ErrorClass {
333 Transient,
335 Permanent,
337 Cancelled,
339 Partial,
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ErrorLayer {
347 Network,
349 Ssh,
351 Auth,
353 Application,
355 Io,
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum RetryKind {
362 NotRetryable,
364 TransientNetwork,
366 TransientTimeout,
368 TransientSsh,
370 PermanentAuth,
372 PermanentClient,
374 PermanentRemoteCommand,
376 Cancelled,
378}
379
380impl SshCliError {
381 #[must_use]
386 pub fn tls_msg(message: impl AsRef<str>) -> Self {
387 Self::Tls {
388 message: message.as_ref().to_owned(),
389 source: None,
390 }
391 }
392
393 #[must_use]
395 pub fn tls_src(
396 message: impl AsRef<str>,
397 source: impl std::error::Error + Send + Sync + 'static,
398 ) -> Self {
399 Self::Tls {
400 message: message.as_ref().to_owned(),
401 source: Some(Box::new(source)),
402 }
403 }
404
405 #[must_use]
407 pub fn channel_msg(message: impl AsRef<str>) -> Self {
408 Self::ChannelFailed {
409 message: message.as_ref().to_owned(),
410 source: None,
411 }
412 }
413
414 #[must_use]
416 pub fn channel_src(
417 message: impl AsRef<str>,
418 source: impl std::error::Error + Send + Sync + 'static,
419 ) -> Self {
420 Self::ChannelFailed {
421 message: message.as_ref().to_owned(),
422 source: Some(Box::new(source)),
423 }
424 }
425
426 #[must_use]
428 pub fn crypto(op: &'static str) -> Self {
429 Self::Crypto { op }
430 }
431
432 #[must_use]
434 pub fn unavailable(service: &'static str) -> Self {
435 Self::Unavailable { service }
436 }
437
438 #[must_use]
440 pub fn software(op: &'static str) -> Self {
441 Self::Software { op }
442 }
443
444 #[must_use]
446 pub fn error_code(&self) -> &'static str {
447 match self {
448 Self::Io(e) if is_broken_pipe(e) => "broken_pipe",
449 Self::Io(_) => "io",
450 Self::Json(_) => "json",
451 Self::TomlDe(_) => "toml_read",
452 Self::TomlSer(_) => "toml_write",
453 Self::Domain(_) => "domain_validation",
454 Self::SshConnection(_) => "ssh_connection",
455 Self::SshAuthentication(_) => "ssh_authentication",
456 Self::ConnectionFailed(_) => "connection_failed",
457 Self::AuthenticationFailed => "authentication_failed",
458 Self::HostKeyChanged { .. } => "host_key_changed",
459 Self::CommandTooLong { .. } => "command_too_long",
460 Self::SudoDisabled => "sudo_disabled",
461 Self::SuPasswordMissing => "su_password_missing",
462 Self::ChannelFailed { .. } => "channel_failed",
463 Self::SshTimeout(_) => "ssh_timeout",
464 Self::CommandFailed { .. } => "command_failed",
465 Self::VpsNotFound(_) => "vps_not_found",
466 Self::NoActiveVps => "no_active_vps",
467 Self::VpsDuplicate(_) => "vps_duplicate",
468 Self::FileNotFound(_) => "file_not_found",
469 Self::InvalidArgument(_) => "invalid_argument",
470 Self::Tls { .. } => "tls",
471 Self::Crypto { .. } => "crypto",
472 Self::Config(_) => "config",
473 Self::Unavailable { .. } => "unavailable",
474 Self::Software { .. } => "software",
475 Self::Timeout(_) => "timeout",
476 Self::XdgDirectory => "xdg_directory",
477 Self::SchemaIncompatible { .. } => "schema_incompatible",
478 Self::PartialFailure { .. } => "partial_failure",
479 Self::Generic(_) => "generic",
480 }
481 }
482
483 #[must_use]
485 pub fn exit_code(&self) -> i32 {
486 match self {
487 Self::Io(e) if is_broken_pipe(e) => exit_codes::EX_PIPE,
489 Self::Io(_) => exit_codes::EX_IOERR,
490 Self::Json(_) => exit_codes::EX_DATAERR,
491 Self::TomlDe(_) => exit_codes::EX_DATAERR,
492 Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
493 Self::Domain(_) => exit_codes::EX_USAGE,
494 Self::SshConnection(_) => exit_codes::EX_IOERR,
495 Self::SshAuthentication(_) => exit_codes::EX_NOPERM,
497 Self::ConnectionFailed(_) => exit_codes::EX_IOERR,
498 Self::AuthenticationFailed => exit_codes::EX_NOPERM,
499 Self::HostKeyChanged { .. } => exit_codes::EX_NOPERM,
500 Self::CommandTooLong { .. } => exit_codes::EX_USAGE,
501 Self::SudoDisabled => exit_codes::EX_NOPERM,
502 Self::SuPasswordMissing => exit_codes::EX_USAGE,
503 Self::ChannelFailed { .. } => exit_codes::EX_IOERR,
504 Self::SshTimeout(_) => exit_codes::EX_IOERR,
505 Self::CommandFailed { .. } => exit_codes::EX_GENERAL,
506 Self::VpsNotFound(_) => exit_codes::EX_NOINPUT,
507 Self::NoActiveVps => exit_codes::EX_NOINPUT,
508 Self::VpsDuplicate(_) => exit_codes::EX_USAGE,
509 Self::FileNotFound(_) => exit_codes::EX_NOINPUT,
510 Self::InvalidArgument(_) => exit_codes::EX_USAGE,
511 Self::Tls { .. } => exit_codes::EX_IOERR,
512 Self::Crypto { .. } => exit_codes::EX_NOPERM,
513 Self::Config(_) => exit_codes::EX_DATAERR,
514 Self::Unavailable { .. } => exit_codes::EX_UNAVAILABLE,
515 Self::Software { .. } => exit_codes::EX_SOFTWARE,
516 Self::Timeout(_) => exit_codes::EX_IOERR,
517 Self::XdgDirectory => exit_codes::EX_CANTCREAT,
518 Self::SchemaIncompatible { .. } => exit_codes::EX_DATAERR,
519 Self::PartialFailure { .. } => exit_codes::EX_GENERAL,
522 Self::Generic(_) => exit_codes::EX_GENERAL,
523 }
524 }
525
526 #[must_use]
528 pub fn retry_kind(&self) -> RetryKind {
529 match self {
530 Self::Io(e) if is_broken_pipe(e) => RetryKind::Cancelled,
531 Self::Io(_) => RetryKind::TransientNetwork,
533 Self::Json(_) | Self::TomlDe(_) | Self::TomlSer(_) | Self::Config(_) => {
534 RetryKind::PermanentClient
535 }
536 Self::Domain(_) => RetryKind::PermanentClient,
537 Self::SshConnection(_) => RetryKind::TransientSsh,
538 Self::SshAuthentication(_)
539 | Self::AuthenticationFailed
540 | Self::HostKeyChanged { .. } => RetryKind::PermanentAuth,
541 Self::ConnectionFailed(_) | Self::Tls { .. } => RetryKind::TransientNetwork,
542 Self::Unavailable { .. } => RetryKind::TransientTimeout,
547 Self::Software { .. } => RetryKind::PermanentClient,
550 Self::CommandTooLong { .. }
551 | Self::SudoDisabled
552 | Self::SuPasswordMissing
553 | Self::VpsNotFound(_)
554 | Self::NoActiveVps
555 | Self::VpsDuplicate(_)
556 | Self::FileNotFound(_)
557 | Self::InvalidArgument(_)
558 | Self::Crypto { .. }
559 | Self::XdgDirectory
560 | Self::SchemaIncompatible { .. }
561 | Self::Generic(_) => RetryKind::PermanentClient,
562 Self::PartialFailure { .. } => RetryKind::NotRetryable,
564 Self::ChannelFailed { .. } => RetryKind::TransientSsh,
565 Self::SshTimeout(_) | Self::Timeout(_) => RetryKind::TransientTimeout,
566 Self::CommandFailed { .. } => RetryKind::PermanentRemoteCommand,
567 }
568 }
569
570 #[must_use]
572 pub fn is_retryable(&self) -> bool {
573 matches!(
574 self.retry_kind(),
575 RetryKind::TransientNetwork | RetryKind::TransientTimeout | RetryKind::TransientSsh
576 )
577 }
578
579 #[must_use]
581 pub fn is_permanent(&self) -> bool {
582 !self.is_retryable() && !matches!(self.retry_kind(), RetryKind::Cancelled)
583 }
584
585 #[must_use]
587 pub fn classify(&self) -> ErrorClass {
588 if matches!(self, Self::PartialFailure { .. }) {
592 return ErrorClass::Partial;
593 }
594 match self.retry_kind() {
595 RetryKind::TransientNetwork | RetryKind::TransientTimeout | RetryKind::TransientSsh => {
596 ErrorClass::Transient
597 }
598 RetryKind::Cancelled => ErrorClass::Cancelled,
599 RetryKind::NotRetryable
600 | RetryKind::PermanentAuth
601 | RetryKind::PermanentClient
602 | RetryKind::PermanentRemoteCommand => ErrorClass::Permanent,
603 }
604 }
605
606 #[must_use]
608 pub fn layer(&self) -> ErrorLayer {
609 match self {
610 Self::Io(e) if is_broken_pipe(e) => ErrorLayer::Io,
611 Self::Io(e) if io_error_is_transient_network(e) => ErrorLayer::Network,
612 Self::Io(_) => ErrorLayer::Io,
613 Self::ConnectionFailed(_) | Self::Timeout(_) | Self::Tls { .. } => ErrorLayer::Network,
614 Self::SshConnection(_) | Self::ChannelFailed { .. } | Self::SshTimeout(_) => {
615 ErrorLayer::Ssh
616 }
617 Self::SshAuthentication(_)
618 | Self::AuthenticationFailed
619 | Self::HostKeyChanged { .. }
620 | Self::SudoDisabled
621 | Self::Crypto { .. }
622 | Self::Unavailable { .. } => ErrorLayer::Auth,
624 Self::Json(_)
625 | Self::TomlDe(_)
626 | Self::TomlSer(_)
627 | Self::Domain(_)
628 | Self::CommandTooLong { .. }
629 | Self::SuPasswordMissing
630 | Self::CommandFailed { .. }
631 | Self::VpsNotFound(_)
632 | Self::NoActiveVps
633 | Self::VpsDuplicate(_)
634 | Self::FileNotFound(_)
635 | Self::InvalidArgument(_)
636 | Self::Config(_)
637 | Self::Software { .. }
638 | Self::XdgDirectory
639 | Self::SchemaIncompatible { .. }
640 | Self::PartialFailure { .. }
641 | Self::Generic(_) => ErrorLayer::Application,
642 }
643 }
644
645 #[must_use]
647 pub fn retry_after(&self) -> Option<std::time::Duration> {
648 let _ = self;
649 None
650 }
651
652 #[must_use]
654 pub fn suggestion(&self) -> Option<&'static str> {
655 match self {
660 Self::Unavailable { .. } => {
661 return Some(
662 "unlock or start the OS keyring, or fall back to XDG `secrets.key` \
663 (`--secrets-key-file`); the same argv succeeds once it answers (exit 69)",
664 );
665 }
666 Self::Software { .. } => {
667 return Some(
668 "internal failure with no user-fixable input; report it — retrying \
669 unchanged will not help (exit 70)",
670 );
671 }
672 _ => {}
673 }
674 match self.retry_kind() {
675 RetryKind::TransientNetwork | RetryKind::TransientSsh => {
676 Some("retry at most twice with exponential full-jitter backoff (exit 74)")
677 }
678 RetryKind::TransientTimeout => {
679 Some("increase --timeout / --timeout-ms, then retry at most twice with backoff")
680 }
681 RetryKind::PermanentAuth => {
682 Some("change credentials (--key / --password-stdin) or host-key policy; do not blind-retry")
683 }
684 RetryKind::PermanentRemoteCommand => {
685 Some("inspect remote stderr; fix remote command — transport retry will not help")
686 }
687 RetryKind::PermanentClient => {
688 Some("fix CLI arguments, registry state, or schema; do not retry unchanged")
689 }
690 RetryKind::Cancelled => Some("do not retry after signal or broken pipe"),
691 RetryKind::NotRetryable => None,
692 }
693 }
694}
695
696#[must_use]
698pub fn io_error_is_transient_network(err: &std::io::Error) -> bool {
699 use std::io::ErrorKind;
700 matches!(
701 err.kind(),
702 ErrorKind::ConnectionRefused
703 | ErrorKind::ConnectionReset
704 | ErrorKind::ConnectionAborted
705 | ErrorKind::NotConnected
706 | ErrorKind::AddrNotAvailable
707 | ErrorKind::TimedOut
708 | ErrorKind::Interrupted
709 | ErrorKind::WouldBlock
710 | ErrorKind::UnexpectedEof
711 | ErrorKind::NetworkUnreachable
712 | ErrorKind::HostUnreachable
713 )
714}
715
716pub type SshCliResult<T> = std::result::Result<T, SshCliError>;
718
719pub fn finish_batch(failed: usize, total: usize, op: &'static str) -> SshCliResult<()> {
746 if failed == 0 {
747 return Ok(());
748 }
749 Err(SshCliError::PartialFailure { failed, total, op })
750}
751
752#[cfg(test)]
753#[path = "errors_tests.rs"]
754mod tests;