Skip to main content

ssh_cli/
erros.rs

1//! Tipos de erro do ssh-cli.
2//!
3//! Define o enum [`ErroSshCli`] com todas as categorias de erro do domínio
4//! usadas pela CLI.
5
6use thiserror::Error;
7
8/// Enum com todos os erros possíveis do ssh-cli.
9#[derive(Debug, Error)]
10pub enum ErroSshCli {
11    /// Erro de I/O subjacente.
12    #[error("erro de I/O: {0}")]
13    Io(#[from] std::io::Error),
14
15    /// Erro de serialização/deserialização JSON.
16    #[error("erro de JSON: {0}")]
17    Json(#[from] serde_json::Error),
18
19    /// Erro de deserialização TOML.
20    #[error("erro de TOML (leitura): {0}")]
21    TomlDe(#[from] toml::de::Error),
22
23    /// Erro de serialização TOML.
24    #[error("erro de TOML (escrita): {0}")]
25    TomlSer(#[from] toml::ser::Error),
26
27    /// Erro de conexão SSH.
28    #[error("erro de conexão SSH: {0}")]
29    ConexaoSsh(String),
30
31    /// Erro de autenticação SSH.
32    #[error("erro de autenticação SSH: {0}")]
33    AutenticacaoSsh(String),
34
35    /// Falha ao estabelecer conexão TCP/SSH (passo anterior à autenticação).
36    #[error("conexão SSH falhou: {0}")]
37    ConexaoFalhou(String),
38
39    /// Autenticação SSH rejeitada pelo servidor.
40    #[error(
41        "autenticação SSH falhou; tente --password-stdin, --key PATH, --key-passphrase-stdin ou verifique o usuário"
42    )]
43    AutenticacaoFalhou,
44
45    /// Host key divergiu do known_hosts (possível MITM).
46    #[error(
47        "host key mudou para {host}:{porta}: esperado {esperado}, obtido {obtido} (use --replace-host-key se a troca for legítima)"
48    )]
49    HostKeyMudou {
50        /// Host.
51        host: String,
52        /// Porta.
53        porta: u16,
54        /// Fingerprint esperado.
55        esperado: String,
56        /// Fingerprint obtido.
57        obtido: String,
58    },
59
60    /// Comando excede `max_command_chars`.
61    #[error("comando excede max_command_chars ({max}): {len} caracteres")]
62    ComandoMuitoLongo {
63        /// Limite configurado.
64        max: usize,
65        /// Tamanho do comando.
66        len: usize,
67    },
68
69    /// Elevação desabilitada (`disable_sudo`).
70    #[error("sudo/su desabilitado para este host (disable_sudo)")]
71    SudoDesabilitado,
72
73    /// Senha su ausente para `su-exec`.
74    #[error("senha_su não configurada; use vps edit --su-password ou --su-password-stdin")]
75    SenhaSuAusente,
76
77    /// Falha ao abrir ou operar um canal SSH.
78    #[error("canal SSH falhou: {0}")]
79    CanalFalhou(String),
80
81    /// Timeout específico em operação SSH.
82    #[error("timeout SSH após {0}ms")]
83    TimeoutSsh(u64),
84
85    /// Comando remoto terminou com código de saída diferente de zero.
86    #[error("comando falhou com exit code {exit_code}: {stderr}")]
87    ComandoFalhou {
88        /// Código de saída retornado pelo comando remoto.
89        exit_code: i32,
90        /// Trecho (possivelmente truncado) de stderr.
91        stderr: String,
92    },
93
94    /// VPS não encontrada no registro.
95    #[error("VPS '{0}' não encontrada no registro")]
96    VpsNaoEncontrada(String),
97
98    /// Nenhuma VPS ativa (arquivo `active` ausente) — GAP-SSH-EXIT-002.
99    #[error("Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.")]
100    NenhumaVpsAtiva,
101
102    /// VPS com nome duplicado no registro.
103    #[error("VPS '{0}' já existe no registro")]
104    VpsDuplicada(String),
105
106    /// Arquivo local não encontrado.
107    #[error("arquivo não encontrado: {0}")]
108    ArquivoNaoEncontrado(String),
109
110    /// Argumento inválido recebido via CLI.
111    #[error("argumento inválido: {0}")]
112    ArgumentoInvalido(String),
113
114    /// Timeout excedido em operação.
115    #[error("timeout excedido após {0}ms")]
116    Timeout(u64),
117
118    /// Erro de diretório XDG.
119    #[error("diretório de configuração indisponível")]
120    DiretorioXdg,
121
122    /// Versão de schema incompatível.
123    #[error("versão de schema incompatível: esperada {esperada}, encontrada {encontrada}")]
124    SchemaIncompativel {
125        /// Versão esperada.
126        esperada: u32,
127        /// Versão encontrada no arquivo.
128        encontrada: u32,
129    },
130
131    /// Erro genérico não categorizado.
132    #[error("erro: {0}")]
133    Generico(String),
134}
135
136/// Exit codes padronizados conforme sysexits.h e convenções de sinais Unix.
137pub mod exit_codes {
138    /// Sucesso.
139    pub const EX_OK: i32 = 0;
140    /// Erro genérico de domínio.
141    pub const EX_GENERAL: i32 = 1;
142    /// Uso incorreto da CLI (argumento inválido).
143    pub const EX_USAGE: i32 = 64;
144    /// Dados de entrada inválidos.
145    pub const EX_DATAERR: i32 = 65;
146    /// Entrada não encontrada.
147    pub const EX_NOINPUT: i32 = 66;
148    /// Não foi possível criar saída.
149    pub const EX_CANTCREAT: i32 = 73;
150    /// Erro de I/O.
151    pub const EX_IOERR: i32 = 74;
152    /// Permissão negada.
153    pub const EX_NOPERM: i32 = 77;
154    /// Terminado por SIGINT (Ctrl+C).
155    pub const EX_SIGINT: i32 = 130;
156    /// Terminado por SIGTERM.
157    pub const EX_SIGTERM: i32 = 143;
158}
159
160impl ErroSshCli {
161    /// Retorna o exit code sysexits.h correspondente a este erro.
162    #[must_use]
163    pub fn exit_code(&self) -> i32 {
164        match self {
165            Self::Io(_) => exit_codes::EX_IOERR,
166            Self::Json(_) => exit_codes::EX_DATAERR,
167            Self::TomlDe(_) => exit_codes::EX_DATAERR,
168            Self::TomlSer(_) => exit_codes::EX_CANTCREAT,
169            Self::ConexaoSsh(_) => exit_codes::EX_IOERR,
170            Self::AutenticacaoSsh(_) => exit_codes::EX_IOERR,
171            Self::ConexaoFalhou(_) => exit_codes::EX_IOERR,
172            Self::AutenticacaoFalhou => exit_codes::EX_NOPERM,
173            Self::HostKeyMudou { .. } => exit_codes::EX_NOPERM,
174            Self::ComandoMuitoLongo { .. } => exit_codes::EX_USAGE,
175            Self::SudoDesabilitado => exit_codes::EX_NOPERM,
176            Self::SenhaSuAusente => exit_codes::EX_USAGE,
177            Self::CanalFalhou(_) => exit_codes::EX_IOERR,
178            Self::TimeoutSsh(_) => exit_codes::EX_IOERR,
179            // GAP-SSH-EXIT-001: não colidir sysexits com exit remoto (ex.: 64).
180            // O código remoto fica na mensagem / envelope JSON (`remote_exit_code`).
181            Self::ComandoFalhou { .. } => exit_codes::EX_GENERAL,
182            Self::VpsNaoEncontrada(_) => exit_codes::EX_NOINPUT,
183            Self::NenhumaVpsAtiva => exit_codes::EX_NOINPUT,
184            Self::VpsDuplicada(_) => exit_codes::EX_USAGE,
185            Self::ArquivoNaoEncontrado(_) => exit_codes::EX_NOINPUT,
186            Self::ArgumentoInvalido(_) => exit_codes::EX_USAGE,
187            Self::Timeout(_) => exit_codes::EX_IOERR,
188            Self::DiretorioXdg => exit_codes::EX_CANTCREAT,
189            Self::SchemaIncompativel { .. } => exit_codes::EX_DATAERR,
190            Self::Generico(_) => exit_codes::EX_GENERAL,
191        }
192    }
193}
194
195/// Alias de `Result` usando o tipo de erro do ssh-cli.
196pub type ResultadoSshCli<T> = std::result::Result<T, ErroSshCli>;
197
198#[cfg(test)]
199mod testes {
200    use super::*;
201
202    #[test]
203    fn vps_nao_encontrada_mensagem_contem_nome() {
204        let erro = ErroSshCli::VpsNaoEncontrada("producao".into());
205        assert!(erro.to_string().contains("producao"));
206    }
207
208    #[test]
209    fn vps_duplicada_mensagem_contem_nome() {
210        let erro = ErroSshCli::VpsDuplicada("vps-1".into());
211        let msg = erro.to_string();
212        assert!(msg.contains("vps-1"));
213        assert!(msg.contains("já existe"));
214    }
215
216    #[test]
217    fn erro_io_exibe_mensagem() {
218        let erro = ErroSshCli::from(std::io::Error::new(
219            std::io::ErrorKind::NotFound,
220            "arquivo nao encontrado",
221        ));
222        let msg = erro.to_string();
223        assert!(msg.contains("I/O") || msg.contains("arquivo nao encontrado"));
224    }
225
226    #[test]
227    fn erro_toml_de_exibe_mensagem() {
228        let toml_err = "invalid TOML".parse::<toml::Value>().unwrap_err();
229        let erro = ErroSshCli::TomlDe(toml_err);
230        let msg = erro.to_string();
231        assert!(msg.contains("TOML") || msg.contains("leitura"));
232    }
233
234    #[test]
235    fn erro_tipo_servidor_vps_nao_encontrada() {
236        let erro = ErroSshCli::VpsNaoEncontrada("servidor-x".into());
237        let msg = erro.to_string();
238        assert!(msg.contains("servidor-x"));
239        assert!(msg.contains("não encontrada") || msg.contains("not found"));
240    }
241
242    #[test]
243    fn exit_code_io_retorna_ioerr() {
244        let e = ErroSshCli::Io(std::io::Error::other("teste"));
245        assert_eq!(e.exit_code(), exit_codes::EX_IOERR);
246    }
247
248    #[test]
249    fn exit_code_autenticacao_falhou_retorna_noperm() {
250        assert_eq!(
251            ErroSshCli::AutenticacaoFalhou.exit_code(),
252            exit_codes::EX_NOPERM
253        );
254    }
255
256    #[test]
257    fn exit_code_vps_nao_encontrada_retorna_noinput() {
258        let e = ErroSshCli::VpsNaoEncontrada("teste".to_string());
259        assert_eq!(e.exit_code(), exit_codes::EX_NOINPUT);
260    }
261
262    #[test]
263    fn exit_code_nenhuma_vps_ativa_retorna_noinput() {
264        assert_eq!(
265            ErroSshCli::NenhumaVpsAtiva.exit_code(),
266            exit_codes::EX_NOINPUT
267        );
268    }
269
270    #[test]
271    fn exit_code_comando_falhou_usa_ex_general_nao_remoto() {
272        let e = ErroSshCli::ComandoFalhou {
273            exit_code: 127,
274            stderr: "not found".to_string(),
275        };
276        assert_eq!(e.exit_code(), exit_codes::EX_GENERAL);
277        let e64 = ErroSshCli::ComandoFalhou {
278            exit_code: 64,
279            stderr: "usage".to_string(),
280        };
281        assert_eq!(e64.exit_code(), exit_codes::EX_GENERAL);
282    }
283
284    #[test]
285    fn exit_code_argumento_invalido_retorna_usage() {
286        let e = ErroSshCli::ArgumentoInvalido("bad".to_string());
287        assert_eq!(e.exit_code(), exit_codes::EX_USAGE);
288    }
289
290    #[test]
291    fn exit_code_diretorio_xdg_retorna_cantcreat() {
292        assert_eq!(
293            ErroSshCli::DiretorioXdg.exit_code(),
294            exit_codes::EX_CANTCREAT
295        );
296    }
297}