Skip to main content

ssh_cli/vps/
modelo.rs

1//! Modelo de dados `VpsRegistro`.
2//!
3//! Senhas usam `SecretString` para zeroize automático via `Drop`. O TOML
4//! gravado em disco contém a senha em texto claro (protegido por `chmod 0o600`).
5//! `Debug` é customizado para NUNCA expor valores sensíveis.
6
7use secrecy::{ExposeSecret, SecretString};
8use serde::{Deserialize, Serialize};
9
10/// Versão atual do schema do arquivo `config.toml`.
11pub const SCHEMA_VERSION_ATUAL: u32 = 1;
12
13/// Timeout padrão em milissegundos para operações SSH.
14pub const TIMEOUT_PADRAO_MS: u64 = 30_000;
15
16/// Limite padrão de caracteres em output capturado.
17pub const MAX_CHARS_PADRAO: usize = 100_000;
18
19/// Registro de uma VPS no arquivo de configuração.
20#[derive(Clone, Serialize, Deserialize)]
21pub struct VpsRegistro {
22    /// Nome lógico único da VPS.
23    pub nome: String,
24    /// Hostname ou IP do servidor.
25    pub host: String,
26    /// Porta SSH.
27    pub porta: u16,
28    /// Usuário SSH.
29    pub usuario: String,
30    /// Senha SSH (em memória como `SecretString`).
31    #[serde(with = "secret_string_serde")]
32    pub senha: SecretString,
33    /// Timeout em milissegundos.
34    pub timeout_ms: u64,
35    /// Limite de caracteres em output.
36    pub max_chars: usize,
37    /// Senha para `sudo` (opcional).
38    #[serde(default, with = "opcao_secret_string_serde")]
39    pub senha_sudo: Option<SecretString>,
40    /// Senha para `su -` (opcional).
41    #[serde(default, with = "opcao_secret_string_serde")]
42    pub senha_su: Option<SecretString>,
43    /// Versão do schema deste registro.
44    pub schema_version: u32,
45    /// Timestamp RFC 3339 de inclusão.
46    pub adicionado_em: String,
47}
48
49impl std::fmt::Debug for VpsRegistro {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("VpsRegistro")
52            .field("nome", &self.nome)
53            .field("host", &self.host)
54            .field("porta", &self.porta)
55            .field("usuario", &self.usuario)
56            .field("senha", &"<redacted>")
57            .field("timeout_ms", &self.timeout_ms)
58            .field("max_chars", &self.max_chars)
59            .field(
60                "senha_sudo",
61                &self.senha_sudo.as_ref().map(|_| "<redacted>"),
62            )
63            .field("senha_su", &self.senha_su.as_ref().map(|_| "<redacted>"))
64            .field("schema_version", &self.schema_version)
65            .field("adicionado_em", &self.adicionado_em)
66            .finish()
67    }
68}
69
70impl VpsRegistro {
71    /// Cria um novo registro aplicando defaults para timeout e `max_chars`.
72    #[must_use]
73    #[allow(clippy::too_many_arguments)]
74    pub fn novo(
75        nome: String,
76        host: String,
77        porta: u16,
78        usuario: String,
79        senha: SecretString,
80        timeout_ms: Option<u64>,
81        max_chars: Option<usize>,
82        senha_sudo: Option<SecretString>,
83        senha_su: Option<SecretString>,
84    ) -> Self {
85        Self {
86            nome,
87            host,
88            porta,
89            usuario,
90            senha,
91            timeout_ms: timeout_ms.unwrap_or(TIMEOUT_PADRAO_MS),
92            max_chars: max_chars.unwrap_or(MAX_CHARS_PADRAO),
93            senha_sudo,
94            senha_su,
95            schema_version: SCHEMA_VERSION_ATUAL,
96            adicionado_em: chrono::Utc::now().to_rfc3339(),
97        }
98    }
99}
100
101mod secret_string_serde {
102    use super::{ExposeSecret, SecretString};
103    use serde::{Deserialize, Deserializer, Serializer};
104
105    pub fn serialize<S: Serializer>(valor: &SecretString, s: S) -> Result<S::Ok, S::Error> {
106        s.serialize_str(valor.expose_secret())
107    }
108
109    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
110        let s = String::deserialize(d)?;
111        Ok(SecretString::from(s))
112    }
113}
114
115mod opcao_secret_string_serde {
116    use super::{ExposeSecret, SecretString};
117    use serde::{Deserialize, Deserializer, Serializer};
118
119    pub fn serialize<S: Serializer>(valor: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
120        match valor {
121            Some(v) => s.serialize_some(v.expose_secret()),
122            None => s.serialize_none(),
123        }
124    }
125
126    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
127        let opt = Option::<String>::deserialize(d)?;
128        Ok(opt.map(SecretString::from))
129    }
130}
131
132#[cfg(test)]
133mod testes {
134    use super::*;
135
136    #[test]
137    fn novo_registro_aplica_defaults() {
138        let r = VpsRegistro::novo(
139            "teste".into(),
140            "1.2.3.4".into(),
141            22,
142            "root".into(),
143            SecretString::from("senha".to_string()),
144            None,
145            None,
146            None,
147            None,
148        );
149        assert_eq!(r.timeout_ms, TIMEOUT_PADRAO_MS);
150        assert_eq!(r.max_chars, MAX_CHARS_PADRAO);
151        assert_eq!(r.schema_version, SCHEMA_VERSION_ATUAL);
152        assert!(!r.adicionado_em.is_empty());
153    }
154
155    #[test]
156    fn debug_nao_exibe_senha() {
157        let r = VpsRegistro::novo(
158            "t".into(),
159            "h".into(),
160            22,
161            "u".into(),
162            SecretString::from("senha-super-secreta".to_string()),
163            None,
164            None,
165            None,
166            None,
167        );
168        let dbg = format!("{r:?}");
169        assert!(!dbg.contains("senha-super-secreta"));
170        assert!(dbg.contains("redacted"));
171    }
172
173    #[test]
174    fn round_trip_toml_preserva_dados() {
175        let r = VpsRegistro::novo(
176            "producao".into(),
177            "srv.exemplo.com".into(),
178            2222,
179            "admin".into(),
180            SecretString::from("senha-do-admin-longa".to_string()),
181            Some(5000),
182            Some(50_000),
183            Some(SecretString::from("sudopass".to_string())),
184            None,
185        );
186        let toml_str = toml::to_string(&r).expect("serializar");
187        let r2: VpsRegistro = toml::from_str(&toml_str).expect("deserializar");
188        assert_eq!(r2.nome, "producao");
189        assert_eq!(r2.porta, 2222);
190        assert_eq!(r2.senha.expose_secret(), "senha-do-admin-longa");
191        assert_eq!(
192            r2.senha_sudo
193                .as_ref()
194                .map(|s| s.expose_secret().to_string()),
195            Some("sudopass".to_string())
196        );
197        assert!(r2.senha_su.is_none());
198    }
199}