1use std::{borrow::Cow, error::Error as StdError, fmt};
4
5use crate::CommandExit;
6
7pub type BoxError = Box<dyn StdError + Send + Sync + 'static>;
9
10pub type Result<T, E = Error> = std::result::Result<T, E>;
12
13pub struct CategoryError<K> {
15 kind: K,
16 message: Cow<'static, str>,
17 source: Option<BoxError>,
18}
19
20impl<K> CategoryError<K> {
21 pub fn new(kind: K, message: impl Into<Cow<'static, str>>) -> Self {
23 Self {
24 kind,
25 message: message.into(),
26 source: None,
27 }
28 }
29
30 pub fn with_source<E>(kind: K, message: impl Into<Cow<'static, str>>, source: E) -> Self
32 where
33 E: StdError + Send + Sync + 'static,
34 {
35 Self::with_boxed_source(kind, message, Box::new(source))
36 }
37
38 pub fn with_boxed_source(
40 kind: K,
41 message: impl Into<Cow<'static, str>>,
42 source: BoxError,
43 ) -> Self {
44 Self {
45 kind,
46 message: message.into(),
47 source: Some(source),
48 }
49 }
50
51 pub fn kind(&self) -> K
53 where
54 K: Copy,
55 {
56 self.kind
57 }
58
59 pub fn message(&self) -> &str {
61 &self.message
62 }
63
64 pub fn has_source(&self) -> bool {
66 self.source.is_some()
67 }
68}
69
70impl<K> fmt::Debug for CategoryError<K>
71where
72 K: fmt::Debug,
73{
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.debug_struct("CategoryError")
76 .field("kind", &self.kind)
77 .field("message", &self.message)
78 .field("has_source", &self.source.is_some())
79 .finish()
80 }
81}
82
83impl<K> fmt::Display for CategoryError<K> {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.write_str(&self.message)
86 }
87}
88
89impl<K> StdError for CategoryError<K>
90where
91 K: fmt::Debug + 'static,
92{
93 fn source(&self) -> Option<&(dyn StdError + 'static)> {
94 self.source
95 .as_ref()
96 .map(|source| source.as_ref() as &(dyn StdError + 'static))
97 }
98}
99
100#[non_exhaustive]
102#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
103pub enum TransportErrorKind {
104 Dns,
106 TcpConnect,
108 Negotiation,
110 Keepalive,
112 Encryption,
114 Io,
116 Other,
118}
119
120#[non_exhaustive]
122#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
123pub enum HostKeyErrorKind {
124 Unknown,
126 Changed,
128 Rejected,
130 Unsupported,
132 Unavailable,
134}
135
136#[non_exhaustive]
138#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
139pub enum AuthenticationErrorKind {
140 Rejected,
142 Exhausted,
144 Partial,
146 UnsupportedMethod,
148 Unavailable,
150}
151
152#[non_exhaustive]
154#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
155pub enum ChannelErrorKind {
156 Open,
158 Request,
160 Read,
162 Write,
164 Eof,
166 Close,
168 Protocol,
170}
171
172#[non_exhaustive]
174#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
175pub enum SftpErrorKind {
176 RemoteStatus,
178 Protocol,
180 ChannelIo,
182 UnexpectedResponse,
184 UnsupportedVersion,
186 Unsupported,
188 NoSuchFile,
190 PermissionDenied,
192}
193
194#[non_exhaustive]
196#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
197pub enum ForwardingErrorKind {
198 Bind,
200 Listen,
202 Accept,
204 Connect,
206 GlobalRequest,
208 ChannelOpen,
210 StreamCopy,
212 Cancel,
214 Shutdown,
216}
217
218#[non_exhaustive]
220#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
221pub enum Operation {
222 Connect,
224 Authentication,
226 ChannelOpen,
228 Command,
230 Shell,
232 Sftp,
234 Forwarding,
236 Server,
238 Shutdown,
240 Other,
242}
243
244#[non_exhaustive]
246#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
247pub enum SshErrorKind {
248 Russh,
250 Other,
252}
253
254pub type TransportError = CategoryError<TransportErrorKind>;
256pub type HostKeyError = CategoryError<HostKeyErrorKind>;
258pub type AuthenticationError = CategoryError<AuthenticationErrorKind>;
260pub type ChannelError = CategoryError<ChannelErrorKind>;
262pub type SftpError = CategoryError<SftpErrorKind>;
264pub type ForwardingError = CategoryError<ForwardingErrorKind>;
266pub type TimeoutError = CategoryError<Operation>;
268pub type CancelledError = CategoryError<Operation>;
270pub type DisconnectedError = CategoryError<Operation>;
272pub type SshError = CategoryError<SshErrorKind>;
274
275#[non_exhaustive]
277#[derive(Debug, thiserror::Error)]
278pub enum Error {
279 #[error("invalid configuration: {0}")]
281 InvalidConfig(Cow<'static, str>),
282
283 #[error("transport error: {0}")]
285 Transport(#[source] TransportError),
286
287 #[error("host key verification failed: {0}")]
289 HostKey(#[source] HostKeyError),
290
291 #[error("authentication failed: {0}")]
293 Authentication(#[source] AuthenticationError),
294
295 #[error("channel error: {0}")]
297 Channel(#[source] ChannelError),
298
299 #[error("remote command exited unsuccessfully: {exit:?}")]
301 CommandExit {
302 exit: CommandExit,
304 },
305
306 #[error("sftp error: {0}")]
308 Sftp(#[source] SftpError),
309
310 #[error("forwarding error: {0}")]
312 Forwarding(#[source] ForwardingError),
313
314 #[error("operation timed out: {0}")]
316 Timeout(#[source] TimeoutError),
317
318 #[error("operation cancelled: {0}")]
320 Cancelled(#[source] CancelledError),
321
322 #[error("remote disconnected: {0}")]
324 Disconnected(#[source] DisconnectedError),
325
326 #[error("unsupported operation: {0}")]
328 Unsupported(Cow<'static, str>),
329
330 #[error(transparent)]
332 Io(#[from] std::io::Error),
333
334 #[error("ssh error: {0}")]
336 Ssh(#[source] SshError),
337}
338
339impl Error {
340 pub fn invalid_config(message: impl Into<Cow<'static, str>>) -> Self {
342 Self::InvalidConfig(message.into())
343 }
344
345 pub fn transport(kind: TransportErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
347 Self::Transport(TransportError::new(kind, message))
348 }
349
350 pub fn transport_with_source<E>(
352 kind: TransportErrorKind,
353 message: impl Into<Cow<'static, str>>,
354 source: E,
355 ) -> Self
356 where
357 E: StdError + Send + Sync + 'static,
358 {
359 Self::Transport(TransportError::with_source(kind, message, source))
360 }
361
362 pub fn host_key(kind: HostKeyErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
364 Self::HostKey(HostKeyError::new(kind, message))
365 }
366
367 pub fn host_key_with_source<E>(
369 kind: HostKeyErrorKind,
370 message: impl Into<Cow<'static, str>>,
371 source: E,
372 ) -> Self
373 where
374 E: StdError + Send + Sync + 'static,
375 {
376 Self::HostKey(HostKeyError::with_source(kind, message, source))
377 }
378
379 pub fn authentication(message: impl Into<Cow<'static, str>>) -> Self {
381 Self::Authentication(AuthenticationError::new(
382 AuthenticationErrorKind::Rejected,
383 message,
384 ))
385 }
386
387 pub fn authentication_kind(
389 kind: AuthenticationErrorKind,
390 message: impl Into<Cow<'static, str>>,
391 ) -> Self {
392 Self::Authentication(AuthenticationError::new(kind, message))
393 }
394
395 pub fn authentication_with_source<E>(
397 kind: AuthenticationErrorKind,
398 message: impl Into<Cow<'static, str>>,
399 source: E,
400 ) -> Self
401 where
402 E: StdError + Send + Sync + 'static,
403 {
404 Self::Authentication(AuthenticationError::with_source(kind, message, source))
405 }
406
407 pub fn channel(message: impl Into<Cow<'static, str>>) -> Self {
409 Self::Channel(ChannelError::new(ChannelErrorKind::Protocol, message))
410 }
411
412 pub fn channel_kind(kind: ChannelErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
414 Self::Channel(ChannelError::new(kind, message))
415 }
416
417 pub fn channel_with_source<E>(
419 kind: ChannelErrorKind,
420 message: impl Into<Cow<'static, str>>,
421 source: E,
422 ) -> Self
423 where
424 E: StdError + Send + Sync + 'static,
425 {
426 Self::Channel(ChannelError::with_source(kind, message, source))
427 }
428
429 pub fn command_exit(exit: CommandExit) -> Self {
431 Self::CommandExit { exit }
432 }
433
434 pub fn sftp(kind: SftpErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
436 Self::Sftp(SftpError::new(kind, message))
437 }
438
439 pub fn sftp_with_source<E>(
441 kind: SftpErrorKind,
442 message: impl Into<Cow<'static, str>>,
443 source: E,
444 ) -> Self
445 where
446 E: StdError + Send + Sync + 'static,
447 {
448 Self::Sftp(SftpError::with_source(kind, message, source))
449 }
450
451 pub fn forwarding(kind: ForwardingErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
453 Self::Forwarding(ForwardingError::new(kind, message))
454 }
455
456 pub fn forwarding_with_source<E>(
458 kind: ForwardingErrorKind,
459 source: E,
460 message: impl Into<Cow<'static, str>>,
461 ) -> Self
462 where
463 E: StdError + Send + Sync + 'static,
464 {
465 Self::Forwarding(ForwardingError::with_source(kind, message, source))
466 }
467
468 pub fn timeout(operation: Operation, message: impl Into<Cow<'static, str>>) -> Self {
470 Self::Timeout(TimeoutError::new(operation, message))
471 }
472
473 pub fn cancelled(operation: Operation, message: impl Into<Cow<'static, str>>) -> Self {
475 Self::Cancelled(CancelledError::new(operation, message))
476 }
477
478 pub fn disconnected(operation: Operation, message: impl Into<Cow<'static, str>>) -> Self {
480 Self::Disconnected(DisconnectedError::new(operation, message))
481 }
482
483 pub fn unsupported(message: impl Into<Cow<'static, str>>) -> Self {
485 Self::Unsupported(message.into())
486 }
487
488 pub fn ssh_with_source<E>(message: impl Into<Cow<'static, str>>, source: E) -> Self
490 where
491 E: StdError + Send + Sync + 'static,
492 {
493 Self::Ssh(SshError::with_source(SshErrorKind::Other, message, source))
494 }
495
496 pub fn is_timeout(&self) -> bool {
498 matches!(self, Self::Timeout(_))
499 || matches!(self, Self::Io(error) if error.kind() == std::io::ErrorKind::TimedOut)
500 }
501
502 pub fn is_cancelled(&self) -> bool {
504 matches!(self, Self::Cancelled(_))
505 }
506
507 pub fn is_disconnected(&self) -> bool {
509 matches!(self, Self::Disconnected(_))
510 }
511}
512
513impl From<BoxError> for Error {
514 fn from(source: BoxError) -> Self {
515 Self::Ssh(SshError::with_boxed_source(
516 SshErrorKind::Other,
517 "lower-level SSH error",
518 source,
519 ))
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use std::{error::Error as StdError, fmt};
526
527 use crate::{
528 AuthenticationErrorKind, Error, HostKeyError, HostKeyErrorKind, Operation, SshErrorKind,
529 TransportError, TransportErrorKind,
530 };
531
532 #[derive(Debug)]
533 struct SourceError;
534
535 impl fmt::Display for SourceError {
536 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537 f.write_str("source display")
538 }
539 }
540
541 impl StdError for SourceError {}
542
543 #[derive(Debug)]
544 struct SecretSource;
545
546 impl fmt::Display for SecretSource {
547 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548 f.write_str("secret-source-display")
549 }
550 }
551
552 impl StdError for SecretSource {}
553
554 #[test]
555 fn category_errors_preserve_source_without_debugging_it() {
556 let error = TransportError::with_source(
557 TransportErrorKind::Dns,
558 "failed to resolve host",
559 SecretSource,
560 );
561
562 assert_eq!(error.kind(), TransportErrorKind::Dns);
563 assert_eq!(error.message(), "failed to resolve host");
564 assert!(error.has_source());
565 assert!(StdError::source(&error).is_some());
566
567 let debug = format!("{error:?}");
568 assert!(debug.contains("Dns"));
569 assert!(debug.contains("has_source: true"));
570 assert!(!debug.contains("secret-source-display"));
571 }
572
573 #[test]
574 fn top_level_errors_preserve_category_sources() {
575 let error = Error::transport_with_source(
576 TransportErrorKind::TcpConnect,
577 "tcp connect failed",
578 SourceError,
579 );
580
581 let category = StdError::source(&error).expect("category source");
582 assert_eq!(category.to_string(), "tcp connect failed");
583 assert!(category.source().is_some());
584 }
585
586 #[test]
587 fn helper_predicates_classify_common_control_flow() {
588 assert!(Error::timeout(Operation::Connect, "connect timed out").is_timeout());
589 assert!(Error::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "io")).is_timeout());
590 assert!(Error::cancelled(Operation::Command, "cancelled").is_cancelled());
591 assert!(Error::disconnected(Operation::Sftp, "disconnect").is_disconnected());
592 assert!(!Error::unsupported("not yet").is_timeout());
593 }
594
595 #[test]
596 fn typed_variants_expose_stable_kinds() {
597 let auth = Error::authentication_kind(AuthenticationErrorKind::Exhausted, "no credentials");
598 let Error::Authentication(auth) = auth else {
599 panic!("expected authentication error");
600 };
601 assert_eq!(auth.kind(), AuthenticationErrorKind::Exhausted);
602
603 let host_key = Error::HostKey(HostKeyError::new(
604 HostKeyErrorKind::Changed,
605 "host key changed",
606 ));
607 let Error::HostKey(host_key) = host_key else {
608 panic!("expected host key error");
609 };
610 assert_eq!(host_key.kind(), HostKeyErrorKind::Changed);
611 }
612
613 #[test]
614 fn boxed_sources_convert_to_unclassified_ssh_errors() {
615 let error = Error::from(Box::new(SourceError) as crate::BoxError);
616 let Error::Ssh(ssh) = error else {
617 panic!("expected ssh error");
618 };
619
620 assert_eq!(ssh.kind(), SshErrorKind::Other);
621 assert!(ssh.has_source());
622 }
623}