Skip to main content

russh_extra_core/
error.rs

1//! Error and result types used across the workspace.
2
3use std::{borrow::Cow, error::Error as StdError, fmt};
4
5use crate::CommandExit;
6
7/// Boxed error source preserved for diagnostics.
8pub type BoxError = Box<dyn StdError + Send + Sync + 'static>;
9
10/// Result type used by `russh-extra`.
11pub type Result<T, E = Error> = std::result::Result<T, E>;
12
13/// Shared category details for typed error variants.
14pub struct CategoryError<K> {
15    kind: K,
16    message: Cow<'static, str>,
17    source: Option<BoxError>,
18}
19
20impl<K> CategoryError<K> {
21    /// Creates a category error without a lower-level source.
22    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    /// Creates a category error with a lower-level source.
31    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    /// Creates a category error with an already boxed lower-level source.
39    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    /// Returns the stable subcategory.
52    pub fn kind(&self) -> K
53    where
54        K: Copy,
55    {
56        self.kind
57    }
58
59    /// Returns the user-facing message.
60    pub fn message(&self) -> &str {
61        &self.message
62    }
63
64    /// Returns whether a lower-level source is preserved.
65    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/// Transport failure category.
101#[non_exhaustive]
102#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
103pub enum TransportErrorKind {
104    /// DNS resolution failed.
105    Dns,
106    /// TCP connection failed.
107    TcpConnect,
108    /// SSH negotiation failed.
109    Negotiation,
110    /// Keepalive failed.
111    Keepalive,
112    /// Encryption or MAC handling failed.
113    Encryption,
114    /// Transport I/O failed.
115    Io,
116    /// Other transport failure.
117    Other,
118}
119
120/// Host-key verification failure category.
121#[non_exhaustive]
122#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
123pub enum HostKeyErrorKind {
124    /// Host key is unknown to the configured policy.
125    Unknown,
126    /// Host key changed from a previously trusted value.
127    Changed,
128    /// Host key was rejected by policy.
129    Rejected,
130    /// Host key algorithm or format is unsupported.
131    Unsupported,
132    /// Host key was unavailable when required.
133    Unavailable,
134}
135
136/// Authentication failure category.
137#[non_exhaustive]
138#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
139pub enum AuthenticationErrorKind {
140    /// Credentials were rejected.
141    Rejected,
142    /// All configured credentials were exhausted.
143    Exhausted,
144    /// Authentication partially succeeded but did not complete.
145    Partial,
146    /// Requested authentication method is unsupported.
147    UnsupportedMethod,
148    /// Authentication could not be attempted.
149    Unavailable,
150}
151
152/// Channel failure category.
153#[non_exhaustive]
154#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
155pub enum ChannelErrorKind {
156    /// Channel open failed.
157    Open,
158    /// Channel request failed.
159    Request,
160    /// Channel read failed.
161    Read,
162    /// Channel write failed.
163    Write,
164    /// Unexpected EOF.
165    Eof,
166    /// Channel close failed or arrived unexpectedly.
167    Close,
168    /// Protocol ordering or framing was invalid for the high-level API.
169    Protocol,
170}
171
172/// SFTP failure category.
173#[non_exhaustive]
174#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
175pub enum SftpErrorKind {
176    /// Remote SFTP status response indicated failure (SSH_FX_* code).
177    RemoteStatus,
178    /// Packet was malformed or protocol framing is invalid.
179    Protocol,
180    /// SSH channel read/write failure.
181    ChannelIo,
182    /// Response request ID did not match an in-flight request.
183    UnexpectedResponse,
184    /// Protocol version is unsupported by the server.
185    UnsupportedVersion,
186    /// Feature not yet implemented.
187    Unsupported,
188    /// File or directory not found (SSH_FX_NO_SUCH_FILE).
189    NoSuchFile,
190    /// Insufficient permissions (SSH_FX_PERMISSION_DENIED).
191    PermissionDenied,
192}
193
194/// Forwarding failure category.
195#[non_exhaustive]
196#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
197pub enum ForwardingErrorKind {
198    /// Local or remote bind failed.
199    Bind,
200    /// Listener setup failed.
201    Listen,
202    /// Accepting a forwarded connection failed.
203    Accept,
204    /// Connecting to a forwarding target failed.
205    Connect,
206    /// SSH global forwarding request failed.
207    GlobalRequest,
208    /// Opening a forwarding channel failed.
209    ChannelOpen,
210    /// Bidirectional stream copy failed.
211    StreamCopy,
212    /// Forwarding cancellation failed.
213    Cancel,
214    /// Forwarding shutdown failed.
215    Shutdown,
216}
217
218/// High-level operation category used by timeout, cancellation, and disconnects.
219#[non_exhaustive]
220#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
221pub enum Operation {
222    /// Establishing a client connection.
223    Connect,
224    /// Authenticating a client or server session.
225    Authentication,
226    /// Opening a channel.
227    ChannelOpen,
228    /// Running a remote command.
229    Command,
230    /// Running an interactive shell.
231    Shell,
232    /// Running SFTP.
233    Sftp,
234    /// Running forwarding or tunnel work.
235    Forwarding,
236    /// Running server work.
237    Server,
238    /// Shutting down an operation.
239    Shutdown,
240    /// Other operation.
241    Other,
242}
243
244/// Lower-level SSH failure category.
245#[non_exhaustive]
246#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
247pub enum SshErrorKind {
248    /// Error came from `russh`.
249    Russh,
250    /// Other lower-level SSH error.
251    Other,
252}
253
254/// Transport failure details.
255pub type TransportError = CategoryError<TransportErrorKind>;
256/// Host-key verification failure details.
257pub type HostKeyError = CategoryError<HostKeyErrorKind>;
258/// Authentication failure details.
259pub type AuthenticationError = CategoryError<AuthenticationErrorKind>;
260/// Channel failure details.
261pub type ChannelError = CategoryError<ChannelErrorKind>;
262/// SFTP failure details.
263pub type SftpError = CategoryError<SftpErrorKind>;
264/// Forwarding failure details.
265pub type ForwardingError = CategoryError<ForwardingErrorKind>;
266/// Timeout failure details.
267pub type TimeoutError = CategoryError<Operation>;
268/// Cancellation failure details.
269pub type CancelledError = CategoryError<Operation>;
270/// Remote disconnect failure details.
271pub type DisconnectedError = CategoryError<Operation>;
272/// Lower-level SSH failure details.
273pub type SshError = CategoryError<SshErrorKind>;
274
275/// Error type used by `russh-extra`.
276#[non_exhaustive]
277#[derive(Debug, thiserror::Error)]
278pub enum Error {
279    /// A builder or parser received invalid configuration.
280    #[error("invalid configuration: {0}")]
281    InvalidConfig(Cow<'static, str>),
282
283    /// SSH transport failed.
284    #[error("transport error: {0}")]
285    Transport(#[source] TransportError),
286
287    /// Host-key verification failed.
288    #[error("host key verification failed: {0}")]
289    HostKey(#[source] HostKeyError),
290
291    /// Authentication was rejected or could not be attempted.
292    #[error("authentication failed: {0}")]
293    Authentication(#[source] AuthenticationError),
294
295    /// A channel could not be opened or used.
296    #[error("channel error: {0}")]
297    Channel(#[source] ChannelError),
298
299    /// A remote command exited unsuccessfully.
300    #[error("remote command exited unsuccessfully: {exit:?}")]
301    CommandExit {
302        /// Reported remote command exit.
303        exit: CommandExit,
304    },
305
306    /// SFTP operation failed.
307    #[error("sftp error: {0}")]
308    Sftp(#[source] SftpError),
309
310    /// Forwarding operation failed.
311    #[error("forwarding error: {0}")]
312    Forwarding(#[source] ForwardingError),
313
314    /// Operation timed out.
315    #[error("operation timed out: {0}")]
316    Timeout(#[source] TimeoutError),
317
318    /// Operation was cancelled.
319    #[error("operation cancelled: {0}")]
320    Cancelled(#[source] CancelledError),
321
322    /// Remote peer disconnected.
323    #[error("remote disconnected: {0}")]
324    Disconnected(#[source] DisconnectedError),
325
326    /// A requested operation is not implemented or not supported.
327    #[error("unsupported operation: {0}")]
328    Unsupported(Cow<'static, str>),
329
330    /// Local I/O failed.
331    #[error(transparent)]
332    Io(#[from] std::io::Error),
333
334    /// An unclassified lower-level SSH error occurred.
335    #[error("ssh error: {0}")]
336    Ssh(#[source] SshError),
337}
338
339impl Error {
340    /// Creates an invalid configuration error.
341    pub fn invalid_config(message: impl Into<Cow<'static, str>>) -> Self {
342        Self::InvalidConfig(message.into())
343    }
344
345    /// Creates a transport error.
346    pub fn transport(kind: TransportErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
347        Self::Transport(TransportError::new(kind, message))
348    }
349
350    /// Creates a transport error with a lower-level source.
351    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    /// Creates a host-key verification error.
363    pub fn host_key(kind: HostKeyErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
364        Self::HostKey(HostKeyError::new(kind, message))
365    }
366
367    /// Creates a host-key verification error with a lower-level source.
368    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    /// Creates an authentication error.
380    pub fn authentication(message: impl Into<Cow<'static, str>>) -> Self {
381        Self::Authentication(AuthenticationError::new(
382            AuthenticationErrorKind::Rejected,
383            message,
384        ))
385    }
386
387    /// Creates an authentication error with a specific category.
388    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    /// Creates an authentication error with a lower-level source.
396    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    /// Creates a channel error.
408    pub fn channel(message: impl Into<Cow<'static, str>>) -> Self {
409        Self::Channel(ChannelError::new(ChannelErrorKind::Protocol, message))
410    }
411
412    /// Creates a channel error with a specific category.
413    pub fn channel_kind(kind: ChannelErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
414        Self::Channel(ChannelError::new(kind, message))
415    }
416
417    /// Creates a channel error with a lower-level source.
418    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    /// Creates a remote command exit error.
430    pub fn command_exit(exit: CommandExit) -> Self {
431        Self::CommandExit { exit }
432    }
433
434    /// Creates an SFTP error.
435    pub fn sftp(kind: SftpErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
436        Self::Sftp(SftpError::new(kind, message))
437    }
438
439    /// Creates an SFTP error with a lower-level source.
440    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    /// Creates a forwarding error.
452    pub fn forwarding(kind: ForwardingErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
453        Self::Forwarding(ForwardingError::new(kind, message))
454    }
455
456    /// Creates a forwarding error with a lower-level source.
457    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    /// Creates a timeout error.
469    pub fn timeout(operation: Operation, message: impl Into<Cow<'static, str>>) -> Self {
470        Self::Timeout(TimeoutError::new(operation, message))
471    }
472
473    /// Creates a cancellation error.
474    pub fn cancelled(operation: Operation, message: impl Into<Cow<'static, str>>) -> Self {
475        Self::Cancelled(CancelledError::new(operation, message))
476    }
477
478    /// Creates a remote disconnect error.
479    pub fn disconnected(operation: Operation, message: impl Into<Cow<'static, str>>) -> Self {
480        Self::Disconnected(DisconnectedError::new(operation, message))
481    }
482
483    /// Creates an unsupported operation error.
484    pub fn unsupported(message: impl Into<Cow<'static, str>>) -> Self {
485        Self::Unsupported(message.into())
486    }
487
488    /// Creates an unclassified lower-level SSH error with a source.
489    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    /// Returns whether this error is a timeout.
497    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    /// Returns whether this error is a cancellation.
503    pub fn is_cancelled(&self) -> bool {
504        matches!(self, Self::Cancelled(_))
505    }
506
507    /// Returns whether this error is a remote disconnect.
508    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}