1use secrecy::{ExposeSecret, SecretString};
11use serde::{Deserialize, Serialize};
12
13pub const SCHEMA_VERSION_ATUAL: u32 = 2;
15
16pub const TIMEOUT_PADRAO_MS: u64 = 60_000;
18
19pub const MAX_COMMAND_CHARS_PADRAO: usize = 1_000;
21
22pub const MAX_OUTPUT_CHARS_PADRAO: usize = 100_000;
24
25#[derive(Clone, Serialize, Deserialize)]
27pub struct VpsRegistro {
28 pub nome: String,
30 pub host: String,
32 pub porta: u16,
34 pub usuario: String,
36 #[serde(default, with = "secret_string_serde")]
38 pub senha: SecretString,
39 #[serde(default)]
41 pub key_path: Option<String>,
42 #[serde(default, with = "opcao_secret_string_serde")]
44 pub key_passphrase: Option<SecretString>,
45 pub timeout_ms: u64,
47 #[serde(default = "default_max_command_chars")]
49 pub max_command_chars: usize,
50 #[serde(default = "default_max_output_chars", alias = "max_chars")]
52 pub max_output_chars: usize,
53 #[serde(default, with = "opcao_secret_string_serde")]
55 pub senha_sudo: Option<SecretString>,
56 #[serde(default, with = "opcao_secret_string_serde")]
58 pub senha_su: Option<SecretString>,
59 #[serde(default)]
61 pub disable_sudo: bool,
62 pub schema_version: u32,
64 pub adicionado_em: String,
66}
67
68fn default_max_command_chars() -> usize {
69 MAX_COMMAND_CHARS_PADRAO
70}
71
72fn default_max_output_chars() -> usize {
73 MAX_OUTPUT_CHARS_PADRAO
74}
75
76impl std::fmt::Debug for VpsRegistro {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_struct("VpsRegistro")
79 .field("nome", &self.nome)
80 .field("host", &self.host)
81 .field("porta", &self.porta)
82 .field("usuario", &self.usuario)
83 .field("senha", &"<redacted>")
84 .field("key_path", &self.key_path)
85 .field(
86 "key_passphrase",
87 &self.key_passphrase.as_ref().map(|_| "<redacted>"),
88 )
89 .field("timeout_ms", &self.timeout_ms)
90 .field("max_command_chars", &self.max_command_chars)
91 .field("max_output_chars", &self.max_output_chars)
92 .field(
93 "senha_sudo",
94 &self.senha_sudo.as_ref().map(|_| "<redacted>"),
95 )
96 .field("senha_su", &self.senha_su.as_ref().map(|_| "<redacted>"))
97 .field("disable_sudo", &self.disable_sudo)
98 .field("schema_version", &self.schema_version)
99 .field("adicionado_em", &self.adicionado_em)
100 .finish()
101 }
102}
103
104impl VpsRegistro {
105 #[must_use]
107 #[allow(clippy::too_many_arguments)]
108 pub fn novo(
109 nome: String,
110 host: String,
111 porta: u16,
112 usuario: String,
113 senha: SecretString,
114 key_path: Option<String>,
115 key_passphrase: Option<SecretString>,
116 timeout_ms: Option<u64>,
117 max_command_chars: Option<usize>,
118 max_output_chars: Option<usize>,
119 senha_sudo: Option<SecretString>,
120 senha_su: Option<SecretString>,
121 disable_sudo: bool,
122 ) -> Self {
123 Self {
124 nome,
125 host,
126 porta,
127 usuario,
128 senha,
129 key_path,
130 key_passphrase,
131 timeout_ms: timeout_ms.unwrap_or(TIMEOUT_PADRAO_MS),
132 max_command_chars: max_command_chars.unwrap_or(MAX_COMMAND_CHARS_PADRAO),
133 max_output_chars: max_output_chars.unwrap_or(MAX_OUTPUT_CHARS_PADRAO),
134 senha_sudo,
135 senha_su,
136 disable_sudo,
137 schema_version: SCHEMA_VERSION_ATUAL,
138 adicionado_em: chrono::Utc::now().to_rfc3339(),
139 }
140 }
141
142 #[must_use]
144 pub fn tem_senha(&self) -> bool {
145 !self.senha.expose_secret().is_empty()
146 }
147
148 #[must_use]
150 pub fn tem_chave(&self) -> bool {
151 self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty())
152 }
153
154 pub fn validar_credenciais(&self) -> Result<(), String> {
156 if !self.tem_senha() && !self.tem_chave() {
157 return Err(
158 "é obrigatório fornecer --password ou --key (auth password ou chave privada)"
159 .to_string(),
160 );
161 }
162 Ok(())
163 }
164
165 pub fn validar(&self) -> Result<(), String> {
170 if self.porta == 0 {
171 return Err("porta SSH inválida: 0 (use 1..=65535)".to_string());
172 }
173 if self.host.trim().is_empty() {
174 return Err("host não pode ser vazio".to_string());
175 }
176 if self.usuario.trim().is_empty() {
177 return Err("usuário SSH não pode ser vazio".to_string());
178 }
179 self.validar_credenciais()
180 }
181
182 pub fn normalizar_schema(&mut self) {
184 if self.schema_version < SCHEMA_VERSION_ATUAL {
185 self.schema_version = SCHEMA_VERSION_ATUAL;
186 }
187 if self.max_command_chars == 0 && self.max_output_chars == 0 {
188 }
190 }
191}
192
193#[must_use]
197pub fn parse_limite_chars(s: &str) -> usize {
198 let t = s.trim();
199 if t.eq_ignore_ascii_case("none") || t == "0" {
200 0
201 } else {
202 t.parse().unwrap_or(MAX_OUTPUT_CHARS_PADRAO)
203 }
204}
205
206#[must_use]
210pub fn limite_efetivo(configurado: usize) -> usize {
211 if configurado == 0 {
212 usize::MAX
213 } else {
214 configurado
215 }
216}
217
218mod secret_string_serde {
219 use super::{ExposeSecret, SecretString};
220 use serde::{Deserialize, Deserializer, Serializer};
221
222 pub fn serialize<S: Serializer>(valor: &SecretString, s: S) -> Result<S::Ok, S::Error> {
223 let plain = valor.expose_secret();
224 let out = crate::secrets::serializar_segredo(plain).map_err(serde::ser::Error::custom)?;
225 s.serialize_str(&out)
226 }
227
228 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
229 let s = String::deserialize(d)?;
230 let plain = crate::secrets::deserializar_segredo(&s).map_err(serde::de::Error::custom)?;
231 Ok(SecretString::from(plain))
232 }
233}
234
235mod opcao_secret_string_serde {
236 use super::{ExposeSecret, SecretString};
237 use serde::{Deserialize, Deserializer, Serializer};
238
239 pub fn serialize<S: Serializer>(valor: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
240 match valor {
241 Some(v) => {
242 let out = crate::secrets::serializar_segredo(v.expose_secret())
243 .map_err(serde::ser::Error::custom)?;
244 s.serialize_some(&out)
245 }
246 None => s.serialize_none(),
247 }
248 }
249
250 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
251 let opt = Option::<String>::deserialize(d)?;
252 match opt {
253 None => Ok(None),
254 Some(s) => {
255 let plain =
256 crate::secrets::deserializar_segredo(&s).map_err(serde::de::Error::custom)?;
257 Ok(Some(SecretString::from(plain)))
258 }
259 }
260 }
261}
262
263#[cfg(test)]
264mod testes {
265 use super::*;
266
267 #[test]
268 fn novo_registro_aplica_defaults() {
269 let r = VpsRegistro::novo(
270 "teste".into(),
271 "1.2.3.4".into(),
272 22,
273 "root".into(),
274 SecretString::from("senha".to_string()),
275 None,
276 None,
277 None,
278 None,
279 None,
280 None,
281 None,
282 false,
283 );
284 assert_eq!(r.timeout_ms, TIMEOUT_PADRAO_MS);
285 assert_eq!(r.max_command_chars, MAX_COMMAND_CHARS_PADRAO);
286 assert_eq!(r.max_output_chars, MAX_OUTPUT_CHARS_PADRAO);
287 assert_eq!(r.schema_version, SCHEMA_VERSION_ATUAL);
288 assert!(!r.adicionado_em.is_empty());
289 }
290
291 #[test]
292 fn debug_nao_exibe_senha() {
293 let r = VpsRegistro::novo(
294 "t".into(),
295 "h".into(),
296 22,
297 "u".into(),
298 SecretString::from("senha-super-secreta".to_string()),
299 None,
300 None,
301 None,
302 None,
303 None,
304 None,
305 None,
306 false,
307 );
308 let dbg = format!("{r:?}");
309 assert!(!dbg.contains("senha-super-secreta"));
310 assert!(dbg.contains("redacted"));
311 }
312
313 #[test]
314 #[serial_test::serial]
315 fn round_trip_toml_preserva_dados() {
316 let tmp = tempfile::TempDir::new().unwrap();
318 crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
319 unsafe {
322 std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
323 }
324 let r = VpsRegistro::novo(
325 "producao".into(),
326 "srv.exemplo.com".into(),
327 2222,
328 "admin".into(),
329 SecretString::from("senha-do-admin-longa".to_string()),
330 Some("/home/u/.ssh/id_ed25519".into()),
331 None,
332 Some(5000),
333 Some(500),
334 Some(50_000),
335 Some(SecretString::from("sudopass".to_string())),
336 None,
337 false,
338 );
339 let toml_str = toml::to_string(&r).expect("serializar");
340 let r2: VpsRegistro = toml::from_str(&toml_str).expect("deserializar");
341 assert_eq!(r2.nome, "producao");
342 assert_eq!(r2.porta, 2222);
343 assert_eq!(r2.senha.expose_secret(), "senha-do-admin-longa");
344 assert_eq!(r2.key_path.as_deref(), Some("/home/u/.ssh/id_ed25519"));
345 assert_eq!(r2.max_command_chars, 500);
346 assert_eq!(r2.max_output_chars, 50_000);
347 assert_eq!(
348 r2.senha_sudo
349 .as_ref()
350 .map(|s| s.expose_secret().to_string()),
351 Some("sudopass".to_string())
352 );
353 assert!(r2.senha_su.is_none());
354 unsafe {
355 std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
356 }
357 crate::secrets::definir_diretorio_config(None);
358 }
359
360 #[test]
361 fn migra_max_chars_legado() {
362 let legado = r#"
363nome = "x"
364host = "h"
365porta = 22
366usuario = "u"
367senha = "s"
368timeout_ms = 30000
369max_chars = 4242
370schema_version = 1
371adicionado_em = "2020-01-01T00:00:00Z"
372"#;
373 let r: VpsRegistro = toml::from_str(legado).expect("deserializar legado");
374 assert_eq!(r.max_output_chars, 4242);
375 assert_eq!(r.max_command_chars, MAX_COMMAND_CHARS_PADRAO);
376 }
377
378 #[test]
379 fn validar_credenciais_exige_password_ou_key() {
380 let mut r = VpsRegistro::novo(
381 "t".into(),
382 "h".into(),
383 22,
384 "u".into(),
385 SecretString::from(String::new()),
386 None,
387 None,
388 None,
389 None,
390 None,
391 None,
392 None,
393 false,
394 );
395 assert!(r.validar_credenciais().is_err());
396 r.key_path = Some("/tmp/k".into());
397 assert!(r.validar_credenciais().is_ok());
398 }
399
400 #[test]
401 fn parse_limite_none_e_zero() {
402 assert_eq!(parse_limite_chars("none"), 0);
403 assert_eq!(parse_limite_chars("0"), 0);
404 assert_eq!(parse_limite_chars("1000"), 1000);
405 assert_eq!(limite_efetivo(0), usize::MAX);
406 assert_eq!(limite_efetivo(10), 10);
407 }
408}