ssh_cli/vps/
import_export.rs1#![forbid(unsafe_code)]
4use 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
18pub(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 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 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 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 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
98pub fn parse_import_payload(text: &str) -> SshCliResult<ConfigFile> {
101 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 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
136pub(super) fn run_import(
138 path: &Path,
139 file: &Path,
140 allow_incomplete: bool,
141 format: OutputFormat,
142) -> Result<()> {
143 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 let mut planned: Vec<serde_json::Value> = Vec::new();
153 for (k, mut v) in imported.hosts {
154 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 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 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 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, ¤t)?;
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}