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(self, map_key: &str, defaults: ImportDefaults) -> Result<VpsRecord, String> {
345 let port_u64 = self.port.unwrap_or(22);
347 let port = match u16::try_from(port_u64) {
348 Ok(p) => p,
349 Err(_) => {
350 return Err(format!(
351 "invalid SSH port {port_u64} (use 1..=65535) for host '{map_key}'"
352 ));
353 }
354 };
355 let name = self.name.unwrap_or_else(|| map_key.to_string());
356 let host = self
357 .host
358 .filter(|h| !h.trim().is_empty())
359 .ok_or_else(|| format!("missing host for import key '{map_key}'"))?;
360 let username = self
361 .username
362 .filter(|u| !u.trim().is_empty())
363 .ok_or_else(|| format!("missing username for import key '{map_key}'"))?;
364 let password = secrecy::SecretString::from(self.password.unwrap_or_default());
365 let key_passphrase = self.key_passphrase.map(secrecy::SecretString::from);
366 let sudo_password = self.sudo_password.map(secrecy::SecretString::from);
367 let su_password = self.su_password.map(secrecy::SecretString::from);
368 let mut record = VpsRecord::try_new(
369 name,
370 host,
371 port,
372 username,
373 password,
374 self.key_path.as_deref(),
375 key_passphrase,
376 Some(self.timeout_ms.unwrap_or(defaults.timeout_ms)),
377 Some(self.max_command_chars.unwrap_or(defaults.max_command_chars)),
378 Some(self.max_output_chars.unwrap_or(defaults.max_output_chars)),
379 sudo_password,
380 su_password,
381 self.disable_sudo.unwrap_or(false),
382 )?;
383 record.schema_version = self.schema_version.unwrap_or(defaults.schema_version);
384 if let Some(a) = self.added_at {
385 record.added_at = crate::domain::Rfc3339Utc::try_new(a).map_err(|e| e.to_string())?;
386 }
387 if let Some(tags) = self.tags {
388 record.set_tags_from_raw(tags).map_err(|e| e.to_string())?;
389 }
390 record.tls = self.tls.unwrap_or(false);
391 record.tls_sni = self.tls_sni;
392 record.tls_client_cert = self.tls_client_cert;
393 record.tls_client_key = self.tls_client_key;
394 Ok(record)
395 }
396}
397
398#[derive(Debug, Clone, Copy)]
400pub struct ImportDefaults {
401 pub timeout_ms: u64,
403 pub max_command_chars: usize,
405 pub max_output_chars: usize,
407 pub schema_version: u32,
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::json_wire::{
415 strip_utf8_bom, ErrorEnvelope, ExecutionJson, SuccessEnvelope, UTF8_BOM,
416 };
417 use crate::ssh::ExecutionOutput;
418 use crate::vps::model::{
419 CURRENT_SCHEMA_VERSION, DEFAULT_MAX_COMMAND_CHARS, DEFAULT_MAX_OUTPUT_CHARS,
420 DEFAULT_TIMEOUT_MS,
421 };
422 use secrecy::SecretString;
423
424 fn sample_record() -> VpsRecord {
425 VpsRecord::test_new(
426 "vps-teste",
427 "1.2.3.4",
428 22,
429 "root",
430 SecretString::from("senha-super-secreta".to_string()),
431 None,
432 None,
433 Some(5000),
434 Some(1000),
435 Some(1000),
436 Some(SecretString::from("sudo-password-longa-aqui".to_string())),
437 None,
438 false,
439 )
440 }
441
442 #[test]
443 fn strip_bom_removes_leading_feff() {
444 let with = format!("{UTF8_BOM}{{\"ok\":true}}");
445 assert_eq!(strip_utf8_bom(&with), "{\"ok\":true}");
446 assert_eq!(strip_utf8_bom("{\"ok\":true}"), "{\"ok\":true}");
447 }
448
449 #[test]
450 fn compact_json_is_single_line() {
451 let env = ErrorEnvelope {
452 exit_code: 65,
453 error_code: "invalid_argument".into(),
454 message: "bad".into(),
455 remote_exit_code: None,
456 error_class: crate::errors::ErrorClass::Permanent,
457 retryable: false,
458 suggestion: None,
459 };
460 let s = serde_json::to_string(&env).unwrap();
461 assert!(!s.contains('\n'), "agent wire must be compact: {s}");
462 assert!(s.starts_with('{'));
463 assert!(s.contains("\"exit_code\":65"));
464 }
465
466 #[test]
467 fn masked_vps_roundtrip_value_eq() {
468 let r = sample_record();
469 let m = MaskedVpsJson::from(&r);
470 let v = serde_json::to_value(&m).unwrap();
471 assert_eq!(v["name"], "vps-teste");
472 assert_eq!(v["user"], "root");
473 assert_eq!(v["password"], "***");
474 assert_eq!(v["sudo_password"], "***");
475 assert!(v["su_password"].is_null());
476 let back: MaskedVpsJson = serde_json::from_value(v).unwrap();
477 assert_eq!(back.name, m.name);
478 assert_eq!(back.password.as_deref(), Some("***"));
479 }
480
481 #[test]
482 fn execution_json_from_output() {
483 let o = ExecutionOutput {
484 stdout: "out".into(),
485 stderr: "err".into(),
486 exit_code: Some(0),
487 truncated_stdout: false,
488 truncated_stderr: true,
489 duration_ms: 42,
490 };
491 let j = ExecutionJson::from(&o);
492 let s = serde_json::to_string(&j).unwrap();
493 assert!(s.contains("\"duration_ms\":42"));
494 assert!(s.contains("\"truncated_stderr\":true"));
495 }
496
497 #[test]
498 fn import_envelope_accepts_user_alias_and_unknown_fields() {
499 let raw = r#"{
500 "event": "vps-export",
501 "extra_future": 1,
502 "hosts": {
503 "h1": {
504 "host": "10.0.0.1",
505 "user": "admin",
506 "port": 2222,
507 "password": "p",
508 "future_field": true
509 }
510 }
511 }"#;
512 let env: ImportEnvelope = serde_json::from_str(raw).unwrap();
513 let rec = env.hosts["h1"]
514 .clone()
515 .into_record(
516 "h1",
517 ImportDefaults {
518 timeout_ms: DEFAULT_TIMEOUT_MS,
519 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
520 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
521 schema_version: CURRENT_SCHEMA_VERSION,
522 },
523 )
524 .unwrap();
525 assert_eq!(rec.username.as_str(), "admin");
526 assert_eq!(rec.port.get(), 2222);
527 assert_eq!(rec.host.as_str(), "10.0.0.1");
528 }
529
530 #[test]
531 fn import_rejects_port_out_of_range() {
532 let entry = ImportHostEntry {
533 host: Some("h".into()),
534 port: Some(70000),
535 username: Some("u".into()),
536 password: Some("p".into()),
537 ..Default::default()
538 };
539 let err = entry
540 .into_record(
541 "bad",
542 ImportDefaults {
543 timeout_ms: DEFAULT_TIMEOUT_MS,
544 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
545 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
546 schema_version: CURRENT_SCHEMA_VERSION,
547 },
548 )
549 .unwrap_err();
550 assert!(err.contains("70000"), "{err}");
551 }
552
553 #[test]
554 fn export_envelope_stable_keys_btree() {
555 let mut hosts = BTreeMap::new();
556 let r = sample_record();
557 hosts.insert("b".into(), ExportHostJson::from_record(&r, false));
558 hosts.insert("a".into(), ExportHostJson::from_record(&r, false));
559 let env = VpsExportJson {
560 ok: true,
561 event: "vps-export".into(),
562 schema_version: CURRENT_SCHEMA_VERSION,
563 include_secrets: false,
564 hosts,
565 };
566 let s = serde_json::to_string(&env).unwrap();
567 let pos_a = s.find("\"a\":").unwrap();
568 let pos_b = s.find("\"b\":").unwrap();
569 assert!(pos_a < pos_b, "BTreeMap must emit sorted keys: {s}");
570 assert!(!s.contains('\n'));
571 }
572
573 #[test]
574 fn success_envelope_flattens_fields() {
575 let mut fields = BTreeMap::new();
576 fields.insert("name".into(), serde_json::json!("x"));
577 let e = SuccessEnvelope::new("vps-added", fields);
578 let v = serde_json::to_value(&e).unwrap();
579 assert_eq!(v["ok"], true);
580 assert_eq!(v["event"], "vps-added");
581 assert_eq!(v["name"], "x");
582 }
583
584 #[test]
585 fn tags_roundtrip_export_import_json() {
586 let mut r = sample_record();
587 r.set_tags_from_raw(["prod", "web"]).unwrap();
588 let export = ExportHostJson::from_record(&r, true);
589 assert_eq!(export.tags, vec!["prod", "web"]);
590 let masked = MaskedVpsJson::from(&r);
591 assert_eq!(masked.tags, vec!["prod", "web"]);
592 let entry = ImportHostEntry {
593 name: Some(r.name.as_str().to_owned()),
594 host: Some(r.host.as_str().to_owned()),
595 port: Some(u64::from(r.port.get())),
596 username: Some(r.username.as_str().to_owned()),
597 password: Some("secret".into()),
598 timeout_ms: Some(r.timeout_ms.get()),
599 max_command_chars: Some(r.max_command_chars.wire()),
600 max_output_chars: Some(r.max_output_chars.wire()),
601 disable_sudo: Some(false),
602 schema_version: Some(CURRENT_SCHEMA_VERSION),
603 added_at: Some(r.added_at.to_rfc3339()),
604 tags: Some(vec!["prod".into(), "web".into()]),
605 ..Default::default()
606 };
607 let defaults = ImportDefaults {
608 timeout_ms: DEFAULT_TIMEOUT_MS,
609 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
610 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
611 schema_version: CURRENT_SCHEMA_VERSION,
612 };
613 let back = entry.into_record("k", defaults).unwrap();
614 assert_eq!(
615 back.tags.iter().map(|t| t.as_str()).collect::<Vec<_>>(),
616 vec!["prod", "web"]
617 );
618 }
619}