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::{strip_utf8_bom, SuccessEnvelope, UTF8_BOM};
415 use crate::vps::model::{
416 CURRENT_SCHEMA_VERSION, DEFAULT_MAX_COMMAND_CHARS, DEFAULT_MAX_OUTPUT_CHARS,
417 DEFAULT_TIMEOUT_MS,
418 };
419 use secrecy::SecretString;
420
421 fn sample_record() -> VpsRecord {
422 VpsRecord::test_new(
423 "vps-teste",
424 "1.2.3.4",
425 22,
426 "root",
427 SecretString::from("senha-super-secreta".to_string()),
428 None,
429 None,
430 Some(5000),
431 Some(1000),
432 Some(1000),
433 Some(SecretString::from("sudo-password-longa-aqui".to_string())),
434 None,
435 false,
436 )
437 }
438
439 #[test]
440 fn strip_bom_removes_leading_feff() {
441 let with = format!("{UTF8_BOM}{{\"ok\":true}}");
442 assert_eq!(strip_utf8_bom(&with), "{\"ok\":true}");
443 assert_eq!(strip_utf8_bom("{\"ok\":true}"), "{\"ok\":true}");
444 }
445
446 #[test]
447 fn masked_vps_roundtrip_value_eq() {
448 let r = sample_record();
449 let m = MaskedVpsJson::from(&r);
450 let v = serde_json::to_value(&m).unwrap();
451 assert_eq!(v["name"], "vps-teste");
452 assert_eq!(v["user"], "root");
453 assert_eq!(v["password"], "***");
454 assert_eq!(v["sudo_password"], "***");
455 assert!(v["su_password"].is_null());
456 let back: MaskedVpsJson = serde_json::from_value(v).unwrap();
457 assert_eq!(back.name, m.name);
458 assert_eq!(back.password.as_deref(), Some("***"));
459 }
460
461 #[test]
465 fn import_envelope_accepts_user_alias_and_unknown_fields() {
466 let raw = r#"{
467 "event": "vps-export",
468 "extra_future": 1,
469 "hosts": {
470 "h1": {
471 "host": "10.0.0.1",
472 "user": "admin",
473 "port": 2222,
474 "password": "p",
475 "future_field": true
476 }
477 }
478 }"#;
479 let env: ImportEnvelope = serde_json::from_str(raw).unwrap();
480 let rec = env.hosts["h1"]
481 .clone()
482 .into_record(
483 "h1",
484 ImportDefaults {
485 timeout_ms: DEFAULT_TIMEOUT_MS,
486 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
487 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
488 schema_version: CURRENT_SCHEMA_VERSION,
489 },
490 )
491 .unwrap();
492 assert_eq!(rec.username.as_str(), "admin");
493 assert_eq!(rec.port.get(), 2222);
494 assert_eq!(rec.host.as_str(), "10.0.0.1");
495 }
496
497 #[test]
498 fn import_rejects_port_out_of_range() {
499 let entry = ImportHostEntry {
500 host: Some("h".into()),
501 port: Some(70000),
502 username: Some("u".into()),
503 password: Some("p".into()),
504 ..Default::default()
505 };
506 let err = entry
507 .into_record(
508 "bad",
509 ImportDefaults {
510 timeout_ms: DEFAULT_TIMEOUT_MS,
511 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
512 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
513 schema_version: CURRENT_SCHEMA_VERSION,
514 },
515 )
516 .unwrap_err();
517 assert!(err.contains("70000"), "{err}");
518 }
519
520 #[test]
521 fn export_envelope_stable_keys_btree() {
522 let mut hosts = BTreeMap::new();
523 let r = sample_record();
524 hosts.insert("b".into(), ExportHostJson::from_record(&r, false));
525 hosts.insert("a".into(), ExportHostJson::from_record(&r, false));
526 let env = VpsExportJson {
527 ok: true,
528 event: "vps-export".into(),
529 schema_version: CURRENT_SCHEMA_VERSION,
530 include_secrets: false,
531 hosts,
532 };
533 let s = serde_json::to_string(&env).unwrap();
534 let pos_a = s.find("\"a\":").unwrap();
535 let pos_b = s.find("\"b\":").unwrap();
536 assert!(pos_a < pos_b, "BTreeMap must emit sorted keys: {s}");
537 assert!(!s.contains('\n'));
538 }
539
540 #[test]
541 fn success_envelope_flattens_fields() {
542 let mut fields = BTreeMap::new();
543 fields.insert("name".into(), serde_json::json!("x"));
544 let e = SuccessEnvelope::new("vps-added", fields);
545 let v = serde_json::to_value(&e).unwrap();
546 assert_eq!(v["ok"], true);
547 assert_eq!(v["event"], "vps-added");
548 assert_eq!(v["name"], "x");
549 }
550
551 #[test]
552 fn tags_roundtrip_export_import_json() {
553 let mut r = sample_record();
554 r.set_tags_from_raw(["prod", "web"]).unwrap();
555 let export = ExportHostJson::from_record(&r, true);
556 assert_eq!(export.tags, vec!["prod", "web"]);
557 let masked = MaskedVpsJson::from(&r);
558 assert_eq!(masked.tags, vec!["prod", "web"]);
559 let entry = ImportHostEntry {
560 name: Some(r.name.as_str().to_owned()),
561 host: Some(r.host.as_str().to_owned()),
562 port: Some(u64::from(r.port.get())),
563 username: Some(r.username.as_str().to_owned()),
564 password: Some("secret".into()),
565 timeout_ms: Some(r.timeout_ms.get()),
566 max_command_chars: Some(r.max_command_chars.wire()),
567 max_output_chars: Some(r.max_output_chars.wire()),
568 disable_sudo: Some(false),
569 schema_version: Some(CURRENT_SCHEMA_VERSION),
570 added_at: Some(r.added_at.to_rfc3339()),
571 tags: Some(vec!["prod".into(), "web".into()]),
572 ..Default::default()
573 };
574 let defaults = ImportDefaults {
575 timeout_ms: DEFAULT_TIMEOUT_MS,
576 max_command_chars: DEFAULT_MAX_COMMAND_CHARS,
577 max_output_chars: DEFAULT_MAX_OUTPUT_CHARS,
578 schema_version: CURRENT_SCHEMA_VERSION,
579 };
580 let back = entry.into_record("k", defaults).unwrap();
581 assert_eq!(
582 back.tags.iter().map(|t| t.as_str()).collect::<Vec<_>>(),
583 vec!["prod", "web"]
584 );
585 }
586}