1use std::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum ReadStage {
11 Spawn,
13 Connect,
16 Open,
18 Inspect,
20 Transfer,
22 Validate,
24 Teardown,
26}
27
28impl ReadStage {
29 fn as_str(self) -> &'static str {
30 match self {
31 Self::Spawn => "spawn",
32 Self::Connect => "connect",
33 Self::Open => "open",
34 Self::Inspect => "inspect",
35 Self::Transfer => "transfer",
36 Self::Validate => "validate",
37 Self::Teardown => "teardown",
38 }
39 }
40}
41
42impl fmt::Display for ReadStage {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 formatter.write_str(self.as_str())
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum ReadFailureKind {
52 Spawn,
54 Auth,
56 Trust,
58 Network,
60 Subsystem,
62 Connect,
64 NotFound,
66 Permission,
68 Protocol,
70 Io,
72 NotRegularFile,
74 UnknownLength,
76 TooLarge,
78 ShortRead,
80 InvalidUtf8,
82 Cancelled,
84 Deadline,
86 #[cfg(not(unix))]
88 Unsupported,
89}
90
91impl ReadFailureKind {
92 fn as_str(self) -> &'static str {
93 match self {
94 Self::Spawn => "local ssh unavailable",
95 Self::Auth => "authentication failed",
96 Self::Trust => "host key refused",
97 Self::Network => "network failure",
98 Self::Subsystem => "sftp subsystem unavailable",
99 Self::Connect => "connection failed",
100 Self::NotFound => "remote file missing",
101 Self::Permission => "remote access denied",
102 Self::Protocol => "sftp protocol failure",
103 Self::Io => "transport I/O failure",
104 Self::NotRegularFile => "not a regular file",
105 Self::UnknownLength => "unknown file length",
106 Self::TooLarge => "snapshot too large",
107 Self::ShortRead => "short read",
108 Self::InvalidUtf8 => "invalid UTF-8",
109 Self::Cancelled => "cancelled",
110 Self::Deadline => "deadline exceeded",
111 #[cfg(not(unix))]
112 Self::Unsupported => "unsupported platform",
113 }
114 }
115}
116
117impl fmt::Display for ReadFailureKind {
118 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119 formatter.write_str(self.as_str())
120 }
121}
122
123#[derive(Debug, Clone)]
127pub(super) struct Fault {
128 stage: ReadStage,
129 kind: ReadFailureKind,
130 detail: String,
131}
132
133impl Fault {
134 pub(super) fn new(stage: ReadStage, kind: ReadFailureKind, detail: impl Into<String>) -> Self {
135 Self {
136 stage,
137 kind,
138 detail: detail.into(),
139 }
140 }
141
142 pub(super) fn connect(detail: impl Into<String>) -> Self {
145 Self::new(ReadStage::Connect, ReadFailureKind::Connect, detail)
146 }
147
148 pub(super) fn cancelled(stage: ReadStage) -> Self {
149 Self::new(stage, ReadFailureKind::Cancelled, "the read was cancelled")
150 }
151
152 pub(super) fn deadline(stage: ReadStage) -> Self {
153 Self::new(
154 stage,
155 ReadFailureKind::Deadline,
156 "connection, transfer or close exceeded the total deadline",
157 )
158 }
159
160 fn into_parts(self) -> (ReadStage, ReadFailureKind, String) {
161 (self.stage, self.kind, self.detail)
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct RemoteReadError {
170 remote: String,
171 stage: ReadStage,
172 kind: ReadFailureKind,
173 detail: String,
174 stderr: Option<String>,
175 exit: Option<String>,
176}
177
178impl RemoteReadError {
179 pub(super) fn bare(stage: ReadStage, kind: ReadFailureKind, detail: impl Into<String>) -> Self {
182 Self {
183 remote: String::new(),
184 stage,
185 kind,
186 detail: detail.into(),
187 stderr: None,
188 exit: None,
189 }
190 }
191
192 pub(super) fn fault(remote: &str, fault: Fault) -> Self {
193 let (stage, kind, detail) = fault.into_parts();
194 Self {
195 remote: remote.to_owned(),
196 stage,
197 kind,
198 detail,
199 stderr: None,
200 exit: None,
201 }
202 }
203
204 pub(super) fn remote(mut self, remote: &str) -> Self {
205 self.remote = remote.to_owned();
206 self
207 }
208
209 pub(super) fn stderr(mut self, stderr: Option<String>) -> Self {
210 self.stderr = stderr;
211 self
212 }
213
214 pub(super) fn exit(mut self, exit: Option<String>) -> Self {
215 self.exit = exit;
216 self
217 }
218
219 pub fn kind(&self) -> ReadFailureKind {
220 self.kind
221 }
222
223 pub fn is_cancellation(&self) -> bool {
225 self.kind == ReadFailureKind::Cancelled
226 }
227
228 pub fn hint(&self) -> Option<&'static str> {
230 match self.kind {
231 ReadFailureKind::Spawn => {
232 Some("install the OpenSSH client; strop runs ssh(1) found on PATH")
233 }
234 ReadFailureKind::Auth => Some(
235 "strop authenticates noninteractively: load the key into ssh-agent \
236 or configure it in ~/.ssh/config, then verify `ssh` to the host \
237 answers without any prompt",
238 ),
239 ReadFailureKind::Trust => Some(
240 "connect to the host once outside strop to establish trust, or \
241 repair its known_hosts entry; strop never accepts an unknown or \
242 changed host key",
243 ),
244 ReadFailureKind::Subsystem => {
245 Some("the remote sshd must offer the SFTP subsystem (internal-sftp)")
246 }
247 ReadFailureKind::TooLarge => Some(
248 "remote snapshots are capped at 256 MiB in memory; read a \
249 smaller file or tail it on the host",
250 ),
251 ReadFailureKind::InvalidUtf8 => {
252 Some("strop buffers are text; this remote file is not valid UTF-8")
253 }
254 ReadFailureKind::Deadline => Some(
255 "connection, transfer and close must finish within the total \
256 deadline; check reachability and file size",
257 ),
258 #[cfg(not(unix))]
259 ReadFailureKind::Unsupported => Some("remote reads require Unix process supervision"),
260 _ => None,
261 }
262 }
263
264 pub(super) fn refine_connect(&mut self) {
268 debug_assert_eq!(self.kind, ReadFailureKind::Connect);
269 let mut haystack = format!("{}\n{}", self.detail, self.stderr.as_deref().unwrap_or(""));
270 haystack.make_ascii_lowercase();
271 self.kind = classify_connect(&haystack);
272 }
273}
274
275fn classify_connect(haystack: &str) -> ReadFailureKind {
276 const AUTH: &[&str] = &[
277 "permission denied",
278 "authentication failed",
279 "no supported authentication methods",
280 "too many authentication failures",
281 "passphrase",
282 ];
283 const TRUST: &[&str] = &[
284 "host key verification failed",
285 "host key for server changed",
286 ];
287 const NETWORK: &[&str] = &[
288 "could not resolve hostname",
289 "name or service not known",
290 "connection refused",
291 "timed out",
292 "timeout",
293 "network is unreachable",
294 "no route to host",
295 "connection reset",
296 "connection aborted",
297 ];
298 const SUBSYSTEM: &[&str] = &["subsystem request failed"];
299 if TRUST.iter().any(|needle| haystack.contains(needle)) {
300 return ReadFailureKind::Trust;
301 }
302 if AUTH.iter().any(|needle| haystack.contains(needle)) {
303 return ReadFailureKind::Auth;
304 }
305 if SUBSYSTEM.iter().any(|needle| haystack.contains(needle)) {
306 return ReadFailureKind::Subsystem;
307 }
308 if NETWORK.iter().any(|needle| haystack.contains(needle)) {
309 return ReadFailureKind::Network;
310 }
311 ReadFailureKind::Connect
312}
313
314impl fmt::Display for RemoteReadError {
315 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316 if self.remote.is_empty() {
317 write!(
318 formatter,
319 "remote read failed at {}: {}",
320 self.stage, self.detail
321 )?;
322 } else {
323 write!(
324 formatter,
325 "remote read of {} failed at {}: {}",
326 self.remote, self.stage, self.detail
327 )?;
328 }
329 if let Some(stderr) = &self.stderr {
330 write!(formatter, "\nssh stderr: {stderr}")?;
331 }
332 if let Some(exit) = &self.exit {
333 write!(formatter, "\nssh exit: {exit}")?;
334 }
335 if let Some(hint) = self.hint() {
336 write!(formatter, "\nhint: {hint}")?;
337 }
338 Ok(())
339 }
340}
341
342impl std::error::Error for RemoteReadError {}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn refined(detail: &str, stderr: &str) -> ReadFailureKind {
349 let mut error = RemoteReadError::fault("ssh://host/file", Fault::connect(detail))
350 .stderr(Some(stderr.to_owned()));
351 error.refine_connect();
352 error.kind()
353 }
354
355 #[test]
356 fn host_key_refusal_is_trust() {
357 assert_eq!(
359 refined("", "Host key verification failed."),
360 ReadFailureKind::Trust
361 );
362 }
363
364 #[test]
365 fn batchmode_auth_failure_is_auth() {
366 assert_eq!(
367 refined("", "user@host: Permission denied (publickey)."),
368 ReadFailureKind::Auth
369 );
370 }
371
372 #[test]
373 fn missing_subsystem_is_named() {
374 assert_eq!(
375 refined("", "subsystem request failed on channel 0"),
376 ReadFailureKind::Subsystem
377 );
378 }
379
380 #[test]
381 fn network_failures_are_network() {
382 assert_eq!(
383 refined(
384 "",
385 "ssh: connect to host devbox port 22: Connection refused"
386 ),
387 ReadFailureKind::Network
388 );
389 }
390
391 #[test]
392 fn unclassified_stays_connect() {
393 assert_eq!(
394 refined("hello message invalid", ""),
395 ReadFailureKind::Connect
396 );
397 }
398
399 #[test]
400 fn trust_outranks_auth_wording() {
401 assert_eq!(
404 refined("", "Host key verification failed.\nPermission denied."),
405 ReadFailureKind::Trust
406 );
407 }
408
409 #[test]
410 fn display_carries_context_without_invented_hint() {
411 let error = RemoteReadError::fault(
412 "ssh://devbox/var/log/app.log",
413 Fault::new(
414 ReadStage::Transfer,
415 ReadFailureKind::ShortRead,
416 "expected 10 bytes, received 4",
417 ),
418 )
419 .stderr(Some("killed".to_owned()))
420 .exit(Some("signal: 9 (SIGKILL)".to_owned()));
421 let text = error.to_string();
422 assert!(text.contains("ssh://devbox/var/log/app.log"));
423 assert!(text.contains("transfer"));
424 assert!(text.contains("expected 10 bytes, received 4"));
425 assert!(text.contains("ssh stderr: killed"));
426 assert!(text.contains("ssh exit: signal: 9"));
427 assert!(!text.contains("hint:"));
428 }
429
430 #[test]
431 fn cancellation_is_identifiable() {
432 let error = RemoteReadError::fault("ssh://h/f", Fault::cancelled(ReadStage::Transfer));
433 assert!(error.is_cancellation());
434 }
435}