1use std::io;
2
3#[derive(Debug, thiserror::Error)]
5#[non_exhaustive]
6pub enum Error {
7 #[error("the master connection failed")]
9 Master(#[source] io::Error),
10
11 #[error("failed to connect to the remote host")]
13 Connect(#[source] io::Error),
14
15 #[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 #[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 #[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 #[error("the remote command could not be executed")]
35 Remote(#[source] io::Error),
36
37 #[error("the connection was terminated")]
45 Disconnected,
46
47 #[error("the remote process has terminated")]
61 RemoteProcessTerminated,
62
63 #[error("failed to remove temporary ssh session directory")]
65 Cleanup(#[source] io::Error),
66
67 #[error("failure while accessing standard i/o of remote process")]
69 ChildIo(#[source] io::Error),
70
71 #[error("rejected runing a command over ssh that expects env variables to be carried over to remote.")]
74 CommandHasEnv,
75
76 #[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 | 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 let mut stderr = stderr.trim();
116 stderr = stderr.strip_prefix("ssh: ").unwrap_or(stderr);
117 if stderr.starts_with("Warning: Permanently added ") {
118 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 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 kind = io::ErrorKind::TimedOut;
147 }
148 e if ssh_error.starts_with("connect to host") && e == "Permission denied" => {
149 kind = io::ErrorKind::Other;
151 }
152 e if e.contains("Permission denied (") => {
153 kind = io::ErrorKind::PermissionDenied;
154 }
155 _ => {}
156 }
157 }
158 }
159
160 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}