Skip to main content

git_ark/
sshdiag.rs

1//! Turn raw `ssh` stderr into an actionable diagnosis.
2//!
3//! Diagnosis is pure and always-on: `hostcmd::host_add`'s probe-failure path
4//! classifies the underlying ssh error and, when it recognizes the shape,
5//! prints a hint alongside it — never in place of it. The *doing* (`host
6//! setup-key`, Task 2) is a separate, explicit command; this module only
7//! looks and tells.
8
9/// What went wrong establishing an SSH connection, as inferred from stderr.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum SshDiagnosis {
12    /// The host is reachable and sshd is up, but no key authenticates.
13    PermissionDenied,
14    /// TCP connected (or was actively rejected) but nothing is listening.
15    ConnectionRefused,
16    /// No route to the host at all — DNS, network, or a dead box.
17    Unreachable,
18    /// The host's key changed since it was last trusted.
19    HostKeyChanged,
20    /// Unrecognized — the caller falls back to the raw error, no fabricated
21    /// advice.
22    Other,
23}
24
25/// Classify `stderr` from a failed `ssh` invocation. Substring match, most
26/// specific first: `HostKeyChanged` and `ConnectionRefused` are checked
27/// before the more generic `Unreachable`/`PermissionDenied`, and
28/// `PermissionDenied`'s "Permission denied"/"publickey" are checked before
29/// falling back to `Other`.
30pub fn classify_ssh_error(stderr: &str) -> SshDiagnosis {
31    if stderr.contains("REMOTE HOST IDENTIFICATION HAS CHANGED")
32        || stderr.contains("Host key verification failed")
33    {
34        return SshDiagnosis::HostKeyChanged;
35    }
36    if stderr.contains("Connection refused") {
37        return SshDiagnosis::ConnectionRefused;
38    }
39    if stderr.contains("timed out")
40        || stderr.contains("No route to host")
41        || stderr.contains("Could not resolve hostname")
42        || stderr.contains("Network is unreachable")
43    {
44        return SshDiagnosis::Unreachable;
45    }
46    if stderr.contains("Permission denied") || stderr.contains("publickey") {
47        return SshDiagnosis::PermissionDenied;
48    }
49    SshDiagnosis::Other
50}
51
52/// An actionable, multi-line hint for `d` — empty for `Other`, so the caller
53/// falls back to the raw ssh error rather than showing a blank line.
54pub fn diagnosis_message(d: SshDiagnosis, target: &str, port: u16) -> String {
55    match d {
56        SshDiagnosis::PermissionDenied => format!(
57            "can't authenticate to `{target}` — key auth isn't set up on this host. \
58             Fix it with `git-ark host setup-key {target} [--port {port}]` (generates + \
59             copies a key), or `ssh-copy-id -p {port} {target}` yourself, then re-run \
60             `git-ark host add`."
61        ),
62        SshDiagnosis::ConnectionRefused => format!(
63            "nothing is listening on `{target}:{port}` — is the host up and is sshd on \
64             that port?"
65        ),
66        SshDiagnosis::Unreachable => {
67            format!("`{target}` is unreachable — check the hostname/network.")
68        }
69        SshDiagnosis::HostKeyChanged => format!(
70            "`{target}`'s SSH host key changed — if expected, run `ssh-keygen -R \
71             '[host]:{port}'`; if not, investigate before proceeding."
72        ),
73        SshDiagnosis::Other => String::new(),
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn classifies_permission_denied() {
83        assert_eq!(
84            classify_ssh_error("ark@nas: Permission denied (publickey)."),
85            SshDiagnosis::PermissionDenied
86        );
87    }
88
89    #[test]
90    fn classifies_connection_refused() {
91        assert_eq!(
92            classify_ssh_error("ssh: connect to host x port 22: Connection refused"),
93            SshDiagnosis::ConnectionRefused
94        );
95    }
96
97    #[test]
98    fn classifies_unreachable() {
99        assert_eq!(
100            classify_ssh_error("ssh: connect to host x port 22: Operation timed out"),
101            SshDiagnosis::Unreachable
102        );
103        assert_eq!(
104            classify_ssh_error("ssh: connect to host x port 22: No route to host"),
105            SshDiagnosis::Unreachable
106        );
107        assert_eq!(
108            classify_ssh_error("ssh: Could not resolve hostname x: nodename nor servname provided"),
109            SshDiagnosis::Unreachable
110        );
111    }
112
113    #[test]
114    fn classifies_host_key_changed() {
115        assert_eq!(
116            classify_ssh_error(
117                "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n\
118                 @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @\n\
119                 @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"
120            ),
121            SshDiagnosis::HostKeyChanged
122        );
123        assert_eq!(
124            classify_ssh_error("Host key verification failed."),
125            SshDiagnosis::HostKeyChanged
126        );
127    }
128
129    #[test]
130    fn classifies_other_for_unrecognized_stderr() {
131        assert_eq!(
132            classify_ssh_error("some completely unrelated error message"),
133            SshDiagnosis::Other
134        );
135    }
136
137    #[test]
138    fn diagnosis_message_permission_denied_names_setup_key_and_target() {
139        let msg = diagnosis_message(SshDiagnosis::PermissionDenied, "ark@nas", 2222);
140        assert!(msg.contains("setup-key"));
141        assert!(msg.contains("ark@nas"));
142    }
143
144    #[test]
145    fn diagnosis_message_other_is_empty() {
146        assert_eq!(diagnosis_message(SshDiagnosis::Other, "ark@nas", 22), "");
147    }
148}