1#![forbid(unsafe_code)]
4use crate::domain::{
15 secret_nonempty, try_tags, CharLimit, HostTag, KeyPath, Rfc3339Utc, SshHost, SshPort, SshUser,
16 TimeoutMs, VpsName,
17};
18use crate::validation::MAX_TAGS;
19use secrecy::{ExposeSecret, SecretString};
20use serde::{Deserialize, Serialize};
21
22pub const CURRENT_SCHEMA_VERSION: u32 = 3;
24
25pub const DEFAULT_TIMEOUT_MS: u64 = 60_000;
27
28pub const DEFAULT_MAX_COMMAND_CHARS: usize = 1_000;
30
31pub const DEFAULT_MAX_OUTPUT_CHARS: usize = 100_000;
33
34const _: () = assert!(CURRENT_SCHEMA_VERSION >= 1);
36const _: () = assert!(DEFAULT_TIMEOUT_MS > 0);
37const _: () = assert!(DEFAULT_MAX_COMMAND_CHARS > 0);
38const _: () = assert!(DEFAULT_MAX_OUTPUT_CHARS >= DEFAULT_MAX_COMMAND_CHARS);
39
40#[derive(Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct VpsRecord {
50 #[serde(alias = "nome")]
52 pub name: VpsName,
53 pub host: SshHost,
55 #[serde(alias = "porta")]
57 pub port: SshPort,
58 #[serde(alias = "usuario")]
60 pub username: SshUser,
61 #[serde(default, alias = "senha", with = "secret_string_serde")]
63 pub password: SecretString,
64 #[serde(default)]
66 pub key_path: Option<KeyPath>,
67 #[serde(default, with = "opcao_secret_string_serde")]
69 pub key_passphrase: Option<SecretString>,
70 #[serde(default)]
72 pub use_agent: bool,
73 #[serde(default)]
75 pub agent_socket: Option<String>,
76 #[serde(default = "default_timeout_ms")]
78 pub timeout_ms: TimeoutMs,
79 #[serde(default = "default_max_command_chars")]
81 pub max_command_chars: CharLimit,
82 #[serde(default = "default_max_output_chars", alias = "max_chars")]
84 pub max_output_chars: CharLimit,
85 #[serde(default, alias = "senha_sudo", with = "opcao_secret_string_serde")]
87 pub sudo_password: Option<SecretString>,
88 #[serde(default, alias = "senha_su", with = "opcao_secret_string_serde")]
90 pub su_password: Option<SecretString>,
91 #[serde(default)]
93 pub disable_sudo: bool,
94 #[serde(default = "default_schema_version")]
96 pub schema_version: u32,
97 #[serde(default = "default_added_at", alias = "adicionado_em")]
99 pub added_at: Rfc3339Utc,
100 #[serde(default)]
102 pub tags: Vec<HostTag>,
103 #[serde(default)]
105 pub tls: bool,
106 #[serde(default)]
108 pub tls_sni: Option<String>,
109 #[serde(default)]
111 pub tls_client_cert: Option<String>,
112 #[serde(default)]
114 pub tls_client_key: Option<String>,
115}
116
117fn default_max_command_chars() -> CharLimit {
118 CharLimit::try_new(DEFAULT_MAX_COMMAND_CHARS).expect("default command limit in range")
119}
120
121fn default_max_output_chars() -> CharLimit {
122 CharLimit::try_new(DEFAULT_MAX_OUTPUT_CHARS).expect("default output limit in range")
123}
124
125fn default_timeout_ms() -> TimeoutMs {
126 TimeoutMs::try_new(DEFAULT_TIMEOUT_MS).expect("default timeout in range")
127}
128
129fn default_schema_version() -> u32 {
130 CURRENT_SCHEMA_VERSION
131}
132
133fn default_added_at() -> Rfc3339Utc {
134 Rfc3339Utc::now()
135}
136
137impl std::fmt::Debug for VpsRecord {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 f.debug_struct("VpsRecord")
140 .field("name", &self.name.as_str())
141 .field("host", &self.host.as_str())
142 .field("port", &self.port.get())
143 .field("username", &self.username.as_str())
144 .field("password", &"<redacted>")
145 .field("key_path", &self.key_path.as_ref().map(|k| k.as_path()))
146 .field(
147 "key_passphrase",
148 &self.key_passphrase.as_ref().map(|_| "<redacted>"),
149 )
150 .field("timeout_ms", &self.timeout_ms.get())
151 .field("max_command_chars", &self.max_command_chars.wire())
152 .field("max_output_chars", &self.max_output_chars.wire())
153 .field(
154 "sudo_password",
155 &self.sudo_password.as_ref().map(|_| "<redacted>"),
156 )
157 .field(
158 "su_password",
159 &self.su_password.as_ref().map(|_| "<redacted>"),
160 )
161 .field("disable_sudo", &self.disable_sudo)
162 .field("schema_version", &self.schema_version)
163 .field("added_at", &self.added_at.to_rfc3339())
164 .field("tags", &self.tags)
165 .field("tls", &self.tls)
166 .field("use_agent", &self.use_agent)
167 .field("agent_socket", &self.agent_socket)
168 .field("tls_sni", &self.tls_sni)
169 .field("tls_client_cert", &self.tls_client_cert)
170 .field(
171 "tls_client_key",
172 &self.tls_client_key.as_ref().map(|_| "<redacted-path>"),
173 )
174 .finish()
175 }
176}
177
178impl VpsRecord {
179 #[allow(clippy::too_many_arguments)]
189 pub fn try_new(
190 name: impl AsRef<str>,
191 host: impl AsRef<str>,
192 port: u16,
193 username: impl AsRef<str>,
194 password: SecretString,
195 key_path: Option<impl AsRef<str>>,
196 key_passphrase: Option<SecretString>,
197 timeout_ms: Option<u64>,
198 max_command_chars: Option<usize>,
199 max_output_chars: Option<usize>,
200 sudo_password: Option<SecretString>,
201 su_password: Option<SecretString>,
202 disable_sudo: bool,
203 ) -> Result<Self, String> {
204 let key_path = KeyPath::try_from_optional(key_path).map_err(|e| e.to_string())?;
205 Ok(Self {
206 name: VpsName::try_new(name).map_err(|e| e.to_string())?,
207 host: SshHost::try_new(host).map_err(|e| e.to_string())?,
208 port: SshPort::try_new(port).map_err(|e| e.to_string())?,
209 username: SshUser::try_new(username).map_err(|e| e.to_string())?,
210 password,
211 key_path,
212 key_passphrase,
213 use_agent: false,
214 agent_socket: None,
215 timeout_ms: TimeoutMs::try_new(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS))
216 .map_err(|e| e.to_string())?,
217 max_command_chars: CharLimit::try_new(
218 max_command_chars.unwrap_or(DEFAULT_MAX_COMMAND_CHARS),
219 )
220 .map_err(|e| e.to_string())?,
221 max_output_chars: CharLimit::try_new(
222 max_output_chars.unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
223 )
224 .map_err(|e| e.to_string())?,
225 sudo_password,
226 su_password,
227 disable_sudo,
228 schema_version: CURRENT_SCHEMA_VERSION,
229 added_at: Rfc3339Utc::now(),
230 tags: Vec::new(),
231 tls: false,
232 tls_sni: None,
233 tls_client_cert: None,
234 tls_client_key: None,
235 })
236 }
237
238 #[cfg(test)]
240 #[allow(clippy::too_many_arguments)]
242 pub(crate) fn test_new(
243 name: impl AsRef<str>,
244 host: impl AsRef<str>,
245 port: u16,
246 username: impl AsRef<str>,
247 password: SecretString,
248 key_path: Option<&str>,
249 key_passphrase: Option<SecretString>,
250 timeout_ms: Option<u64>,
251 max_command_chars: Option<usize>,
252 max_output_chars: Option<usize>,
253 sudo_password: Option<SecretString>,
254 su_password: Option<SecretString>,
255 disable_sudo: bool,
256 ) -> Self {
257 Self::try_new(
258 name,
259 host,
260 port,
261 username,
262 password,
263 key_path,
264 key_passphrase,
265 timeout_ms,
266 max_command_chars,
267 max_output_chars,
268 sudo_password,
269 su_password,
270 disable_sudo,
271 )
272 .expect("test_new requires valid domain fields")
273 }
274
275 #[must_use]
277 pub fn has_any_tag(&self, wanted: &[HostTag]) -> bool {
278 if wanted.is_empty() {
279 return true;
280 }
281 wanted
282 .iter()
283 .any(|w| self.tags.iter().any(|t| t.as_str() == w.as_str()))
284 }
285
286 #[must_use]
288 pub fn has_password(&self) -> bool {
289 secret_nonempty(&self.password)
290 }
291
292 #[must_use]
294 pub fn has_key(&self) -> bool {
295 self.key_path.is_some()
296 }
297
298 pub fn validate_credentials(&self) -> Result<(), crate::domain::DomainError> {
303 let n = u8::from(self.has_password()) + u8::from(self.has_key()) + u8::from(self.use_agent);
304 if n == 0 {
305 return Err(crate::domain::DomainError::new(
306 "vps_auth",
307 "must provide exactly one of --password, --key, or --use-agent",
308 ));
309 }
310 if n > 1 {
311 return Err(crate::domain::DomainError::new(
312 "vps_auth",
313 "primary auth methods are mutually exclusive: use only one of --password, --key, or --use-agent",
314 ));
315 }
316 Ok(())
317 }
318
319 pub fn validate_structure(&self) -> Result<(), crate::domain::DomainError> {
324 if self.tags.len() > MAX_TAGS {
325 return Err(crate::domain::DomainError::new(
326 "tags",
327 format!("at most {MAX_TAGS} tags allowed"),
328 ));
329 }
330 Ok(())
332 }
333
334 pub fn validate(&self) -> Result<(), crate::domain::DomainError> {
339 self.validate_structure()?;
340 self.validate_credentials()
341 }
342
343 pub fn normalize_schema(&mut self) {
345 if self.schema_version < CURRENT_SCHEMA_VERSION {
346 self.schema_version = CURRENT_SCHEMA_VERSION;
347 }
348 }
349
350 pub fn set_tags_from_raw(
352 &mut self,
353 raw: impl IntoIterator<Item = impl AsRef<str>>,
354 ) -> Result<(), crate::domain::DomainError> {
355 self.tags = try_tags(raw)?;
356 Ok(())
357 }
358}
359
360#[must_use]
364pub fn parse_char_limit(s: &str) -> usize {
365 let t = s.trim();
366 if t.eq_ignore_ascii_case("none") || t == "0" {
367 0
368 } else {
369 t.parse().unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
370 }
371}
372
373#[must_use]
377pub fn effective_limit(configured: usize) -> usize {
378 CharLimit::try_new(configured)
379 .map(|c| c.effective())
380 .unwrap_or(usize::MAX)
381}
382
383#[must_use]
385pub fn effective_char_limit(limit: CharLimit) -> usize {
386 limit.effective()
387}
388
389mod secret_string_serde {
390 use super::{ExposeSecret, SecretString};
391 use serde::{Deserialize, Deserializer, Serializer};
392
393 pub fn serialize<S: Serializer>(value: &SecretString, s: S) -> Result<S::Ok, S::Error> {
394 let plain = value.expose_secret();
395 let out = crate::secrets::serialize_secret(plain).map_err(serde::ser::Error::custom)?;
396 s.serialize_str(&out)
397 }
398
399 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
400 let s = String::deserialize(d)?;
401 let plain = crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
402 Ok(SecretString::from(plain))
403 }
404}
405
406mod opcao_secret_string_serde {
407 use super::{ExposeSecret, SecretString};
408 use serde::{Deserialize, Deserializer, Serializer};
409
410 pub fn serialize<S: Serializer>(value: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
411 match value {
412 Some(v) => {
413 let out = crate::secrets::serialize_secret(v.expose_secret())
414 .map_err(serde::ser::Error::custom)?;
415 s.serialize_some(&out)
416 }
417 None => s.serialize_none(),
418 }
419 }
420
421 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
422 let opt = Option::<String>::deserialize(d)?;
423 match opt {
424 None => Ok(None),
425 Some(s) => {
426 let plain =
427 crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
428 Ok(Some(SecretString::from(plain)))
429 }
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn try_new_applies_defaults() {
440 let r = VpsRecord::test_new(
441 "teste",
442 "1.2.3.4",
443 22,
444 "root",
445 SecretString::from("senha".to_string()),
446 None,
447 None,
448 None,
449 None,
450 None,
451 None,
452 None,
453 false,
454 );
455 assert_eq!(r.timeout_ms.get(), DEFAULT_TIMEOUT_MS);
456 assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
457 assert_eq!(r.max_output_chars.wire(), DEFAULT_MAX_OUTPUT_CHARS);
458 assert_eq!(r.schema_version, CURRENT_SCHEMA_VERSION);
459 assert!(!r.added_at.to_rfc3339().is_empty());
460 }
461
462 #[test]
463 fn try_new_rejects_port_zero() {
464 let err = VpsRecord::try_new(
465 "t",
466 "h",
467 0,
468 "u",
469 SecretString::from("p".to_string()),
470 None::<&str>,
471 None,
472 None,
473 None,
474 None,
475 None,
476 None,
477 false,
478 )
479 .unwrap_err();
480 assert!(err.contains("port") || err.contains("0"), "{err}");
481 }
482
483 #[test]
484 fn debug_does_not_show_password() {
485 let r = VpsRecord::test_new(
486 "t",
487 "h",
488 22,
489 "u",
490 SecretString::from("senha-super-secreta".to_string()),
491 None,
492 None,
493 None,
494 None,
495 None,
496 None,
497 None,
498 false,
499 );
500 let dbg = format!("{r:?}");
501 assert!(!dbg.contains("senha-super-secreta"));
502 assert!(dbg.contains("redacted"));
503 }
504
505 #[test]
506 #[serial_test::serial]
507 fn round_trip_toml_preserves_data() {
508 let tmp = tempfile::TempDir::new().unwrap();
509 crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
510 crate::secrets::set_runtime_flags(true, None, false);
512 let r = VpsRecord::test_new(
513 "producao",
514 "srv.exemplo.com",
515 2222,
516 "admin",
517 SecretString::from("senha-do-admin-longa".to_string()),
518 Some("/home/u/.ssh/id_ed25519"),
519 None,
520 Some(5000),
521 Some(500),
522 Some(50_000),
523 Some(SecretString::from("sudopass".to_string())),
524 None,
525 false,
526 );
527 let toml_str = toml::to_string(&r).expect("serialize");
528 let r2: VpsRecord = toml::from_str(&toml_str).expect("deserialize");
529 assert_eq!(r2.name.as_str(), "producao");
530 assert_eq!(r2.port.get(), 2222);
531 assert_eq!(r2.password.expose_secret(), "senha-do-admin-longa");
532 assert_eq!(
533 r2.key_path.as_ref().map(|k| k.to_string_lossy_owned()),
534 Some("/home/u/.ssh/id_ed25519".into())
535 );
536 assert_eq!(r2.max_command_chars.wire(), 500);
537 assert_eq!(r2.max_output_chars.wire(), 50_000);
538 assert_eq!(
539 r2.sudo_password
540 .as_ref()
541 .map(|s| s.expose_secret().to_string()),
542 Some("sudopass".to_string())
543 );
544 assert!(r2.su_password.is_none());
545 crate::secrets::set_runtime_flags(false, None, false);
546 crate::secrets::set_config_dir(None);
547 }
548
549 #[test]
550 fn migrates_legacy_max_chars() {
551 let legacy = r#"
552nome = "x"
553host = "h"
554porta = 22
555usuario = "u"
556senha = "s"
557timeout_ms = 30000
558max_chars = 4242
559schema_version = 1
560adicionado_em = "2020-01-01T00:00:00Z"
561"#;
562 let r: VpsRecord = toml::from_str(legacy).expect("deserialize legacy PT wire");
563 assert_eq!(r.max_output_chars.wire(), 4242);
564 assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
565 assert_eq!(r.name.as_str(), "x");
566 assert_eq!(r.port.get(), 22);
567 assert_eq!(r.username.as_str(), "u");
568 }
569
570 #[test]
571 fn deserializes_english_wire_keys() {
572 let en = r#"
573name = "prod"
574host = "h.example"
575port = 2222
576username = "admin"
577password = "secret"
578timeout_ms = 5000
579schema_version = 3
580"#;
581 let r: VpsRecord = toml::from_str(en).expect("deserialize EN wire");
582 assert_eq!(r.name.as_str(), "prod");
583 assert_eq!(r.port.get(), 2222);
584 assert_eq!(r.username.as_str(), "admin");
585 assert!(!r.added_at.to_rfc3339().is_empty());
586 }
587
588 #[test]
589 #[serial_test::serial]
590 fn serializes_english_wire_keys() {
591 let tmp = tempfile::TempDir::new().unwrap();
592 crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
593 crate::secrets::set_runtime_flags(true, None, false);
594 let r = VpsRecord::test_new(
595 "prod",
596 "h",
597 22,
598 "u",
599 SecretString::from("p".to_string()),
600 None,
601 None,
602 None,
603 None,
604 None,
605 None,
606 None,
607 false,
608 );
609 let s = toml::to_string(&r).expect("serialize");
610 assert!(s.contains("name ="), "expected EN key name: {s}");
611 assert!(s.contains("port ="), "expected EN key port: {s}");
612 assert!(s.contains("username ="), "expected EN key username: {s}");
613 assert!(s.contains("password ="), "expected EN key password: {s}");
614 assert!(s.contains("added_at ="), "expected EN key added_at: {s}");
615 assert!(!s.contains("nome ="), "must not write PT key nome: {s}");
616 assert!(!s.contains("porta ="), "must not write PT key porta: {s}");
617 assert!(
618 !s.contains("adicionado_em ="),
619 "must not write PT adicionado_em: {s}"
620 );
621 crate::secrets::set_runtime_flags(false, None, false);
622 crate::secrets::set_config_dir(None);
623 }
624
625 #[test]
626 fn deserializes_without_added_at() {
627 let bare = r#"
628nome = "x"
629host = "h"
630porta = 22
631usuario = "u"
632senha = "s"
633schema_version = 2
634"#;
635 let r: VpsRecord = toml::from_str(bare).expect("default added_at");
636 assert!(!r.added_at.to_rfc3339().is_empty());
637 }
638
639 #[test]
640 fn validate_credentials_requires_password_or_key() {
641 let mut r = VpsRecord::test_new(
642 "t",
643 "h",
644 22,
645 "u",
646 SecretString::from(String::new()),
647 None,
648 None,
649 None,
650 None,
651 None,
652 None,
653 None,
654 false,
655 );
656 assert!(r.validate_credentials().is_err());
657 r.key_path = Some(KeyPath::try_new("/tmp/k").unwrap());
658 assert!(r.validate_credentials().is_ok());
659 }
660
661 #[test]
662 fn deny_unknown_fields_on_vps_record() {
663 let bad = r#"
664name = "x"
665host = "1.2.3.4"
666port = 22
667username = "root"
668password = "p"
669timeuot_ms = 1
670"#;
671 let err = toml::from_str::<VpsRecord>(bad).unwrap_err();
672 let s = err.to_string();
673 assert!(
674 s.contains("unknown") || s.contains("timeuot") || s.contains("did not expect"),
675 "expected deny_unknown, got: {s}"
676 );
677 }
678
679 #[test]
680 fn deserialize_rejects_empty_host() {
681 let bad = r#"
682name = "n"
683host = " "
684port = 22
685username = "root"
686password = "p"
687"#;
688 assert!(toml::from_str::<VpsRecord>(bad).is_err());
689 }
690
691 #[test]
692 fn parse_limit_none_and_zero() {
693 assert_eq!(parse_char_limit("none"), 0);
694 assert_eq!(parse_char_limit("0"), 0);
695 assert_eq!(parse_char_limit("1000"), 1000);
696 assert_eq!(effective_limit(0), usize::MAX);
697 assert_eq!(effective_limit(10), 10);
698 }
699}