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::model;
10use super::{load, save, validate_key_path_exists, write_atomic, ConfigFile};
11use crate::cli::OutputFormat;
12use crate::errors::{SshCliError, SshCliResult};
13use anyhow::Result;
14use secrecy::SecretString;
15use std::collections::BTreeMap;
16use std::path::Path;
17
18/// Export hosts to TOML/JSON.
19pub(super) fn run_export(
20    path: &Path,
21    include_secrets: bool,
22    output: Option<&Path>,
23    json: bool,
24    i_understand_secrets_on_stdout: bool,
25    format: OutputFormat,
26) -> Result<()> {
27    // GAP-AUD-011: refuse plaintext secrets on non-file stdout without explicit ack.
28    if include_secrets && output.is_none() && !i_understand_secrets_on_stdout {
29        let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
30        if !stdout_is_tty {
31            return Err(SshCliError::InvalidArgument(
32                "refusing --include-secrets to a pipe/non-TTY stdout; \
33                 use `--output <file>` (mode 0o600) or pass `--i-understand-secrets-on-stdout`"
34                    .into(),
35            )
36            .into());
37        }
38    }
39
40    let file = load(path)?;
41    let mut export = ConfigFile {
42        schema_version: model::CURRENT_SCHEMA_VERSION,
43        hosts: BTreeMap::new(),
44    };
45    for (k, mut v) in file.hosts {
46        if !include_secrets {
47            // EXP-001 parity: redacted clears secrets (never sshcli-enc of empty).
48            v.password = SecretString::from(String::new());
49            v.sudo_password = None;
50            v.su_password = None;
51            v.key_passphrase = None;
52        }
53        v.schema_version = model::CURRENT_SCHEMA_VERSION;
54        export.hosts.insert(k, v);
55    }
56
57    // G-AUD-03: JSON body when local `--json` OR global format is Json.
58    // Agent wire: compact single-root JSON (Rules Rust JSON — no pretty-print).
59    let wants_json = json || format == OutputFormat::Json;
60    let bytes = if wants_json {
61        let envelope = crate::output::export_envelope_json(
62            &export.hosts,
63            export.schema_version,
64            include_secrets,
65        );
66        let text = serde_json::to_string(&envelope)?;
67        text.into_bytes()
68    } else {
69        let text = toml::to_string_pretty(&export)?;
70        text.into_bytes()
71    };
72
73    if let Some(out_path) = output {
74        write_atomic(out_path, &bytes)?;
75        let path_display = out_path.display().to_string();
76        crate::output::emit_success(
77            "vps-export",
78            serde_json::json!({
79                "path": path_display,
80                "include_secrets": include_secrets,
81                "format": if wants_json { "json" } else { "toml" },
82            }),
83            &crate::i18n::t(crate::i18n::Message::ExportCompleted { path: path_display }),
84            format == OutputFormat::Json,
85        )?;
86    } else {
87        // TOML/JSON body to stdout (agent-first: single payload).
88        use std::io::Write;
89        let mut out = std::io::stdout().lock();
90        out.write_all(&bytes)?;
91        if !bytes.ends_with(b"\n") {
92            out.write_all(b"\n")?;
93        }
94    }
95    Ok(())
96}
97
98/// Parses import source: TOML wire or JSON `vps-export` envelope / hosts map.
99/// Parse import payload (TOML or JSON envelope). Public for fuzz targets (G-SERDE-12).
100pub fn parse_import_payload(text: &str) -> SshCliResult<ConfigFile> {
101    // Rules JSON: strip UTF-8 BOM before format detection / parse.
102    let text = crate::json_wire::strip_utf8_bom(text);
103    let trimmed = text.trim_start();
104    if trimmed.starts_with('{') {
105        parse_import_json(trimmed)
106    } else {
107        crate::validation::from_toml_str(text)
108    }
109}
110
111fn parse_import_json(text: &str) -> SshCliResult<ConfigFile> {
112    // G-SERDE-08/14: path errors + warn on unknown fields (Must-Ignore).
113    let envelope: crate::json_wire::ImportEnvelope =
114        crate::validation::from_json_str_warn_unused(text)?;
115    let defaults = crate::json_wire::ImportDefaults {
116        timeout_ms: model::DEFAULT_TIMEOUT_MS,
117        max_command_chars: model::DEFAULT_MAX_COMMAND_CHARS,
118        max_output_chars: model::DEFAULT_MAX_OUTPUT_CHARS,
119        schema_version: model::CURRENT_SCHEMA_VERSION,
120    };
121    let mut hosts = BTreeMap::new();
122    for (key, entry) in envelope.hosts {
123        let rec = entry
124            .into_record(&key, defaults)
125            .map_err(SshCliError::InvalidArgument)?;
126        hosts.insert(key, rec);
127    }
128    Ok(ConfigFile {
129        schema_version: envelope
130            .schema_version
131            .unwrap_or(model::CURRENT_SCHEMA_VERSION),
132        hosts,
133    })
134}
135
136/// Import hosts from TOML/JSON file.
137pub(super) fn run_import(
138    path: &Path,
139    file: &Path,
140    allow_incomplete: bool,
141    format: OutputFormat,
142) -> Result<()> {
143    // Cap import file size (same ceiling as config.toml — OOM hygiene).
144    let text = crate::paths::read_text_capped(file, crate::paths::MAX_CONFIG_TOML_BYTES)
145        .map_err(SshCliError::Io)?;
146    let imported = parse_import_payload(&text)?;
147    let mut current = load(path)?;
148    let mut imported_count = 0usize;
149    // C2: an import silently replaces same-named hosts, so the plan names them.
150    // Reporting only a count would hide the one fact that decides whether the
151    // import is safe to run.
152    let mut planned: Vec<serde_json::Value> = Vec::new();
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                let replaced = current.hosts.insert(name.as_str().to_owned(), v).is_some();
166                planned.push(serde_json::json!({
167                    "name": name.as_str(),
168                    "replaces_existing": replaced,
169                    "incomplete": false,
170                }));
171                imported_count += 1;
172            }
173            Err(ref err) if allow_incomplete => {
174                // GAP-SSH-IMP-001: incomplete skeleton allowed.
175                tracing::warn!(host = %name, error = %err, "import incomplete allowed");
176                let replaced = current.hosts.insert(name.as_str().to_owned(), v).is_some();
177                planned.push(serde_json::json!({
178                    "name": name.as_str(),
179                    "replaces_existing": replaced,
180                    "incomplete": true,
181                }));
182                imported_count += 1;
183            }
184            Err(err) => {
185                // Detect redacted export.
186                let redacted = !v.has_password() && !v.has_key();
187                if redacted {
188                    return Err(SshCliError::InvalidArgument(format!(
189                        "host '{name}' looks like a redacted export (no password/key). \
190                         Use `vps export --include-secrets`, complete with `vps edit`, \
191                         or `vps import --allow-incomplete`. Detail: {err}"
192                    ))
193                    .into());
194                }
195                return Err(SshCliError::InvalidArgument(format!(
196                    "host '{name}' invalid in import: {err}"
197                ))
198                .into());
199            }
200        }
201    }
202    // Every per-host validation above already ran, so a plan that reaches this
203    // point is one the real run would also accept — the preview and the execution
204    // share the same code path up to the single `save`.
205    if crate::cli::dry_run_stop(
206        "vps-import",
207        &[
208            ("source", serde_json::json!(file.display().to_string())),
209            ("config_path", serde_json::json!(path.display().to_string())),
210            ("imported", serde_json::json!(imported_count)),
211            ("hosts", serde_json::Value::Array(planned)),
212        ],
213    )? {
214        return Ok(());
215    }
216    current.schema_version = model::CURRENT_SCHEMA_VERSION;
217    save(path, &current)?;
218    crate::output::emit_success(
219        "vps-import",
220        serde_json::json!({ "imported": imported_count }),
221        &crate::i18n::t(crate::i18n::Message::ImportCompleted),
222        format == OutputFormat::Json,
223    )?;
224    Ok(())
225}