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(self, map_key: &str, defaults: ImportDefaults) -> Result<VpsRecord, String> {
345        // G-TYPE-10: domain try_new — empty host/user rejected (no silent defaults).
346        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/// Default field values applied when import JSON omits them.
399#[derive(Debug, Clone, Copy)]
400pub struct ImportDefaults {
401    /// Default timeout.
402    pub timeout_ms: u64,
403    /// Default command limit.
404    pub max_command_chars: usize,
405    /// Default output limit.
406    pub max_output_chars: usize,
407    /// Default schema version.
408    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}