Skip to main content

strop_remote/transport/
error.rs

1//! Typed remote-read failures: the stage that stopped, a classified kind,
2//! bounded ssh(1) stderr and an actionable hint. Errors are diagnostics
3//! data, never buffer identity.
4
5use std::fmt;
6
7/// Where a remote read stopped. Retained so diagnostics name the phase
8/// instead of a bare transport error.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum ReadStage {
11    /// Starting the local ssh(1) client.
12    Spawn,
13    /// Negotiating the SFTP session: connection, authentication, host
14    /// keys, subsystem availability.
15    Connect,
16    /// Opening the remote file by name through SFTP.
17    Open,
18    /// Proving the opened handle is a regular file of known length.
19    Inspect,
20    /// Transferring exactly the length captured at inspection.
21    Transfer,
22    /// Validating the snapshot as UTF-8 text.
23    Validate,
24    /// Closing file and session within the deadline.
25    Teardown,
26    /// The pooled session itself: admission, connection availability,
27    /// actor state. No protocol exchange reached a phase above.
28    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/// What went wrong, classified far enough to act on. Auth, trust and
53/// install problems carry hints; the rest are honest transport facts.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum ReadFailureKind {
56    /// The local ssh(1) executable could not be started.
57    Spawn,
58    /// Noninteractive authentication failed (BatchMode refuses prompts).
59    Auth,
60    /// Host key unknown or changed; trust is refused, never auto-accepted.
61    Trust,
62    /// Network-level failure: DNS, routing, refusal, reset.
63    Network,
64    /// The remote host does not offer the sftp subsystem.
65    Subsystem,
66    /// Session establishment failed without a finer classification.
67    Connect,
68    /// The remote path does not exist.
69    NotFound,
70    /// The server denied access to the path.
71    Permission,
72    /// The SFTP protocol exchange failed.
73    Protocol,
74    /// Local pipe/transport I/O failure.
75    Io,
76    /// The opened handle is not proven to be a regular file.
77    NotRegularFile,
78    /// The server reported no length for the opened handle.
79    UnknownLength,
80    /// The snapshot exceeds the in-memory cap.
81    TooLarge,
82    /// Fewer bytes than the captured length arrived.
83    ShortRead,
84    /// The snapshot is not valid UTF-8.
85    InvalidUtf8,
86    /// The read was cancelled by its owner.
87    Cancelled,
88    /// The pooled session was stopped: explicit disconnect, or its last
89    /// lease went away.
90    Stopped,
91    /// No authenticated session exists for the endpoint, and the caller
92    /// refused to create one.
93    NotConnected,
94    /// The bounded session admission queue has no free slot.
95    QueueFull,
96    /// The path is not a directory where one is required.
97    NotDirectory,
98    /// The directory exceeds the bounded listing cap.
99    TooManyEntries,
100    /// The server does not advertise `expand-path@openssh.com`, so a home
101    /// query cannot be resolved. REALPATH is never guessed as a substitute.
102    HomeUnsupported,
103    /// The total connection/read/close deadline elapsed.
104    Deadline,
105    /// Process supervision is unavailable on this platform.
106    #[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/// The failure a worker sees before diagnostics are attached. The
149/// orchestrator turns it into a [`RemoteReadError`] with stderr, exit
150/// status and the remote URI.
151#[derive(Debug, Clone)]
152pub(crate) struct Fault {
153    stage: ReadStage,
154    kind: ReadFailureKind,
155    detail: String,
156    /// Set when even handle cleanup failed: the physical connection is
157    /// suspect and must be discarded, whatever the primary kind says.
158    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    /// Session establishment failed; the orchestrator refines the kind
172    /// once ssh's retained stderr is available.
173    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    /// Mark the owning connection as unsafe to reuse.
198    pub(crate) fn poisoned(mut self) -> Self {
199        self.poison = true;
200        self
201    }
202
203    /// Compose a primary fault with a failed cleanup exchange: keep the
204    /// primary diagnosis, and mark the connection unsafe to reuse.
205    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    /// The kind plus whether the connection must be discarded.
217    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/// A fully diagnosed remote read failure. Carries everything a user needs
227/// to act: stage, classification, detail, bounded ssh stderr, the child's
228/// exit line when observed, and a hint for fixable causes.
229#[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    /// A failure with no diagnostics to attach (early exit, unsupported
241    /// platform, spawn refusal).
242    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    /// Cancellation is reported so callers can prefer their own outcome.
285    pub fn is_cancellation(&self) -> bool {
286        self.kind == ReadFailureKind::Cancelled
287    }
288
289    /// An actionable instruction, when one exists.
290    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    /// Refine a generic connect failure now that ssh's stderr and the
342    /// protocol detail are both known. ssh's stderr wording is its
343    /// documented diagnostic surface, not an implementation detail of strop.
344    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        // ssh(1) prints this for unknown and changed keys under BatchMode.
435        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        // A changed key can also mention permission denied; trust is the
479        // actionable classification.
480        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}