ssh_cli/vps/
import_export.rs1#![forbid(unsafe_code)]
4use 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
20pub(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 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 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 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 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
102pub fn parse_import_payload(text: &str) -> SshCliResult<ConfigFile> {
105 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 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
140pub(super) fn run_import(
142 path: &Path,
143 file: &Path,
144 allow_incomplete: bool,
145 format: OutputFormat,
146) -> Result<()> {
147 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 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 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 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, ¤t)?;
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}