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}
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/// What went wrong, classified far enough to act on. Auth, trust and
49/// install problems carry hints; the rest are honest transport facts.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum ReadFailureKind {
52    /// The local ssh(1) executable could not be started.
53    Spawn,
54    /// Noninteractive authentication failed (BatchMode refuses prompts).
55    Auth,
56    /// Host key unknown or changed; trust is refused, never auto-accepted.
57    Trust,
58    /// Network-level failure: DNS, routing, refusal, reset.
59    Network,
60    /// The remote host does not offer the sftp subsystem.
61    Subsystem,
62    /// Session establishment failed without a finer classification.
63    Connect,
64    /// The remote path does not exist.
65    NotFound,
66    /// The server denied access to the path.
67    Permission,
68    /// The SFTP protocol exchange failed.
69    Protocol,
70    /// Local pipe/transport I/O failure.
71    Io,
72    /// The opened handle is not proven to be a regular file.
73    NotRegularFile,
74    /// The server reported no length for the opened handle.
75    UnknownLength,
76    /// The snapshot exceeds the in-memory cap.
77    TooLarge,
78    /// Fewer bytes than the captured length arrived.
79    ShortRead,
80    /// The snapshot is not valid UTF-8.
81    InvalidUtf8,
82    /// The read was cancelled by its owner.
83    Cancelled,
84    /// The total connection/read/close deadline elapsed.
85    Deadline,
86    /// Process supervision is unavailable on this platform.
87    #[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/// The failure a worker sees before diagnostics are attached. The
124/// orchestrator turns it into a [`RemoteReadError`] with stderr, exit
125/// status and the remote URI.
126#[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    /// Session establishment failed; the orchestrator refines the kind
143    /// once ssh's retained stderr is available.
144    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/// A fully diagnosed remote read failure. Carries everything a user needs
166/// to act: stage, classification, detail, bounded ssh stderr, the child's
167/// exit line when observed, and a hint for fixable causes.
168#[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    /// A failure with no diagnostics to attach (early exit, unsupported
180    /// platform, spawn refusal).
181    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    /// Cancellation is reported so callers can prefer their own outcome.
224    pub fn is_cancellation(&self) -> bool {
225        self.kind == ReadFailureKind::Cancelled
226    }
227
228    /// An actionable instruction, when one exists.
229    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    /// Refine a generic connect failure now that ssh's stderr and the
265    /// protocol detail are both known. ssh's stderr wording is its
266    /// documented diagnostic surface, not an implementation detail of strop.
267    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        // ssh(1) prints this for unknown and changed keys under BatchMode.
358        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        // A changed key can also mention permission denied; trust is the
402        // actionable classification.
403        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}