1#![forbid(unsafe_code)]
4use crate::domain::secret_nonempty;
7use crate::masking::mask;
8use crate::vps::model::VpsRecord;
9use secrecy::ExposeSecret;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct MaskedVpsJson {
16 pub name: String,
18 pub host: String,
20 pub port: u16,
22 pub user: String,
24 pub password: Option<String>,
26 pub key_path: Option<String>,
28 pub key_passphrase: Option<String>,
30 pub sudo_password: Option<String>,
32 pub su_password: Option<String>,
34 pub timeout_ms: u64,
36 pub max_command_chars: usize,
38 pub max_output_chars: usize,
40 pub disable_sudo: bool,
42 pub schema_version: u32,
44 pub added_at: String,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub tags: Vec<String>,
49 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51 pub tls: bool,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub tls_sni: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub tls_client_cert: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub tls_client_key: Option<String>,
61}
62
63impl From<&VpsRecord> for MaskedVpsJson {
64 fn from(r: &VpsRecord) -> Self {
65 use crate::domain::secret_nonempty;
66 let password = if secret_nonempty(&r.password) {
67 Some(mask(r.password.expose_secret()).to_string())
68 } else {
69 None
70 };
71 Self {
72 name: r.name.as_str().to_owned(),
73 host: r.host.as_str().to_owned(),
74 port: r.port.get(),
75 user: r.username.as_str().to_owned(),
76 password,
77 key_path: r.key_path.as_ref().map(|k| k.to_string_lossy_owned()),
78 key_passphrase: r
79 .key_passphrase
80 .as_ref()
81 .map(|s| mask(s.expose_secret()).to_string()),
82 sudo_password: r
83 .sudo_password
84 .as_ref()
85 .map(|s| mask(s.expose_secret()).to_string()),
86 su_password: r
87 .su_password
88 .as_ref()
89 .map(|s| mask(s.expose_secret()).to_string()),
90 timeout_ms: r.timeout_ms.get(),
91 max_command_chars: r.max_command_chars.wire(),
92 max_output_chars: r.max_output_chars.wire(),
93 disable_sudo: r.disable_sudo,
94 schema_version: r.schema_version,
95 added_at: r.added_at.to_rfc3339(),
96 tags: r.tags.iter().map(|t| t.as_str().to_owned()).collect(),
97 tls: r.tls,
98 tls_sni: r.tls_sni.clone(),
99 tls_client_cert: r.tls_client_cert.clone(),
100 tls_client_key: r.tls_client_key.clone(),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct ExportHostJson {
108 pub name: String,
110 pub host: String,
112 pub port: u16,
114 pub user: String,
116 pub password: String,
118 pub key_path: Option<String>,
120 pub key_passphrase: Option<String>,
122 pub sudo_password: Option<String>,
124 pub su_password: Option<String>,
126 pub timeout_ms: u64,
128 pub max_command_chars: usize,
130 pub max_output_chars: usize,
132 pub disable_sudo: bool,
134 pub schema_version: u32,
136 pub added_at: String,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub tags: Vec<String>,
141 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143 pub tls: bool,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub tls_sni: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub tls_client_cert: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub tls_client_key: Option<String>,
153}
154
155impl ExportHostJson {
156 #[must_use]
158 pub fn from_record(r: &VpsRecord, include_secrets: bool) -> Self {
159 let tags: Vec<String> = r.tags.iter().map(|t| t.as_str().to_owned()).collect();
160 let key_path = r.key_path.as_ref().map(|k| k.to_string_lossy_owned());
161 let tls = r.tls;
162 let tls_sni = r.tls_sni.clone();
163 let tls_client_cert = r.tls_client_cert.clone();
164 let tls_client_key = r.tls_client_key.clone();
165 if include_secrets {
166 Self {
167 name: r.name.as_str().to_owned(),
168 host: r.host.as_str().to_owned(),
169 port: r.port.get(),
170 user: r.username.as_str().to_owned(),
171 password: r.password.expose_secret().to_string(),
172 key_path,
173 key_passphrase: r
174 .key_passphrase
175 .as_ref()
176 .map(|s| s.expose_secret().to_string()),
177 sudo_password: r
178 .sudo_password
179 .as_ref()
180 .map(|s| s.expose_secret().to_string()),
181 su_password: r
182 .su_password
183 .as_ref()
184 .map(|s| s.expose_secret().to_string()),
185 timeout_ms: r.timeout_ms.get(),
186 max_command_chars: r.max_command_chars.wire(),
187 max_output_chars: r.max_output_chars.wire(),
188 disable_sudo: r.disable_sudo,
189 schema_version: r.schema_version,
190 added_at: r.added_at.to_rfc3339(),
191 tags,
192 tls,
193 tls_sni,
194 tls_client_cert,
195 tls_client_key,
196 }
197 } else {
198 let password = if secret_nonempty(&r.password) {
201 mask(r.password.expose_secret()).to_string()
202 } else {
203 String::new()
204 };
205 Self {
206 name: r.name.as_str().to_owned(),
207 host: r.host.as_str().to_owned(),
208 port: r.port.get(),
209 user: r.username.as_str().to_owned(),
210 password,
211 key_path,
212 key_passphrase: r
213 .key_passphrase
214 .as_ref()
215 .filter(|s| secret_nonempty(s))
216 .map(|s| mask(s.expose_secret()).to_string()),
217 sudo_password: r
218 .sudo_password
219 .as_ref()
220 .filter(|s| secret_nonempty(s))
221 .map(|s| mask(s.expose_secret()).to_string()),
222 su_password: r
223 .su_password
224 .as_ref()
225 .filter(|s| secret_nonempty(s))
226 .map(|s| mask(s.expose_secret()).to_string()),
227 timeout_ms: r.timeout_ms.get(),
228 max_command_chars: r.max_command_chars.wire(),
229 max_output_chars: r.max_output_chars.wire(),
230 disable_sudo: r.disable_sudo,
231 schema_version: r.schema_version,
232 added_at: r.added_at.to_rfc3339(),
233 tags,
234 tls,
235 tls_sni,
236 tls_client_cert,
237 tls_client_key,
238 }
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245pub struct VpsExportJson {
246 pub ok: bool,
248 pub event: String,
250 pub schema_version: u32,
252 pub include_secrets: bool,
254 pub hosts: BTreeMap<String, ExportHostJson>,
256}
257
258#[derive(Debug, Clone, Deserialize)]
264pub struct ImportEnvelope {
265 #[serde(default)]
267 pub schema_version: Option<u32>,
268 pub hosts: BTreeMap<String, ImportHostEntry>,
270}
271
272#[derive(Debug, Clone, Default, Deserialize)]
276pub struct ImportHostEntry {
277 #[serde(default, alias = "nome")]
279 pub name: Option<String>,
280 #[serde(default)]
282 pub host: Option<String>,
283 #[serde(default, alias = "porta")]
285 pub port: Option<u64>,
286 #[serde(default, alias = "usuario", alias = "user")]
288 pub username: Option<String>,
289 #[serde(default, alias = "senha")]
291 pub password: Option<String>,
292 #[serde(default)]
294 pub key_path: Option<String>,
295 #[serde(default)]
297 pub key_passphrase: Option<String>,
298 #[serde(default)]
300 pub timeout_ms: Option<u64>,
301 #[serde(default)]
303 pub max_command_chars: Option<usize>,
304 #[serde(default, alias = "max_chars")]
306 pub max_output_chars: Option<usize>,
307 #[serde(default, alias = "senha_sudo")]
309 pub sudo_password: Option<String>,
310 #[serde(default, alias = "senha_su")]
312 pub su_password: Option<String>,
313 #[serde(default)]
315 pub disable_sudo: Option<bool>,
316 #[serde(default)]
318 pub schema_version: Option<u32>,
319 #[serde(default, alias = "adicionado_em")]
321 pub added_at: Option<String>,
322 #[serde(default)]
324 pub tags: Option<Vec<String>>,
325 #[serde(default)]
327 pub tls: Option<bool>,
328 #[serde(default)]
330 pub tls_sni: Option<String>,
331 #[serde(default)]
333 pub tls_client_cert: Option<String>,
334 #[serde(default)]
336 pub tls_client_key: Option<String>,
337}
338
339impl ImportHostEntry {
340 pub fn into_record(
345 self,
346 map_key: &str,
347 defaults: ImportDefaults,
348 ) -> Result<VpsRecord, String> {
349 let port_u64 = self.port.unwrap_or(22);
351 let port = match u16::try_from(port_u64) {
352 Ok(p) => p,
353 Err(_) => {
354 return Err(format!(
355 "invalid SSH port {port_u64} (use 1..=65535) for host '{map_key}'"
356 ));
357 }
358 };
359 let name = self.name.unwrap_or_else(|| map_key.to_string());
360 let host = self
361 .host
362 .filter(|h| !h.trim().is_empty())
363 .ok_or_else(|| format!("missing host for import key '{map_key}'"))?;
364 let username = self
365 .username
366 .filter(|u| !u.trim().is_empty())
367 .ok_or_else(|| format!("missing username for import key '{map_key}'"))?;
368 let password = secrecy::SecretString::from(self.password.unwrap_or_default());
369 let key_passphrase = self.key_passphrase.map(secrecy::SecretString::from);
370 let sudo_password = self.sudo_password.map(secrecy::SecretString::from);
371 let su_password = self.su_password.map(secrecy::SecretString::from);
372 let mut record = VpsRecord::try_new(
373 name,
374 host,
375 port,
376 username,
377 password,
378 self.key_path.as_deref(),
379 key_passphrase,
380 Some(self.timeout_ms.unwrap_or(defaults.timeout_ms)),
381 Some(
382 self.max_command_chars
383 .unwrap_or(defaults.max_command_chars),
384 ),
385 Some(self.max_output_chars.unwrap_or(defaults.max_output_chars)),
386 sudo_password,
387 su_password,
388 self.disable_sudo.unwrap_or(false),
389 )?;
390 record.schema_version = self.schema_version.unwrap_or(defaults.schema_version);
391 if let Some(a) = self.added_at {
392 record.added_at = crate::domain::Rfc3339Utc::try_new(a).map_err(|e| e.to_string())?;
393 }
394 if let Some(tags) = self.tags {
395 record
396 .set_tags_from_raw(tags)
397 .map_err(|e| e.to_string())?;
398 }
399 record.tls = self.tls.unwrap_or(false);
400 record.tls_sni = self.tls_sni;
401 record.tls_client_cert = self.tls_client_cert;
402 record.tls_client_key = self.tls_client_key;
403 Ok(record)
404 }
405}
406
407#[derive(Debug, Clone, Copy)]
409pub struct ImportDefaults {
410 pub timeout_ms: u64,
412 pub max_command_chars: usize,
414 pub max_output_chars: usize,
416 pub schema_version: u32,
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::json_wire::{
424 strip_utf8_bom, ErrorEnvelope, ExecutionJson, SuccessEnvelope, UTF8_BOM,
425 };
426 use crate::ssh::ExecutionOutput;
427 use crate::vps::model::{
428 CURRENT_SCHEMA_VERSION, DEFAULT_MAX_COMMAND_CHARS, DEFAULT_MAX_OUTPUT_CHARS,
429 DEFAULT_TIMEOUT_MS,
430 };
431 use secrecy::SecretString;
432
433 fn sample_record() -> VpsRecord {
434 VpsRecord::test_new(
435 "vps-teste",
436 "1.2.3.4",
437 22,
438 "root",
439 SecretString::from("senha-super-secreta".to_string()),
440 None,
441 None,
442 Some(5000),
443 Some(1000),
444 Some(1000),
445 Some(SecretString::from("sudo-password-longa-aqui".to_string())),
446 None,
447 false,
448 )
449 }
450
451 #[test]
452 fn strip_bom_removes_leading_feff() {
453 let with = format!("{UTF8_BOM}{{\"ok\":true}}");
454 assert_eq!(strip_utf8_bom(&with), "{\"ok\":true}");
455 assert_eq!(strip_utf8_bom("{\"ok\":true}"), "{\"ok\":true}");
456 }
457
458 #[test]
459 fn compact_json_is_single_line() {
460 let env = ErrorEnvelope {
461 exit_code: 65,
462 error_code: "invalid_argument".into(),
463 message: "bad".into(),
464 remote_exit_code: None,
465 error_class: crate::errors::ErrorClass::Permanent,
466 retryable: false,
467 suggestion: None,
468 };
469 let s = serde_json::to_string(&env).unwrap();
470 assert!(!s.contains('\n'), "agent wire must be compact: {s}");
471 assert!(s.starts_with('{'));
472 assert!(s.contains("\"exit_code\":65"));
473 }
474
475 #[test]
476 fn masked_vps_roundtrip_value_eq() {
477 let r = sample_record();
478 let m = MaskedVpsJson::from(&r);
479 let v = serde_json::to_value(&m).unwrap();
480 assert_eq!(v["name"], "vps-teste");
481 assert_eq!(v["user"], "root");
482 assert_eq!(v["password"], "***");
483 assert_eq!(v["sudo_password"], "***");
484 assert!(v["su_password"].is_null());
485 let back: MaskedVpsJson = serde_json::from_value(v).unwrap();
486 assert_eq!(back.name, m.name);
487 assert_eq!(back.password.as_deref(), Some("***"));
488 }
489
490 #[test]
491 fn execution_json_from_output() {
492 let o = ExecutionOutput {
493 stdout: "out".into(),
494 stderr: "err".into(),
495 exit_code: Some(0),
496 truncated_stdout: false,
497 truncated_stderr: true,
498 duration_ms: 42,
499 };
500 let j = ExecutionJson::from(&o);
501 let s = serde_json::to_string(&j).unwrap();
502 assert!(s.contains("\"duration_ms\":42"));
503 assert!(s.contains("\"truncated_stderr\":true"));
504 }
505
506 #[test]
507 fn import_envelope_accepts_user_alias_and_unknown_fields() {
508 let raw = r#"{
509 "event": "vps-export",
510 "extra_future": 1,
511 "hosts": {
512 "h1": {
513 "host": "10.0.0.1",
514 "user": "admin",
515 "port": 2222,
516 "password": "p",
517 "future_field": true
518 }
519 }
520 }"#;
521 let env: ImportEnvelope = serde_json::from_str(raw).unwrap();
522 let rec = env.hosts["h1"]
523 .clone()
524 .into_record(
525 "h1",
526 ImportDefaults {
527 timeout_ms: DEFAULT_TIMEOUT_MS,
528 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
529 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
530 schema_version: CURRENT_SCHEMA_VERSION,
531 },
532 )
533 .unwrap();
534 assert_eq!(rec.username.as_str(), "admin");
535 assert_eq!(rec.port.get(), 2222);
536 assert_eq!(rec.host.as_str(), "10.0.0.1");
537 }
538
539 #[test]
540 fn import_rejects_port_out_of_range() {
541 let entry = ImportHostEntry {
542 host: Some("h".into()),
543 port: Some(70000),
544 username: Some("u".into()),
545 password: Some("p".into()),
546 ..Default::default()
547 };
548 let err = entry
549 .into_record(
550 "bad",
551 ImportDefaults {
552 timeout_ms: DEFAULT_TIMEOUT_MS,
553 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
554 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
555 schema_version: CURRENT_SCHEMA_VERSION,
556 },
557 )
558 .unwrap_err();
559 assert!(err.contains("70000"), "{err}");
560 }
561
562 #[test]
563 fn export_envelope_stable_keys_btree() {
564 let mut hosts = BTreeMap::new();
565 let r = sample_record();
566 hosts.insert("b".into(), ExportHostJson::from_record(&r, false));
567 hosts.insert("a".into(), ExportHostJson::from_record(&r, false));
568 let env = VpsExportJson {
569 ok: true,
570 event: "vps-export".into(),
571 schema_version: CURRENT_SCHEMA_VERSION,
572 include_secrets: false,
573 hosts,
574 };
575 let s = serde_json::to_string(&env).unwrap();
576 let pos_a = s.find("\"a\":").unwrap();
577 let pos_b = s.find("\"b\":").unwrap();
578 assert!(pos_a < pos_b, "BTreeMap must emit sorted keys: {s}");
579 assert!(!s.contains('\n'));
580 }
581
582 #[test]
583 fn success_envelope_flattens_fields() {
584 let mut fields = BTreeMap::new();
585 fields.insert("name".into(), serde_json::json!("x"));
586 let e = SuccessEnvelope::new("vps-added", fields);
587 let v = serde_json::to_value(&e).unwrap();
588 assert_eq!(v["ok"], true);
589 assert_eq!(v["event"], "vps-added");
590 assert_eq!(v["name"], "x");
591 }
592
593 #[test]
594 fn tags_roundtrip_export_import_json() {
595 let mut r = sample_record();
596 r.set_tags_from_raw(["prod", "web"]).unwrap();
597 let export = ExportHostJson::from_record(&r, true);
598 assert_eq!(export.tags, vec!["prod", "web"]);
599 let masked = MaskedVpsJson::from(&r);
600 assert_eq!(masked.tags, vec!["prod", "web"]);
601 let entry = ImportHostEntry {
602 name: Some(r.name.as_str().to_owned()),
603 host: Some(r.host.as_str().to_owned()),
604 port: Some(u64::from(r.port.get())),
605 username: Some(r.username.as_str().to_owned()),
606 password: Some("secret".into()),
607 timeout_ms: Some(r.timeout_ms.get()),
608 max_command_chars: Some(r.max_command_chars.wire()),
609 max_output_chars: Some(r.max_output_chars.wire()),
610 disable_sudo: Some(false),
611 schema_version: Some(CURRENT_SCHEMA_VERSION),
612 added_at: Some(r.added_at.to_rfc3339()),
613 tags: Some(vec!["prod".into(), "web".into()]),
614 ..Default::default()
615 };
616 let defaults = ImportDefaults {
617 timeout_ms: DEFAULT_TIMEOUT_MS,
618 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
619 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
620 schema_version: CURRENT_SCHEMA_VERSION,
621 };
622 let back = entry.into_record("k", defaults).unwrap();
623 assert_eq!(
624 back.tags.iter().map(|t| t.as_str()).collect::<Vec<_>>(),
625 vec!["prod", "web"]
626 );
627 }
628}