openssh/
error.rs

1use std::io;
2
3/// Errors that occur when interacting with a remote process.
4#[derive(Debug, thiserror::Error)]
5#[non_exhaustive]
6pub enum Error {
7    /// The master connection failed.
8    #[error("the master connection failed")]
9    Master(#[source] io::Error),
10
11    /// Failed to establish initial connection to the remote host.
12    #[error("failed to connect to the remote host")]
13    Connect(#[source] io::Error),
14
15    /// Failed to run the `ssh` command locally.
16    #[cfg(feature = "process-mux")]
17    #[cfg_attr(docsrs, doc(cfg(feature = "process-mux")))]
18    #[error("the local ssh command could not be executed")]
19    Ssh(#[source] io::Error),
20
21    /// Failed to connect to the ssh multiplex server.
22    #[cfg(feature = "native-mux")]
23    #[cfg_attr(docsrs, doc(cfg(feature = "native-mux")))]
24    #[error("failed to connect to the ssh multiplex server")]
25    SshMux(#[source] openssh_mux_client::Error),
26
27    /// Invalid command that contains null byte.
28    #[cfg(feature = "native-mux")]
29    #[cfg_attr(docsrs, doc(cfg(feature = "native-mux")))]
30    #[error("invalid command: Command contains null byte.")]
31    InvalidCommand,
32
33    /// The remote process failed.
34    #[error("the remote command could not be executed")]
35    Remote(#[source] io::Error),
36
37    /// The connection to the remote host was severed.
38    ///
39    /// Note that for the process impl, this is a best-effort error, and it _may_ instead
40    /// signify that the remote process exited with an error code of 255.
41    ///
42    /// You should call [`Session::check`](crate::Session::check) to verify if you get
43    /// this error back.
44    #[error("the connection was terminated")]
45    Disconnected,
46
47    /// Remote process is terminated.
48    ///
49    /// It is likely to be that the process is terminated by signal.
50    ///
51    /// **NOTE that due to a fundamental design flaw in ssh multiplex protocol,
52    /// there is no way to tell `RemoteProcessTerminated` from `Disconnect`.**
53    ///
54    /// If you really need to identify `Disconnect`, you can call `session.check()`
55    /// after `wait()` returns `RemoteProcessTerminated`, however the ssh multiplex master
56    /// could exit right after `wait()`, meaning the remote process actually is terminated
57    /// instead of `Disconnect`ed.
58    ///
59    /// It is thus recommended to create your own workaround for your particular use cases.
60    #[error("the remote process has terminated")]
61    RemoteProcessTerminated,
62
63    /// Failed to remove temporary dir where ssh socket and output is stored.
64    #[error("failed to remove temporary ssh session directory")]
65    Cleanup(#[source] io::Error),
66
67    /// IO Error when creating/reading/writing from ChildStdin, ChildStdout, ChildStderr.
68    #[error("failure while accessing standard i/o of remote process")]
69    ChildIo(#[source] io::Error),
70
71    /// The command has some env variables that it expects to carry over ssh.
72    /// However, OverSsh does not support passing env variables over ssh.
73    #[error("rejected runing a command over ssh that expects env variables to be carried over to remote.")]
74    CommandHasEnv,
75
76    /// The command expects to be in a specific working directory in remote.
77    /// However, OverSsh does not support setting a working directory for commands to be executed over ssh.
78    #[error("rejected runing a command over ssh that expects a specific working directory to be carried over to remote.")]
79    CommandHasCwd,
80}
81
82#[cfg(feature = "native-mux")]
83impl From<openssh_mux_client::Error> for Error {
84    fn from(err: openssh_mux_client::Error) -> Self {
85        use io::ErrorKind;
86
87        match &err {
88            openssh_mux_client::Error::IOError(ioerr) => match ioerr.kind() {
89                ErrorKind::NotFound
90                | ErrorKind::ConnectionReset
91                // If the listener of a unix socket exits without removing the socket
92                // file, then attempt to connect to the file results in
93                // `ConnectionRefused`.
94                | ErrorKind::ConnectionRefused
95                | ErrorKind::ConnectionAborted
96                | ErrorKind::NotConnected => Error::Disconnected,
97
98                _ => Error::SshMux(err),
99            },
100            _ => Error::SshMux(err),
101        }
102    }
103}
104
105impl Error {
106    pub(crate) fn interpret_ssh_error(stderr: &str) -> Self {
107        // we want to turn the string-only ssh error into something a little more "handleable".
108        // we do this by trying to interpret the output from `ssh`. this is error-prone, but
109        // the best we can do. if you find ways to impove this, even just through heuristics,
110        // please file an issue or PR :)
111        //
112        // format is:
113        //
114        //     ssh: ssh error: io error
115        let mut stderr = stderr.trim();
116        stderr = stderr.strip_prefix("ssh: ").unwrap_or(stderr);
117        if stderr.starts_with("Warning: Permanently added ") {
118            // added to hosts file -- let's ignore that message
119            stderr = stderr.split_once('\n').map(|x| x.1.trim()).unwrap_or("");
120        }
121        let mut kind = io::ErrorKind::ConnectionAborted;
122        let mut err = stderr.splitn(2, ": ");
123        if let Some(ssh_error) = err.next() {
124            if ssh_error.starts_with("Could not resolve") {
125                // match what `std` gives: https://github.com/rust-lang/rust/blob/a5de254862477924bcd8b9e1bff7eadd6ffb5e2a/src/libstd/sys/unix/net.rs#L40
126                // we _could_ match on "Name or service not known" from io_error,
127                // but my guess is that the ssh error is more stable.
128                kind = io::ErrorKind::Other;
129            }
130
131            if let Some(io_error) = err.next() {
132                match io_error {
133                    "Network is unreachable" => {
134                        kind = io::ErrorKind::Other;
135                    }
136                    "Connection refused" => {
137                        kind = io::ErrorKind::ConnectionRefused;
138                    }
139                    e if ssh_error.starts_with("connect to host")
140                        && e == "Connection timed out" =>
141                    {
142                        kind = io::ErrorKind::TimedOut;
143                    }
144                    e if ssh_error.starts_with("connect to host") && e == "Operation timed out" => {
145                        // this is the macOS version of "connection timed out"
146                        kind = io::ErrorKind::TimedOut;
147                    }
148                    e if ssh_error.starts_with("connect to host") && e == "Permission denied" => {
149                        // this is the macOS version of "network is unreachable".
150                        kind = io::ErrorKind::Other;
151                    }
152                    e if e.contains("Permission denied (") => {
153                        kind = io::ErrorKind::PermissionDenied;
154                    }
155                    _ => {}
156                }
157            }
158        }
159
160        // NOTE: we may want to provide more structured connection errors than just io::Error?
161        // NOTE: can we re-use this method for non-connect cases?
162        Error::Connect(io::Error::new(kind, stderr))
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::{io, Error};
169
170    #[test]
171    fn parse_error() {
172        let err = "ssh: Warning: Permanently added \'login.csail.mit.edu,128.52.131.0\' (ECDSA) to the list of known hosts.\r\nopenssh-tester@login.csail.mit.edu: Permission denied (publickey,gssapi-keyex,gssapi-with-mic,password,keyboard-interactive).";
173        let err = Error::interpret_ssh_error(err);
174        let target = io::Error::new(io::ErrorKind::PermissionDenied, "openssh-tester@login.csail.mit.edu: Permission denied (publickey,gssapi-keyex,gssapi-with-mic,password,keyboard-interactive).");
175        if let Error::Connect(e) = err {
176            assert_eq!(e.kind(), target.kind());
177            assert_eq!(format!("{}", e), format!("{}", target));
178        } else {
179            unreachable!("{:?}", err);
180        }
181    }
182
183    #[test]
184    fn error_sanity() {
185        use std::error::Error as _;
186
187        let ioe = || io::Error::new(io::ErrorKind::Other, "test");
188        let expect = ioe();
189
190        let e = Error::Master(ioe());
191        assert!(!format!("{}", e).is_empty());
192        let e = e
193            .source()
194            .expect("source failed")
195            .downcast_ref::<io::Error>()
196            .expect("source not io");
197        assert_eq!(e.kind(), expect.kind());
198        assert_eq!(format!("{}", e), format!("{}", expect));
199
200        let e = Error::Connect(ioe());
201        assert!(!format!("{}", e).is_empty());
202        let e = e
203            .source()
204            .expect("source failed")
205            .downcast_ref::<io::Error>()
206            .expect("source not io");
207        assert_eq!(e.kind(), expect.kind());
208        assert_eq!(format!("{}", e), format!("{}", expect));
209
210        #[cfg(feature = "process-mux")]
211        {
212            let e = Error::Ssh(ioe());
213            assert!(!format!("{}", e).is_empty());
214            let e = e
215                .source()
216                .expect("source failed")
217                .downcast_ref::<io::Error>()
218                .expect("source not io");
219            assert_eq!(e.kind(), expect.kind());
220            assert_eq!(format!("{}", e), format!("{}", expect));
221        }
222
223        let e = Error::Remote(ioe());
224        assert!(!format!("{}", e).is_empty());
225        let e = e
226            .source()
227            .expect("source failed")
228            .downcast_ref::<io::Error>()
229            .expect("source not io");
230        assert_eq!(e.kind(), expect.kind());
231        assert_eq!(format!("{}", e), format!("{}", expect));
232
233        let e = Error::Disconnected;
234        assert!(!format!("{}", e).is_empty());
235        assert!(e.source().is_none());
236    }
237}