Skip to main content

ssh_cli/json_wire/
vps_export.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: VPS inventory JSON DTOs (extracted from json_wire monólito).
3#![forbid(unsafe_code)]
4//! Masked VPS / export / import wire types for agent inventory exchange.
5
6use 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/// Masked VPS record for `vps list|show --json` (secrets never raw).
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct MaskedVpsJson {
16    /// Logical name.
17    pub name: String,
18    /// Hostname or IP.
19    pub host: String,
20    /// SSH port.
21    pub port: u16,
22    /// SSH username (`user` on wire, not `username`).
23    pub user: String,
24    /// `null` when empty/key-only; `"***"` when present.
25    pub password: Option<String>,
26    /// Key path when set.
27    pub key_path: Option<String>,
28    /// Masked when present.
29    pub key_passphrase: Option<String>,
30    /// Masked when present.
31    pub sudo_password: Option<String>,
32    /// Masked when present.
33    pub su_password: Option<String>,
34    /// Timeout ms.
35    pub timeout_ms: u64,
36    /// Command char limit.
37    pub max_command_chars: usize,
38    /// Output char limit.
39    pub max_output_chars: usize,
40    /// Sudo/su disabled flag.
41    pub disable_sudo: bool,
42    /// Schema version.
43    pub schema_version: u32,
44    /// RFC 3339 added-at.
45    pub added_at: String,
46    /// Host tags (G-O2 / G-SERDE-06).
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub tags: Vec<String>,
49    /// SSH-over-TLS enabled.
50    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
51    pub tls: bool,
52    /// TLS SNI override.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub tls_sni: Option<String>,
55    /// mTLS client cert path.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub tls_client_cert: Option<String>,
58    /// mTLS client key path (path only; never key material).
59    #[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/// One host entry inside `vps export --json` (redacted or with secrets).
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct ExportHostJson {
108    /// Logical name.
109    pub name: String,
110    /// Hostname or IP.
111    pub host: String,
112    /// SSH port.
113    pub port: u16,
114    /// Wire key `user` (export parity with list/show).
115    pub user: String,
116    /// Password plaintext when include_secrets; [`crate::masking::FIXED_MASK`] when redacted and non-empty; empty string when host has no password (G-E2E-10).
117    pub password: String,
118    /// Key path.
119    pub key_path: Option<String>,
120    /// Optional secrets (null when redacted).
121    pub key_passphrase: Option<String>,
122    /// Optional sudo password.
123    pub sudo_password: Option<String>,
124    /// Optional su password.
125    pub su_password: Option<String>,
126    /// Timeout ms.
127    pub timeout_ms: u64,
128    /// Command char limit.
129    pub max_command_chars: usize,
130    /// Output char limit.
131    pub max_output_chars: usize,
132    /// Sudo/su disabled.
133    pub disable_sudo: bool,
134    /// Schema version.
135    pub schema_version: u32,
136    /// RFC 3339.
137    pub added_at: String,
138    /// Host tags (G-O2 / G-SERDE-06).
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub tags: Vec<String>,
141    /// SSH-over-TLS.
142    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143    pub tls: bool,
144    /// TLS SNI.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub tls_sni: Option<String>,
147    /// mTLS cert path.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub tls_client_cert: Option<String>,
150    /// mTLS key path.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub tls_client_key: Option<String>,
153}
154
155impl ExportHostJson {
156    /// Builds export entry from a record.
157    #[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            // G-E2E-10: redacted non-empty secrets use FIXED_MASK (`***`), not `""`.
199            // Empty password stays `""` so agents distinguish key-only hosts.
200            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/// `vps export --json` envelope.
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245pub struct VpsExportJson {
246    /// Always `true`.
247    pub ok: bool,
248    /// Discriminator: `"vps-export"`.
249    pub event: String,
250    /// File schema version.
251    pub schema_version: u32,
252    /// Whether secrets were included.
253    pub include_secrets: bool,
254    /// Host map (stable key order via `BTreeMap`).
255    pub hosts: BTreeMap<String, ExportHostJson>,
256}
257
258// ---------------------------------------------------------------------------
259// Import DTOs (Must-Ignore unknown fields; dual-read EN + legacy PT)
260// ---------------------------------------------------------------------------
261
262/// Top-level import document: `vps-export` envelope or bare `{ "hosts": … }`.
263#[derive(Debug, Clone, Deserialize)]
264pub struct ImportEnvelope {
265    /// Optional schema version from export envelope.
266    #[serde(default)]
267    pub schema_version: Option<u32>,
268    /// Host map (required).
269    pub hosts: BTreeMap<String, ImportHostEntry>,
270}
271
272/// One host object under `hosts` for JSON import.
273///
274/// Accepts export wire (`user`) and domain/TOML keys (`username` / PT aliases).
275#[derive(Debug, Clone, Default, Deserialize)]
276pub struct ImportHostEntry {
277    /// Name (falls back to map key when absent).
278    #[serde(default, alias = "nome")]
279    pub name: Option<String>,
280    /// Host.
281    #[serde(default)]
282    pub host: Option<String>,
283    /// Port (validated into `u16` after parse).
284    #[serde(default, alias = "porta")]
285    pub port: Option<u64>,
286    /// Username — export uses `user`; TOML/domain uses `username`.
287    #[serde(default, alias = "usuario", alias = "user")]
288    pub username: Option<String>,
289    /// Password.
290    #[serde(default, alias = "senha")]
291    pub password: Option<String>,
292    /// Key path.
293    #[serde(default)]
294    pub key_path: Option<String>,
295    /// Key passphrase.
296    #[serde(default)]
297    pub key_passphrase: Option<String>,
298    /// Timeout ms.
299    #[serde(default)]
300    pub timeout_ms: Option<u64>,
301    /// Command limit.
302    #[serde(default)]
303    pub max_command_chars: Option<usize>,
304    /// Output limit (legacy `max_chars`).
305    #[serde(default, alias = "max_chars")]
306    pub max_output_chars: Option<usize>,
307    /// Sudo password.
308    #[serde(default, alias = "senha_sudo")]
309    pub sudo_password: Option<String>,
310    /// Su password.
311    #[serde(default, alias = "senha_su")]
312    pub su_password: Option<String>,
313    /// Disable sudo/su.
314    #[serde(default)]
315    pub disable_sudo: Option<bool>,
316    /// Schema version.
317    #[serde(default)]
318    pub schema_version: Option<u32>,
319    /// Added-at RFC 3339.
320    #[serde(default, alias = "adicionado_em")]
321    pub added_at: Option<String>,
322    /// Host tags (G-O2 / G-SERDE-06).
323    #[serde(default)]
324    pub tags: Option<Vec<String>>,
325    /// SSH-over-TLS.
326    #[serde(default)]
327    pub tls: Option<bool>,
328    /// TLS SNI.
329    #[serde(default)]
330    pub tls_sni: Option<String>,
331    /// mTLS cert path.
332    #[serde(default)]
333    pub tls_client_cert: Option<String>,
334    /// mTLS key path.
335    #[serde(default)]
336    pub tls_client_key: Option<String>,
337}
338
339impl ImportHostEntry {
340    /// Converts to a domain [`VpsRecord`], validating port ∈ 1..=65535 when set.
341    ///
342    /// # Errors
343    /// Returns a human-readable message when `port` is out of range.
344    pub fn into_record(
345        self,
346        map_key: &str,
347        defaults: ImportDefaults,
348    ) -> Result<VpsRecord, String> {
349        // G-TYPE-10: domain try_new — empty host/user rejected (no silent defaults).
350        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/// Default field values applied when import JSON omits them.
408#[derive(Debug, Clone, Copy)]
409pub struct ImportDefaults {
410    /// Default timeout.
411    pub timeout_ms: u64,
412    /// Default command limit.
413    pub max_command_chars: usize,
414    /// Default output limit.
415    pub max_output_chars: usize,
416    /// Default schema version.
417    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}