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 Session,
29}
30
31impl ReadStage {
32 fn as_str(self) -> &'static str {
33 match self {
34 Self::Spawn => "spawn",
35 Self::Connect => "connect",
36 Self::Open => "open",
37 Self::Inspect => "inspect",
38 Self::Transfer => "transfer",
39 Self::Validate => "validate",
40 Self::Teardown => "teardown",
41 Self::Session => "session",
42 }
43 }
44}
45
46impl fmt::Display for ReadStage {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 formatter.write_str(self.as_str())
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum ReadFailureKind {
56 Spawn,
58 Auth,
60 Trust,
62 Network,
64 Subsystem,
66 Connect,
68 NotFound,
70 Permission,
72 Protocol,
74 Io,
76 NotRegularFile,
78 UnknownLength,
80 TooLarge,
82 ShortRead,
84 InvalidUtf8,
86 Cancelled,
88 Stopped,
91 NotConnected,
94 QueueFull,
96 NotDirectory,
98 TooManyEntries,
100 HomeUnsupported,
103 Deadline,
105 #[cfg(not(unix))]
107 Unsupported,
108}
109
110impl ReadFailureKind {
111 fn as_str(self) -> &'static str {
112 match self {
113 Self::Spawn => "local ssh unavailable",
114 Self::Auth => "authentication failed",
115 Self::Trust => "host key refused",
116 Self::Network => "network failure",
117 Self::Subsystem => "sftp subsystem unavailable",
118 Self::Connect => "connection failed",
119 Self::NotFound => "remote file missing",
120 Self::Permission => "remote access denied",
121 Self::Protocol => "sftp protocol failure",
122 Self::Io => "transport I/O failure",
123 Self::NotRegularFile => "not a regular file",
124 Self::UnknownLength => "unknown file length",
125 Self::TooLarge => "snapshot too large",
126 Self::ShortRead => "short read",
127 Self::InvalidUtf8 => "invalid UTF-8",
128 Self::Cancelled => "request cancelled",
129 Self::QueueFull => "session queue full",
130 Self::Stopped => "session stopped",
131 Self::NotConnected => "not connected",
132 Self::NotDirectory => "not a directory",
133 Self::TooManyEntries => "too many directory entries",
134 Self::HomeUnsupported => "home expansion unavailable",
135 Self::Deadline => "deadline exceeded",
136 #[cfg(not(unix))]
137 Self::Unsupported => "unsupported platform",
138 }
139 }
140}
141
142impl fmt::Display for ReadFailureKind {
143 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144 formatter.write_str(self.as_str())
145 }
146}
147
148#[derive(Debug, Clone)]
152pub(crate) struct Fault {
153 stage: ReadStage,
154 kind: ReadFailureKind,
155 detail: String,
156 poison: bool,
159}
160
161impl Fault {
162 pub(crate) fn new(stage: ReadStage, kind: ReadFailureKind, detail: impl Into<String>) -> Self {
163 Self {
164 stage,
165 kind,
166 detail: detail.into(),
167 poison: false,
168 }
169 }
170
171 pub(crate) fn connect(detail: impl Into<String>) -> Self {
174 Self::new(ReadStage::Connect, ReadFailureKind::Connect, detail)
175 }
176
177 pub(crate) fn cancelled(stage: ReadStage) -> Self {
178 Self::new(stage, ReadFailureKind::Cancelled, "the read was cancelled")
179 }
180
181 pub(crate) fn stopped(stage: ReadStage) -> Self {
182 Self::new(
183 stage,
184 ReadFailureKind::Stopped,
185 "the connection was stopped (disconnect or last lease released)",
186 )
187 }
188
189 pub(crate) fn deadline(stage: ReadStage) -> Self {
190 Self::new(
191 stage,
192 ReadFailureKind::Deadline,
193 "connection, transfer or close exceeded the total deadline",
194 )
195 }
196
197 pub(crate) fn poisoned(mut self) -> Self {
199 self.poison = true;
200 self
201 }
202
203 pub(crate) fn with_cleanup(self, cleanup: Fault) -> Fault {
206 Fault::new(
207 self.stage,
208 self.kind,
209 format!(
210 "{}; connection cleanup also failed: {}",
211 self.detail, cleanup.detail
212 ),
213 )
214 .poisoned()
215 }
216 pub(crate) fn disposition(&self) -> (ReadFailureKind, bool) {
218 (self.kind, self.poison)
219 }
220
221 fn into_parts(self) -> (ReadStage, ReadFailureKind, String) {
222 (self.stage, self.kind, self.detail)
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct RemoteReadError {
231 remote: String,
232 stage: ReadStage,
233 kind: ReadFailureKind,
234 detail: String,
235 stderr: Option<String>,
236 exit: Option<String>,
237}
238
239impl RemoteReadError {
240 pub(crate) fn bare(stage: ReadStage, kind: ReadFailureKind, detail: impl Into<String>) -> Self {
243 Self {
244 remote: String::new(),
245 stage,
246 kind,
247 detail: detail.into(),
248 stderr: None,
249 exit: None,
250 }
251 }
252
253 pub(crate) fn fault(remote: &str, fault: Fault) -> Self {
254 let (stage, kind, detail) = fault.into_parts();
255 Self {
256 remote: remote.to_owned(),
257 stage,
258 kind,
259 detail,
260 stderr: None,
261 exit: None,
262 }
263 }
264
265 pub(crate) fn remote(mut self, remote: &str) -> Self {
266 self.remote = remote.to_owned();
267 self
268 }
269
270 pub(crate) fn stderr(mut self, stderr: Option<String>) -> Self {
271 self.stderr = stderr;
272 self
273 }
274
275 pub(crate) fn exit(mut self, exit: Option<String>) -> Self {
276 self.exit = exit;
277 self
278 }
279
280 pub fn kind(&self) -> ReadFailureKind {
281 self.kind
282 }
283
284 pub fn is_cancellation(&self) -> bool {
286 self.kind == ReadFailureKind::Cancelled
287 }
288
289 pub fn hint(&self) -> Option<&'static str> {
291 match self.kind {
292 ReadFailureKind::Spawn => {
293 Some("install the OpenSSH client; strop runs ssh(1) found on PATH")
294 }
295 ReadFailureKind::Auth => Some(
296 "strop authenticates noninteractively: load the key into ssh-agent \
297 or configure it in ~/.ssh/config, then verify `ssh` to the host \
298 answers without any prompt",
299 ),
300 ReadFailureKind::Trust => Some(
301 "connect to the host once outside strop to establish trust, or \
302 repair its known_hosts entry; strop never accepts an unknown or \
303 changed host key",
304 ),
305 ReadFailureKind::Subsystem => {
306 Some("the remote sshd must offer the SFTP subsystem (internal-sftp)")
307 }
308 ReadFailureKind::TooLarge => Some(
309 "remote snapshots are capped at 256 MiB in memory; read a \
310 smaller file or tail it on the host",
311 ),
312 ReadFailureKind::InvalidUtf8 => {
313 Some("strop buffers are text; this remote file is not valid UTF-8")
314 }
315 ReadFailureKind::Deadline => Some(
316 "connection, transfer and close must finish within the total \
317 deadline; check reachability and file size",
318 ),
319 ReadFailureKind::Stopped => Some(
320 "the session was closed by disconnect or by releasing its last \
321 lease; retrying establishes a fresh connection",
322 ),
323 ReadFailureKind::NotConnected => Some(
324 "no authenticated session exists for this endpoint: open a \
325 remote location or connect explicitly first — completion and \
326 browsing of cached entries never authenticate on their own",
327 ),
328 ReadFailureKind::TooManyEntries => {
329 Some("the directory exceeds the browseable entry cap; narrow the path")
330 }
331 ReadFailureKind::HomeUnsupported => Some(
332 "the server does not advertise expand-path@openssh.com; address \
333 the file by its absolute path instead of `~`",
334 ),
335 #[cfg(not(unix))]
336 ReadFailureKind::Unsupported => Some("remote reads require Unix process supervision"),
337 _ => None,
338 }
339 }
340
341 pub(crate) fn refine_connect(&mut self) {
345 debug_assert_eq!(self.kind, ReadFailureKind::Connect);
346 let mut haystack = format!("{}\n{}", self.detail, self.stderr.as_deref().unwrap_or(""));
347 haystack.make_ascii_lowercase();
348 self.kind = classify_connect(&haystack);
349 }
350}
351
352fn classify_connect(haystack: &str) -> ReadFailureKind {
353 const AUTH: &[&str] = &[
354 "permission denied",
355 "authentication failed",
356 "no supported authentication methods",
357 "too many authentication failures",
358 "passphrase",
359 ];
360 const TRUST: &[&str] = &[
361 "host key verification failed",
362 "host key for server changed",
363 ];
364 const NETWORK: &[&str] = &[
365 "could not resolve hostname",
366 "name or service not known",
367 "connection refused",
368 "timed out",
369 "timeout",
370 "network is unreachable",
371 "no route to host",
372 "connection reset",
373 "connection aborted",
374 ];
375 const SUBSYSTEM: &[&str] = &["subsystem request failed"];
376 if TRUST.iter().any(|needle| haystack.contains(needle)) {
377 return ReadFailureKind::Trust;
378 }
379 if AUTH.iter().any(|needle| haystack.contains(needle)) {
380 return ReadFailureKind::Auth;
381 }
382 if SUBSYSTEM.iter().any(|needle| haystack.contains(needle)) {
383 return ReadFailureKind::Subsystem;
384 }
385 if NETWORK.iter().any(|needle| haystack.contains(needle)) {
386 return ReadFailureKind::Network;
387 }
388 ReadFailureKind::Connect
389}
390
391impl fmt::Display for RemoteReadError {
392 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
393 if self.remote.is_empty() {
394 write!(
395 formatter,
396 "remote read failed at {}: {}",
397 self.stage, self.detail
398 )?;
399 } else {
400 write!(
401 formatter,
402 "remote read of {} failed at {}: {}",
403 self.remote, self.stage, self.detail
404 )?;
405 }
406 if let Some(stderr) = &self.stderr {
407 write!(formatter, "\nssh stderr: {stderr}")?;
408 }
409 if let Some(exit) = &self.exit {
410 write!(formatter, "\nssh exit: {exit}")?;
411 }
412 if let Some(hint) = self.hint() {
413 write!(formatter, "\nhint: {hint}")?;
414 }
415 Ok(())
416 }
417}
418
419impl std::error::Error for RemoteReadError {}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn refined(detail: &str, stderr: &str) -> ReadFailureKind {
426 let mut error = RemoteReadError::fault("ssh://host/file", Fault::connect(detail))
427 .stderr(Some(stderr.to_owned()));
428 error.refine_connect();
429 error.kind()
430 }
431
432 #[test]
433 fn host_key_refusal_is_trust() {
434 assert_eq!(
436 refined("", "Host key verification failed."),
437 ReadFailureKind::Trust
438 );
439 }
440
441 #[test]
442 fn batchmode_auth_failure_is_auth() {
443 assert_eq!(
444 refined("", "user@host: Permission denied (publickey)."),
445 ReadFailureKind::Auth
446 );
447 }
448
449 #[test]
450 fn missing_subsystem_is_named() {
451 assert_eq!(
452 refined("", "subsystem request failed on channel 0"),
453 ReadFailureKind::Subsystem
454 );
455 }
456
457 #[test]
458 fn network_failures_are_network() {
459 assert_eq!(
460 refined(
461 "",
462 "ssh: connect to host devbox port 22: Connection refused"
463 ),
464 ReadFailureKind::Network
465 );
466 }
467
468 #[test]
469 fn unclassified_stays_connect() {
470 assert_eq!(
471 refined("hello message invalid", ""),
472 ReadFailureKind::Connect
473 );
474 }
475
476 #[test]
477 fn trust_outranks_auth_wording() {
478 assert_eq!(
481 refined("", "Host key verification failed.\nPermission denied."),
482 ReadFailureKind::Trust
483 );
484 }
485
486 #[test]
487 fn display_carries_context_without_invented_hint() {
488 let error = RemoteReadError::fault(
489 "ssh://devbox/var/log/app.log",
490 Fault::new(
491 ReadStage::Transfer,
492 ReadFailureKind::ShortRead,
493 "expected 10 bytes, received 4",
494 ),
495 )
496 .stderr(Some("killed".to_owned()))
497 .exit(Some("signal: 9 (SIGKILL)".to_owned()));
498 let text = error.to_string();
499 assert!(text.contains("ssh://devbox/var/log/app.log"));
500 assert!(text.contains("transfer"));
501 assert!(text.contains("expected 10 bytes, received 4"));
502 assert!(text.contains("ssh stderr: killed"));
503 assert!(text.contains("ssh exit: signal: 9"));
504 assert!(!text.contains("hint:"));
505 }
506
507 #[test]
508 fn cancellation_is_identifiable() {
509 let error = RemoteReadError::fault("ssh://h/f", Fault::cancelled(ReadStage::Transfer));
510 assert!(error.is_cancellation());
511 }
512}