Skip to main content

ssh_cli/
errors.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Structured error types for ssh-cli.
3//!
4//! Defines [`SshCliError`] with domain error categories used by the CLI.
5//! Display strings are technical English. Localized UI copy belongs in `i18n`.
6
7use thiserror::Error;
8
9/// Domain errors produced by ssh-cli operations.
10#[derive(Debug, Error)]
11pub enum SshCliError {
12    /// Underlying I/O error.
13    #[error("I/O error: {0}")]
14    Io(#[from] std::io::Error),
15
16    /// JSON serialization or deserialization error.
17    #[error("JSON error: {0}")]
18    Json(#[from] serde_json::Error),
19
20    /// TOML deserialization error.
21    #[error("TOML read error: {0}")]
22    TomlDe(#[from] toml::de::Error),
23
24    /// TOML serialization error.
25    #[error("TOML write error: {0}")]
26    TomlSer(#[from] toml::ser::Error),
27
28    /// SSH connection-layer error.
29    #[error("SSH connection error: {0}")]
30    SshConnection(String),
31
32    /// SSH authentication error with detail.
33    #[error("SSH authentication error: {0}")]
34    SshAuthentication(String),
35
36    /// TCP/SSH connection failed before authentication.
37    #[error("SSH connection failed: {0}")]
38    ConnectionFailed(String),
39
40    /// SSH authentication rejected by the server.
41    #[error(
42        "SSH authentication failed; try --password-stdin, --key PATH, --key-passphrase-stdin, or verify the user"
43    )]
44    AuthenticationFailed,
45
46    /// Host key diverged from known_hosts (possible MITM).
47    #[error(
48        "host key changed for {host}:{port}: expected {expected}, got {obtained} (use --replace-host-key if legitimate)"
49    )]
50    HostKeyChanged {
51        /// Host name.
52        host: String,
53        /// Port.
54        port: u16,
55        /// Expected fingerprint.
56        expected: String,
57        /// Observed fingerprint.
58        obtained: String,
59    },
60
61    /// Command exceeds `max_command_chars`.
62    #[error("command exceeds max_command_chars ({max}): {len} characters")]
63    CommandTooLong {
64        /// Configured limit.
65        max: usize,
66        /// Command length.
67        len: usize,
68    },
69
70    /// Elevation disabled (`disable_sudo`).
71    #[error("sudo/su disabled for this host (disable_sudo)")]
72    SudoDisabled,
73
74    /// Missing `su` password for `su-exec`.
75    #[error("su_password not configured; use vps edit --su-password or --su-password-stdin")]
76    SuPasswordMissing,
77
78    /// Failed to open or operate an SSH channel.
79    #[error("SSH channel failed: {0}")]
80    ChannelFailed(String),
81
82    /// SSH operation timed out.
83    #[error("SSH timeout after {0}ms")]
84    SshTimeout(u64),
85
86    /// Remote command exited non-zero.
87    #[error("command failed with exit code {exit_code}: {stderr}")]
88    CommandFailed {
89        /// Remote process exit code.
90        exit_code: i32,
91        /// Truncated stderr snippet.
92        stderr: String,
93    },
94
95    /// VPS name not found in the registry.
96    #[error("VPS '{0}' not found in registry")]
97    VpsNotFound(String),
98
99    /// No active VPS (`active` sibling file missing) — GAP-SSH-EXIT-002.
100    #[error("No active VPS. Run 'ssh-cli connect <NAME>' first.")]
101    NoActiveVps,
102
103    /// Duplicate VPS name in the registry.
104    #[error("VPS '{0}' already exists in registry")]
105    VpsDuplicate(String),
106
107    /// Local or remote file not found.
108    #[error("file not found: {0}")]
109    FileNotFound(String),
110
111    /// Invalid CLI argument.
112    #[error("invalid argument: {0}")]
113    InvalidArgument(String),
114
115    /// Generic timeout.
116    #[error("timeout exceeded after {0}ms")]
117    Timeout(u64),
118
119    /// XDG config directory unavailable.
120    #[error("configuration directory unavailable")]
121    XdgDirectory,
122
123    /// Incompatible schema version.
124    #[error("incompatible schema version: expected {expected}, found {found}")]
125    SchemaIncompatible {
126        /// Expected schema version.
127        expected: u32,
128        /// Found schema version.
129        found: u32,
130    },
131
132    /// Uncategorized error.
133    #[error("error: {0}")]
134    Generic(String),
135}
136
137/// Process exit codes aligned with sysexits.h and Unix signal conventions.
138pub mod exit_codes {
139    /// Success.
140    pub const EX_OK: i32 = 0;
141    /// Generic domain failure.
142    pub const EX_GENERAL: i32 = 1;
143    /// Incorrect CLI usage.
144    pub const EX_USAGE: i32 = 64;
145    /// Invalid input data.
146    pub const EX_DATAERR: i32 = 65;
147    /// Input not found.
148    pub const EX_NOINPUT: i32 = 66;
149    /// Cannot create output.
150    pub const EX_CANTCREAT: i32 = 73;
151    /// I/O error.
152    pub const EX_IOERR: i32 = 74;
153    /// Permission denied.
154    pub const EX_NOPERM: i32 = 77;
155    /// Terminated by SIGINT (Ctrl+C).
156    pub const EX_SIGINT: i32 = 130;
157    /// Terminated by SIGTERM.
158    pub const EX_SIGTERM: i32 = 143;
159}
160
161impl SshCliError {
162    /// Returns the sysexits.h exit code for this error.
163    #[must_use]
164    pub fn exit_code(&self) -> i32 {
165        match self {
166            Self::Io(_) => exit_codes::EX_IOERR,
167            Self::Json(_) => exit_codes::EX_DATAERR,
168            Self::TomlDe(_) => exit_codes::EX_DATAERR,
169            Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
170            Self::SshConnection(_) => exit_codes::EX_IOERR,
171            // GAP-AUD-020: authentication failures use EX_NOPERM (77); connect/IO stay 74.
172            Self::SshAuthentication(_) => exit_codes::EX_NOPERM,
173            Self::ConnectionFailed(_) => exit_codes::EX_IOERR,
174            Self::AuthenticationFailed => exit_codes::EX_NOPERM,
175            Self::HostKeyChanged { .. } => exit_codes::EX_NOPERM,
176            Self::CommandTooLong { .. } => exit_codes::EX_USAGE,
177            Self::SudoDisabled => exit_codes::EX_NOPERM,
178            Self::SuPasswordMissing => exit_codes::EX_USAGE,
179            Self::ChannelFailed(_) => exit_codes::EX_IOERR,
180            Self::SshTimeout(_) => exit_codes::EX_IOERR,
181            Self::CommandFailed { .. } => exit_codes::EX_GENERAL,
182            Self::VpsNotFound(_) => exit_codes::EX_NOINPUT,
183            Self::NoActiveVps => exit_codes::EX_NOINPUT,
184            Self::VpsDuplicate(_) => exit_codes::EX_USAGE,
185            Self::FileNotFound(_) => exit_codes::EX_NOINPUT,
186            Self::InvalidArgument(_) => exit_codes::EX_USAGE,
187            Self::Timeout(_) => exit_codes::EX_IOERR,
188            Self::XdgDirectory => exit_codes::EX_CANTCREAT,
189            Self::SchemaIncompatible { .. } => exit_codes::EX_DATAERR,
190            Self::Generic(_) => exit_codes::EX_GENERAL,
191        }
192    }
193}
194
195/// Result alias using [`SshCliError`].
196pub type SshCliResult<T> = std::result::Result<T, SshCliError>;
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn vps_not_found_message_contains_name() {
204        let err = SshCliError::VpsNotFound("prod".into());
205        assert!(err.to_string().contains("prod"));
206    }
207
208    #[test]
209    fn vps_duplicate_message_contains_name() {
210        let err = SshCliError::VpsDuplicate("vps-1".into());
211        assert!(err.to_string().contains("already exists"));
212    }
213
214    #[test]
215    fn exit_code_auth_failed_is_noperm() {
216        assert_eq!(
217            SshCliError::AuthenticationFailed.exit_code(),
218            exit_codes::EX_NOPERM
219        );
220    }
221
222    #[test]
223    fn exit_code_command_failed_is_general_not_remote() {
224        let e = SshCliError::CommandFailed {
225            exit_code: 64,
226            stderr: "usage".into(),
227        };
228        assert_eq!(e.exit_code(), exit_codes::EX_GENERAL);
229    }
230}