1use secrecy::{ExposeSecret, SecretString};
13use serde::{Deserialize, Serialize};
14
15pub const CURRENT_SCHEMA_VERSION: u32 = 3;
17
18pub const DEFAULT_TIMEOUT_MS: u64 = 60_000;
20
21pub const DEFAULT_MAX_COMMAND_CHARS: usize = 1_000;
23
24pub const DEFAULT_MAX_OUTPUT_CHARS: usize = 100_000;
26
27#[derive(Clone, Serialize, Deserialize)]
32pub struct VpsRecord {
33 #[serde(alias = "nome")]
35 pub name: String,
36 pub host: String,
38 #[serde(alias = "porta")]
40 pub port: u16,
41 #[serde(alias = "usuario")]
43 pub username: String,
44 #[serde(default, alias = "senha", with = "secret_string_serde")]
46 pub password: SecretString,
47 #[serde(default)]
49 pub key_path: Option<String>,
50 #[serde(default, with = "opcao_secret_string_serde")]
52 pub key_passphrase: Option<SecretString>,
53 #[serde(default = "default_timeout_ms")]
55 pub timeout_ms: u64,
56 #[serde(default = "default_max_command_chars")]
58 pub max_command_chars: usize,
59 #[serde(default = "default_max_output_chars", alias = "max_chars")]
61 pub max_output_chars: usize,
62 #[serde(default, alias = "senha_sudo", with = "opcao_secret_string_serde")]
64 pub sudo_password: Option<SecretString>,
65 #[serde(default, alias = "senha_su", with = "opcao_secret_string_serde")]
67 pub su_password: Option<SecretString>,
68 #[serde(default)]
70 pub disable_sudo: bool,
71 #[serde(default = "default_schema_version")]
73 pub schema_version: u32,
74 #[serde(default = "default_added_at", alias = "adicionado_em")]
76 pub added_at: String,
77}
78
79fn default_max_command_chars() -> usize {
80 DEFAULT_MAX_COMMAND_CHARS
81}
82
83fn default_max_output_chars() -> usize {
84 DEFAULT_MAX_OUTPUT_CHARS
85}
86
87fn default_timeout_ms() -> u64 {
88 DEFAULT_TIMEOUT_MS
89}
90
91fn default_schema_version() -> u32 {
92 CURRENT_SCHEMA_VERSION
93}
94
95fn default_added_at() -> String {
96 chrono::Utc::now().to_rfc3339()
97}
98
99impl std::fmt::Debug for VpsRecord {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.debug_struct("VpsRecord")
102 .field("name", &self.name)
103 .field("host", &self.host)
104 .field("port", &self.port)
105 .field("username", &self.username)
106 .field("password", &"<redacted>")
107 .field("key_path", &self.key_path)
108 .field(
109 "key_passphrase",
110 &self.key_passphrase.as_ref().map(|_| "<redacted>"),
111 )
112 .field("timeout_ms", &self.timeout_ms)
113 .field("max_command_chars", &self.max_command_chars)
114 .field("max_output_chars", &self.max_output_chars)
115 .field(
116 "sudo_password",
117 &self.sudo_password.as_ref().map(|_| "<redacted>"),
118 )
119 .field("su_password", &self.su_password.as_ref().map(|_| "<redacted>"))
120 .field("disable_sudo", &self.disable_sudo)
121 .field("schema_version", &self.schema_version)
122 .field("added_at", &self.added_at)
123 .finish()
124 }
125}
126
127impl VpsRecord {
128 #[must_use]
130 #[allow(clippy::too_many_arguments)]
131 pub fn new(
132 name: String,
133 host: String,
134 port: u16,
135 username: String,
136 password: SecretString,
137 key_path: Option<String>,
138 key_passphrase: Option<SecretString>,
139 timeout_ms: Option<u64>,
140 max_command_chars: Option<usize>,
141 max_output_chars: Option<usize>,
142 sudo_password: Option<SecretString>,
143 su_password: Option<SecretString>,
144 disable_sudo: bool,
145 ) -> Self {
146 Self {
147 name,
148 host,
149 port,
150 username,
151 password,
152 key_path,
153 key_passphrase,
154 timeout_ms: timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS),
155 max_command_chars: max_command_chars.unwrap_or(DEFAULT_MAX_COMMAND_CHARS),
156 max_output_chars: max_output_chars.unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
157 sudo_password,
158 su_password,
159 disable_sudo,
160 schema_version: CURRENT_SCHEMA_VERSION,
161 added_at: chrono::Utc::now().to_rfc3339(),
162 }
163 }
164
165 #[must_use]
167 pub fn has_password(&self) -> bool {
168 !self.password.expose_secret().is_empty()
169 }
170
171 #[must_use]
173 pub fn has_key(&self) -> bool {
174 self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty())
175 }
176
177 pub fn validate_credentials(&self) -> Result<(), String> {
179 if !self.has_password() && !self.has_key() {
180 return Err(
181 "must provide --password or --key (password or private key auth)"
182 .to_string(),
183 );
184 }
185 Ok(())
186 }
187
188 pub fn validate(&self) -> Result<(), String> {
193 if self.port == 0 {
194 return Err("invalid SSH port: 0 (use 1..=65535)".to_string());
195 }
196 if self.host.trim().is_empty() {
197 return Err("host cannot be empty".to_string());
198 }
199 if self.username.trim().is_empty() {
200 return Err("SSH username cannot be empty".to_string());
201 }
202 self.validate_credentials()
203 }
204
205 pub fn normalize_schema(&mut self) {
207 if self.schema_version < CURRENT_SCHEMA_VERSION {
208 self.schema_version = CURRENT_SCHEMA_VERSION;
209 }
210 if self.max_command_chars == 0 && self.max_output_chars == 0 {
211 }
213 }
214}
215
216#[must_use]
220pub fn parse_char_limit(s: &str) -> usize {
221 let t = s.trim();
222 if t.eq_ignore_ascii_case("none") || t == "0" {
223 0
224 } else {
225 t.parse().unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
226 }
227}
228
229#[must_use]
233pub fn effective_limit(configured: usize) -> usize {
234 if configured == 0 {
235 usize::MAX
236 } else {
237 configured
238 }
239}
240
241mod secret_string_serde {
242 use super::{ExposeSecret, SecretString};
243 use serde::{Deserialize, Deserializer, Serializer};
244
245 pub fn serialize<S: Serializer>(value: &SecretString, s: S) -> Result<S::Ok, S::Error> {
246 let plain = value.expose_secret();
247 let out = crate::secrets::serialize_secret(plain).map_err(serde::ser::Error::custom)?;
248 s.serialize_str(&out)
249 }
250
251 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
252 let s = String::deserialize(d)?;
253 let plain = crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
254 Ok(SecretString::from(plain))
255 }
256}
257
258mod opcao_secret_string_serde {
259 use super::{ExposeSecret, SecretString};
260 use serde::{Deserialize, Deserializer, Serializer};
261
262 pub fn serialize<S: Serializer>(value: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
263 match value {
264 Some(v) => {
265 let out = crate::secrets::serialize_secret(v.expose_secret())
266 .map_err(serde::ser::Error::custom)?;
267 s.serialize_some(&out)
268 }
269 None => s.serialize_none(),
270 }
271 }
272
273 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
274 let opt = Option::<String>::deserialize(d)?;
275 match opt {
276 None => Ok(None),
277 Some(s) => {
278 let plain =
279 crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
280 Ok(Some(SecretString::from(plain)))
281 }
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn new_record_applies_defaults() {
292 let r = VpsRecord::new(
293 "teste".into(),
294 "1.2.3.4".into(),
295 22,
296 "root".into(),
297 SecretString::from("senha".to_string()),
298 None,
299 None,
300 None,
301 None,
302 None,
303 None,
304 None,
305 false,
306 );
307 assert_eq!(r.timeout_ms, DEFAULT_TIMEOUT_MS);
308 assert_eq!(r.max_command_chars, DEFAULT_MAX_COMMAND_CHARS);
309 assert_eq!(r.max_output_chars, DEFAULT_MAX_OUTPUT_CHARS);
310 assert_eq!(r.schema_version, CURRENT_SCHEMA_VERSION);
311 assert!(!r.added_at.is_empty());
312 }
313
314 #[test]
315 fn debug_does_not_show_password() {
316 let r = VpsRecord::new(
317 "t".into(),
318 "h".into(),
319 22,
320 "u".into(),
321 SecretString::from("senha-super-secreta".to_string()),
322 None,
323 None,
324 None,
325 None,
326 None,
327 None,
328 None,
329 false,
330 );
331 let dbg = format!("{r:?}");
332 assert!(!dbg.contains("senha-super-secreta"));
333 assert!(dbg.contains("redacted"));
334 }
335
336 #[test]
337 #[serial_test::serial]
338 fn round_trip_toml_preserves_data() {
339 let tmp = tempfile::TempDir::new().unwrap();
341 crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
342 unsafe {
348
349 std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
350 }
351 let r = VpsRecord::new(
352 "producao".into(),
353 "srv.exemplo.com".into(),
354 2222,
355 "admin".into(),
356 SecretString::from("senha-do-admin-longa".to_string()),
357 Some("/home/u/.ssh/id_ed25519".into()),
358 None,
359 Some(5000),
360 Some(500),
361 Some(50_000),
362 Some(SecretString::from("sudopass".to_string())),
363 None,
364 false,
365 );
366 let toml_str = toml::to_string(&r).expect("serializar");
367 let r2: VpsRecord = toml::from_str(&toml_str).expect("deserializar");
368 assert_eq!(r2.name, "producao");
369 assert_eq!(r2.port, 2222);
370 assert_eq!(r2.password.expose_secret(), "senha-do-admin-longa");
371 assert_eq!(r2.key_path.as_deref(), Some("/home/u/.ssh/id_ed25519"));
372 assert_eq!(r2.max_command_chars, 500);
373 assert_eq!(r2.max_output_chars, 50_000);
374 assert_eq!(
375 r2.sudo_password
376 .as_ref()
377 .map(|s| s.expose_secret().to_string()),
378 Some("sudopass".to_string())
379 );
380 assert!(r2.su_password.is_none());
381 unsafe {
392 std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
393 }
394 crate::secrets::set_config_dir(None);
395 }
396
397 #[test]
398 fn migrates_legacy_max_chars() {
399 let legacy = r#"
400nome = "x"
401host = "h"
402porta = 22
403usuario = "u"
404senha = "s"
405timeout_ms = 30000
406max_chars = 4242
407schema_version = 1
408adicionado_em = "2020-01-01T00:00:00Z"
409"#;
410 let r: VpsRecord = toml::from_str(legacy).expect("deserialize legacy PT wire");
411 assert_eq!(r.max_output_chars, 4242);
412 assert_eq!(r.max_command_chars, DEFAULT_MAX_COMMAND_CHARS);
413 assert_eq!(r.name, "x");
414 assert_eq!(r.port, 22);
415 assert_eq!(r.username, "u");
416 }
417
418 #[test]
419 fn deserializes_english_wire_keys() {
420 let en = r#"
421name = "prod"
422host = "h.example"
423port = 2222
424username = "admin"
425password = "secret"
426timeout_ms = 5000
427schema_version = 3
428"#;
429 let r: VpsRecord = toml::from_str(en).expect("deserialize EN wire");
430 assert_eq!(r.name, "prod");
431 assert_eq!(r.port, 2222);
432 assert_eq!(r.username, "admin");
433 assert!(!r.added_at.is_empty());
434 }
435
436 #[test]
437 fn serializes_english_wire_keys() {
438 let r = VpsRecord::new(
439 "prod".into(),
440 "h".into(),
441 22,
442 "u".into(),
443 SecretString::from("p".to_string()),
444 None,
445 None,
446 None,
447 None,
448 None,
449 None,
450 None,
451 false,
452 );
453 let s = toml::to_string(&r).expect("serialize");
454 assert!(s.contains("name ="), "expected EN key name: {s}");
455 assert!(s.contains("port ="), "expected EN key port: {s}");
456 assert!(s.contains("username ="), "expected EN key username: {s}");
457 assert!(s.contains("password ="), "expected EN key password: {s}");
458 assert!(s.contains("added_at ="), "expected EN key added_at: {s}");
459 assert!(!s.contains("nome ="), "must not write PT key nome: {s}");
460 assert!(!s.contains("porta ="), "must not write PT key porta: {s}");
461 assert!(!s.contains("adicionado_em ="), "must not write PT adicionado_em: {s}");
462 }
463
464 #[test]
465 fn deserializes_without_added_at() {
466 let bare = r#"
467nome = "x"
468host = "h"
469porta = 22
470usuario = "u"
471senha = "s"
472schema_version = 2
473"#;
474 let r: VpsRecord = toml::from_str(bare).expect("default added_at");
475 assert!(!r.added_at.is_empty());
476 }
477
478 #[test]
479 fn validate_credentials_requires_password_or_key() {
480 let mut r = VpsRecord::new(
481 "t".into(),
482 "h".into(),
483 22,
484 "u".into(),
485 SecretString::from(String::new()),
486 None,
487 None,
488 None,
489 None,
490 None,
491 None,
492 None,
493 false,
494 );
495 assert!(r.validate_credentials().is_err());
496 r.key_path = Some("/tmp/k".into());
497 assert!(r.validate_credentials().is_ok());
498 }
499
500 #[test]
501 fn parse_limit_none_and_zero() {
502 assert_eq!(parse_char_limit("none"), 0);
503 assert_eq!(parse_char_limit("0"), 0);
504 assert_eq!(parse_char_limit("1000"), 1000);
505 assert_eq!(effective_limit(0), usize::MAX);
506 assert_eq!(effective_limit(10), 10);
507 }
508}