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("su_password", &self.su_password.as_ref().map(|_| "<redacted>"))
158 .field("disable_sudo", &self.disable_sudo)
159 .field("schema_version", &self.schema_version)
160 .field("added_at", &self.added_at.to_rfc3339())
161 .field("tags", &self.tags)
162 .field("tls", &self.tls)
163 .field("use_agent", &self.use_agent)
164 .field("agent_socket", &self.agent_socket)
165 .field("tls_sni", &self.tls_sni)
166 .field("tls_client_cert", &self.tls_client_cert)
167 .field(
168 "tls_client_key",
169 &self.tls_client_key.as_ref().map(|_| "<redacted-path>"),
170 )
171 .finish()
172 }
173}
174
175impl VpsRecord {
176 #[allow(clippy::too_many_arguments)]
181 pub fn try_new(
182 name: impl AsRef<str>,
183 host: impl AsRef<str>,
184 port: u16,
185 username: impl AsRef<str>,
186 password: SecretString,
187 key_path: Option<impl AsRef<str>>,
188 key_passphrase: Option<SecretString>,
189 timeout_ms: Option<u64>,
190 max_command_chars: Option<usize>,
191 max_output_chars: Option<usize>,
192 sudo_password: Option<SecretString>,
193 su_password: Option<SecretString>,
194 disable_sudo: bool,
195 ) -> Result<Self, String> {
196 let key_path = KeyPath::try_from_optional(key_path).map_err(|e| e.to_string())?;
197 Ok(Self {
198 name: VpsName::try_new(name).map_err(|e| e.to_string())?,
199 host: SshHost::try_new(host).map_err(|e| e.to_string())?,
200 port: SshPort::try_new(port).map_err(|e| e.to_string())?,
201 username: SshUser::try_new(username).map_err(|e| e.to_string())?,
202 password,
203 key_path,
204 key_passphrase,
205 use_agent: false,
206 agent_socket: None,
207 timeout_ms: TimeoutMs::try_new(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS))
208 .map_err(|e| e.to_string())?,
209 max_command_chars: CharLimit::try_new(
210 max_command_chars.unwrap_or(DEFAULT_MAX_COMMAND_CHARS),
211 )
212 .map_err(|e| e.to_string())?,
213 max_output_chars: CharLimit::try_new(
214 max_output_chars.unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
215 )
216 .map_err(|e| e.to_string())?,
217 sudo_password,
218 su_password,
219 disable_sudo,
220 schema_version: CURRENT_SCHEMA_VERSION,
221 added_at: Rfc3339Utc::now(),
222 tags: Vec::new(),
223 tls: false,
224 tls_sni: None,
225 tls_client_cert: None,
226 tls_client_key: None,
227 })
228 }
229
230 #[cfg(test)]
232 #[allow(clippy::too_many_arguments)]
233 pub(crate) fn test_new(
234 name: impl AsRef<str>,
235 host: impl AsRef<str>,
236 port: u16,
237 username: impl AsRef<str>,
238 password: SecretString,
239 key_path: Option<&str>,
240 key_passphrase: Option<SecretString>,
241 timeout_ms: Option<u64>,
242 max_command_chars: Option<usize>,
243 max_output_chars: Option<usize>,
244 sudo_password: Option<SecretString>,
245 su_password: Option<SecretString>,
246 disable_sudo: bool,
247 ) -> Self {
248 Self::try_new(
249 name,
250 host,
251 port,
252 username,
253 password,
254 key_path,
255 key_passphrase,
256 timeout_ms,
257 max_command_chars,
258 max_output_chars,
259 sudo_password,
260 su_password,
261 disable_sudo,
262 )
263 .expect("test_new requires valid domain fields")
264 }
265
266 #[must_use]
268 pub fn has_any_tag(&self, wanted: &[HostTag]) -> bool {
269 if wanted.is_empty() {
270 return true;
271 }
272 wanted
273 .iter()
274 .any(|w| self.tags.iter().any(|t| t.as_str() == w.as_str()))
275 }
276
277 #[must_use]
279 pub fn has_password(&self) -> bool {
280 secret_nonempty(&self.password)
281 }
282
283 #[must_use]
285 pub fn has_key(&self) -> bool {
286 self.key_path.is_some()
287 }
288
289 pub fn validate_credentials(&self) -> Result<(), crate::domain::DomainError> {
294 let n = u8::from(self.has_password()) + u8::from(self.has_key()) + u8::from(self.use_agent);
295 if n == 0 {
296 return Err(crate::domain::DomainError::new(
297 "vps_auth",
298 "must provide exactly one of --password, --key, or --use-agent",
299 ));
300 }
301 if n > 1 {
302 return Err(crate::domain::DomainError::new(
303 "vps_auth",
304 "primary auth methods are mutually exclusive: use only one of --password, --key, or --use-agent",
305 ));
306 }
307 Ok(())
308 }
309
310 pub fn validate_structure(&self) -> Result<(), crate::domain::DomainError> {
315 if self.tags.len() > MAX_TAGS {
316 return Err(crate::domain::DomainError::new(
317 "tags",
318 format!("at most {MAX_TAGS} tags allowed"),
319 ));
320 }
321 Ok(())
323 }
324
325 pub fn validate(&self) -> Result<(), crate::domain::DomainError> {
330 self.validate_structure()?;
331 self.validate_credentials()
332 }
333
334 pub fn normalize_schema(&mut self) {
336 if self.schema_version < CURRENT_SCHEMA_VERSION {
337 self.schema_version = CURRENT_SCHEMA_VERSION;
338 }
339 }
340
341 pub fn set_tags_from_raw(
343 &mut self,
344 raw: impl IntoIterator<Item = impl AsRef<str>>,
345 ) -> Result<(), crate::domain::DomainError> {
346 self.tags = try_tags(raw)?;
347 Ok(())
348 }
349}
350
351#[must_use]
355pub fn parse_char_limit(s: &str) -> usize {
356 let t = s.trim();
357 if t.eq_ignore_ascii_case("none") || t == "0" {
358 0
359 } else {
360 t.parse().unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
361 }
362}
363
364#[must_use]
368pub fn effective_limit(configured: usize) -> usize {
369 CharLimit::try_new(configured)
370 .map(|c| c.effective())
371 .unwrap_or(usize::MAX)
372}
373
374#[must_use]
376pub fn effective_char_limit(limit: CharLimit) -> usize {
377 limit.effective()
378}
379
380mod secret_string_serde {
381 use super::{ExposeSecret, SecretString};
382 use serde::{Deserialize, Deserializer, Serializer};
383
384 pub fn serialize<S: Serializer>(value: &SecretString, s: S) -> Result<S::Ok, S::Error> {
385 let plain = value.expose_secret();
386 let out = crate::secrets::serialize_secret(plain).map_err(serde::ser::Error::custom)?;
387 s.serialize_str(&out)
388 }
389
390 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
391 let s = String::deserialize(d)?;
392 let plain = crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
393 Ok(SecretString::from(plain))
394 }
395}
396
397mod opcao_secret_string_serde {
398 use super::{ExposeSecret, SecretString};
399 use serde::{Deserialize, Deserializer, Serializer};
400
401 pub fn serialize<S: Serializer>(value: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
402 match value {
403 Some(v) => {
404 let out = crate::secrets::serialize_secret(v.expose_secret())
405 .map_err(serde::ser::Error::custom)?;
406 s.serialize_some(&out)
407 }
408 None => s.serialize_none(),
409 }
410 }
411
412 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
413 let opt = Option::<String>::deserialize(d)?;
414 match opt {
415 None => Ok(None),
416 Some(s) => {
417 let plain =
418 crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
419 Ok(Some(SecretString::from(plain)))
420 }
421 }
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn try_new_applies_defaults() {
431 let r = VpsRecord::test_new(
432 "teste",
433 "1.2.3.4",
434 22,
435 "root",
436 SecretString::from("senha".to_string()),
437 None,
438 None,
439 None,
440 None,
441 None,
442 None,
443 None,
444 false,
445 );
446 assert_eq!(r.timeout_ms.get(), DEFAULT_TIMEOUT_MS);
447 assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
448 assert_eq!(r.max_output_chars.wire(), DEFAULT_MAX_OUTPUT_CHARS);
449 assert_eq!(r.schema_version, CURRENT_SCHEMA_VERSION);
450 assert!(!r.added_at.to_rfc3339().is_empty());
451 }
452
453 #[test]
454 fn try_new_rejects_port_zero() {
455 let err = VpsRecord::try_new(
456 "t",
457 "h",
458 0,
459 "u",
460 SecretString::from("p".to_string()),
461 None::<&str>,
462 None,
463 None,
464 None,
465 None,
466 None,
467 None,
468 false,
469 )
470 .unwrap_err();
471 assert!(err.contains("port") || err.contains("0"), "{err}");
472 }
473
474 #[test]
475 fn debug_does_not_show_password() {
476 let r = VpsRecord::test_new(
477 "t",
478 "h",
479 22,
480 "u",
481 SecretString::from("senha-super-secreta".to_string()),
482 None,
483 None,
484 None,
485 None,
486 None,
487 None,
488 None,
489 false,
490 );
491 let dbg = format!("{r:?}");
492 assert!(!dbg.contains("senha-super-secreta"));
493 assert!(dbg.contains("redacted"));
494 }
495
496 #[test]
497 #[serial_test::serial]
498 fn round_trip_toml_preserves_data() {
499 let tmp = tempfile::TempDir::new().unwrap();
500 crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
501 crate::secrets::set_runtime_flags(true, None, false);
503 let r = VpsRecord::test_new(
504 "producao",
505 "srv.exemplo.com",
506 2222,
507 "admin",
508 SecretString::from("senha-do-admin-longa".to_string()),
509 Some("/home/u/.ssh/id_ed25519"),
510 None,
511 Some(5000),
512 Some(500),
513 Some(50_000),
514 Some(SecretString::from("sudopass".to_string())),
515 None,
516 false,
517 );
518 let toml_str = toml::to_string(&r).expect("serialize");
519 let r2: VpsRecord = toml::from_str(&toml_str).expect("deserialize");
520 assert_eq!(r2.name.as_str(), "producao");
521 assert_eq!(r2.port.get(), 2222);
522 assert_eq!(r2.password.expose_secret(), "senha-do-admin-longa");
523 assert_eq!(
524 r2.key_path.as_ref().map(|k| k.to_string_lossy_owned()),
525 Some("/home/u/.ssh/id_ed25519".into())
526 );
527 assert_eq!(r2.max_command_chars.wire(), 500);
528 assert_eq!(r2.max_output_chars.wire(), 50_000);
529 assert_eq!(
530 r2.sudo_password
531 .as_ref()
532 .map(|s| s.expose_secret().to_string()),
533 Some("sudopass".to_string())
534 );
535 assert!(r2.su_password.is_none());
536 crate::secrets::set_runtime_flags(false, None, false);
537 crate::secrets::set_config_dir(None);
538 }
539
540 #[test]
541 fn migrates_legacy_max_chars() {
542 let legacy = r#"
543nome = "x"
544host = "h"
545porta = 22
546usuario = "u"
547senha = "s"
548timeout_ms = 30000
549max_chars = 4242
550schema_version = 1
551adicionado_em = "2020-01-01T00:00:00Z"
552"#;
553 let r: VpsRecord = toml::from_str(legacy).expect("deserialize legacy PT wire");
554 assert_eq!(r.max_output_chars.wire(), 4242);
555 assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
556 assert_eq!(r.name.as_str(), "x");
557 assert_eq!(r.port.get(), 22);
558 assert_eq!(r.username.as_str(), "u");
559 }
560
561 #[test]
562 fn deserializes_english_wire_keys() {
563 let en = r#"
564name = "prod"
565host = "h.example"
566port = 2222
567username = "admin"
568password = "secret"
569timeout_ms = 5000
570schema_version = 3
571"#;
572 let r: VpsRecord = toml::from_str(en).expect("deserialize EN wire");
573 assert_eq!(r.name.as_str(), "prod");
574 assert_eq!(r.port.get(), 2222);
575 assert_eq!(r.username.as_str(), "admin");
576 assert!(!r.added_at.to_rfc3339().is_empty());
577 }
578
579 #[test]
580 #[serial_test::serial]
581 fn serializes_english_wire_keys() {
582 let tmp = tempfile::TempDir::new().unwrap();
583 crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
584 crate::secrets::set_runtime_flags(true, None, false);
585 let r = VpsRecord::test_new(
586 "prod",
587 "h",
588 22,
589 "u",
590 SecretString::from("p".to_string()),
591 None,
592 None,
593 None,
594 None,
595 None,
596 None,
597 None,
598 false,
599 );
600 let s = toml::to_string(&r).expect("serialize");
601 assert!(s.contains("name ="), "expected EN key name: {s}");
602 assert!(s.contains("port ="), "expected EN key port: {s}");
603 assert!(s.contains("username ="), "expected EN key username: {s}");
604 assert!(s.contains("password ="), "expected EN key password: {s}");
605 assert!(s.contains("added_at ="), "expected EN key added_at: {s}");
606 assert!(!s.contains("nome ="), "must not write PT key nome: {s}");
607 assert!(!s.contains("porta ="), "must not write PT key porta: {s}");
608 assert!(!s.contains("adicionado_em ="), "must not write PT adicionado_em: {s}");
609 crate::secrets::set_runtime_flags(false, None, false);
610 crate::secrets::set_config_dir(None);
611 }
612
613 #[test]
614 fn deserializes_without_added_at() {
615 let bare = r#"
616nome = "x"
617host = "h"
618porta = 22
619usuario = "u"
620senha = "s"
621schema_version = 2
622"#;
623 let r: VpsRecord = toml::from_str(bare).expect("default added_at");
624 assert!(!r.added_at.to_rfc3339().is_empty());
625 }
626
627 #[test]
628 fn validate_credentials_requires_password_or_key() {
629 let mut r = VpsRecord::test_new(
630 "t",
631 "h",
632 22,
633 "u",
634 SecretString::from(String::new()),
635 None,
636 None,
637 None,
638 None,
639 None,
640 None,
641 None,
642 false,
643 );
644 assert!(r.validate_credentials().is_err());
645 r.key_path = Some(KeyPath::try_new("/tmp/k").unwrap());
646 assert!(r.validate_credentials().is_ok());
647 }
648
649 #[test]
650 fn deny_unknown_fields_on_vps_record() {
651 let bad = r#"
652name = "x"
653host = "1.2.3.4"
654port = 22
655username = "root"
656password = "p"
657timeuot_ms = 1
658"#;
659 let err = toml::from_str::<VpsRecord>(bad).unwrap_err();
660 let s = err.to_string();
661 assert!(
662 s.contains("unknown") || s.contains("timeuot") || s.contains("did not expect"),
663 "expected deny_unknown, got: {s}"
664 );
665 }
666
667 #[test]
668 fn deserialize_rejects_empty_host() {
669 let bad = r#"
670name = "n"
671host = " "
672port = 22
673username = "root"
674password = "p"
675"#;
676 assert!(toml::from_str::<VpsRecord>(bad).is_err());
677 }
678
679 #[test]
680 fn parse_limit_none_and_zero() {
681 assert_eq!(parse_char_limit("none"), 0);
682 assert_eq!(parse_char_limit("0"), 0);
683 assert_eq!(parse_char_limit("1000"), 1000);
684 assert_eq!(effective_limit(0), usize::MAX);
685 assert_eq!(effective_limit(10), 10);
686 }
687}