Skip to main content

ssh_cli/vps/
import_export.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP-03: import/export extracted from `vps/mod` (SRP).
3#![forbid(unsafe_code)]
4//! VPS inventory import/export (TOML + agent JSON envelope).
5//!
6//! Workload: **local disk + optional secret materialization**. Sequential justified:
7//! single atomic write path; concurrent import would race flock/rename.
8
9use super::{
10    load, save, validate_key_path_exists, write_atomic, ConfigFile,
11};
12use super::model;
13use crate::cli::OutputFormat;
14use crate::errors::{SshCliError, SshCliResult};
15use anyhow::Result;
16use secrecy::SecretString;
17use std::collections::BTreeMap;
18use std::path::Path;
19
20/// Export hosts to TOML/JSON.
21pub(super) fn run_export(
22    path: &Path,
23    include_secrets: bool,
24    output: Option<&Path>,
25    json: bool,
26    i_understand_secrets_on_stdout: bool,
27    format: OutputFormat,
28) -> Result<()> {
29    // GAP-AUD-011: refuse plaintext secrets on non-file stdout without explicit ack.
30    if include_secrets && output.is_none() && !i_understand_secrets_on_stdout {
31        let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
32        if !stdout_is_tty {
33            return Err(SshCliError::InvalidArgument(
34                "refusing --include-secrets to a pipe/non-TTY stdout; \
35                 use `--output <file>` (mode 0o600) or pass `--i-understand-secrets-on-stdout`"
36                    .into(),
37            )
38            .into());
39        }
40    }
41
42    let file = load(path)?;
43    let mut export = ConfigFile {
44        schema_version: model::CURRENT_SCHEMA_VERSION,
45        hosts: BTreeMap::new(),
46    };
47    for (k, mut v) in file.hosts {
48        if !include_secrets {
49            // EXP-001 parity: redacted clears secrets (never sshcli-enc of empty).
50            v.password = SecretString::from(String::new());
51            v.sudo_password = None;
52            v.su_password = None;
53            v.key_passphrase = None;
54        }
55        v.schema_version = model::CURRENT_SCHEMA_VERSION;
56        export.hosts.insert(k, v);
57    }
58
59    // G-AUD-03: JSON body when local `--json` OR global format is Json.
60    // Agent wire: compact single-root JSON (Rules Rust JSON — no pretty-print).
61    let wants_json = json || format == OutputFormat::Json;
62    let bytes = if wants_json {
63        let envelope = crate::output::export_envelope_json(
64            &export.hosts,
65            export.schema_version,
66            include_secrets,
67        );
68        let text = serde_json::to_string(&envelope)?;
69        text.into_bytes()
70    } else {
71        let text = toml::to_string_pretty(&export)?;
72        text.into_bytes()
73    };
74
75    if let Some(out_path) = output {
76        write_atomic(out_path, &bytes)?;
77        let path_display = out_path.display().to_string();
78        crate::output::emit_success(
79            "vps-export",
80            serde_json::json!({
81                "path": path_display,
82                "include_secrets": include_secrets,
83                "format": if wants_json { "json" } else { "toml" },
84            }),
85            &crate::i18n::t(crate::i18n::Message::ExportCompleted {
86                path: path_display,
87            }),
88            format == OutputFormat::Json,
89        )?;
90    } else {
91        // TOML/JSON body to stdout (agent-first: single payload).
92        use std::io::Write;
93        let mut out = std::io::stdout().lock();
94        out.write_all(&bytes)?;
95        if !bytes.ends_with(b"\n") {
96            out.write_all(b"\n")?;
97        }
98    }
99    Ok(())
100}
101
102/// Parses import source: TOML wire or JSON `vps-export` envelope / hosts map.
103/// Parse import payload (TOML or JSON envelope). Public for fuzz targets (G-SERDE-12).
104pub fn parse_import_payload(text: &str) -> SshCliResult<ConfigFile> {
105    // Rules JSON: strip UTF-8 BOM before format detection / parse.
106    let text = crate::json_wire::strip_utf8_bom(text);
107    let trimmed = text.trim_start();
108    if trimmed.starts_with('{') {
109        parse_import_json(trimmed)
110    } else {
111        crate::validation::from_toml_str(text)
112    }
113}
114
115fn parse_import_json(text: &str) -> SshCliResult<ConfigFile> {
116    // G-SERDE-08/14: path errors + warn on unknown fields (Must-Ignore).
117    let envelope: crate::json_wire::ImportEnvelope =
118            crate::validation::from_json_str_warn_unused(text)?;
119    let defaults = crate::json_wire::ImportDefaults {
120        timeout_ms: model::DEFAULT_TIMEOUT_MS,
121        max_command_chars: model::DEFAULT_MAX_COMMAND_CHARS,
122        max_output_chars: model::DEFAULT_MAX_OUTPUT_CHARS,
123        schema_version: model::CURRENT_SCHEMA_VERSION,
124    };
125    let mut hosts = BTreeMap::new();
126    for (key, entry) in envelope.hosts {
127        let rec = entry
128            .into_record(&key, defaults)
129            .map_err(SshCliError::InvalidArgument)?;
130        hosts.insert(key, rec);
131    }
132    Ok(ConfigFile {
133        schema_version: envelope
134            .schema_version
135            .unwrap_or(model::CURRENT_SCHEMA_VERSION),
136        hosts,
137    })
138}
139
140/// Import hosts from TOML/JSON file.
141pub(super) fn run_import(
142    path: &Path,
143    file: &Path,
144    allow_incomplete: bool,
145    format: OutputFormat,
146) -> Result<()> {
147    // Cap import file size (same ceiling as config.toml — OOM hygiene).
148    let text = crate::paths::read_text_capped(file, crate::paths::MAX_CONFIG_TOML_BYTES)
149        .map_err(SshCliError::Io)?;
150    let imported = parse_import_payload(&text)?;
151    let mut current = load(path)?;
152    let mut imported_count = 0usize;
153    for (k, mut v) in imported.hosts {
154        // VAL-001 on import (domain VpsName = path-safe NFC name).
155        let name = crate::domain::VpsName::try_new(&k).map_err(|e| {
156            SshCliError::InvalidArgument(format!("invalid VPS name in import '{k}': {e}"))
157        })?;
158        v.name = name.clone();
159        v.normalize_schema();
160        if let Some(ref key) = v.key_path {
161            validate_key_path_exists(&key.to_string_lossy_owned())?;
162        }
163        match v.validate() {
164            Ok(()) => {
165                current.hosts.insert(name.as_str().to_owned(), v);
166                imported_count += 1;
167            }
168            Err(ref err) if allow_incomplete => {
169                // GAP-SSH-IMP-001: incomplete skeleton allowed.
170                tracing::warn!(host = %name, error = %err, "import incomplete allowed");
171                current.hosts.insert(name.as_str().to_owned(), v);
172                imported_count += 1;
173            }
174            Err(err) => {
175                // Detect redacted export.
176                let redacted = !v.has_password() && !v.has_key();
177                if redacted {
178                    return Err(SshCliError::InvalidArgument(format!(
179                        "host '{name}' looks like a redacted export (no password/key). \
180                         Use `vps export --include-secrets`, complete with `vps edit`, \
181                         or `vps import --allow-incomplete`. Detail: {err}"
182                    ))
183                    .into());
184                }
185                return Err(SshCliError::InvalidArgument(format!(
186                    "host '{name}' invalid in import: {err}"
187                ))
188                .into());
189            }
190        }
191    }
192    current.schema_version = model::CURRENT_SCHEMA_VERSION;
193    save(path, &current)?;
194    crate::output::emit_success(
195        "vps-import",
196        serde_json::json!({ "imported": imported_count }),
197        &crate::i18n::t(crate::i18n::Message::ImportCompleted),
198        format == OutputFormat::Json,
199    )?;
200    Ok(())
201}