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 for (k, mut v) in imported.hosts {
150 let name = crate::domain::VpsName::try_new(&k).map_err(|e| {
152 SshCliError::InvalidArgument(format!("invalid VPS name in import '{k}': {e}"))
153 })?;
154 v.name = name.clone();
155 v.normalize_schema();
156 if let Some(ref key) = v.key_path {
157 validate_key_path_exists(&key.to_string_lossy_owned())?;
158 }
159 match v.validate() {
160 Ok(()) => {
161 current.hosts.insert(name.as_str().to_owned(), v);
162 imported_count += 1;
163 }
164 Err(ref err) if allow_incomplete => {
165 tracing::warn!(host = %name, error = %err, "import incomplete allowed");
167 current.hosts.insert(name.as_str().to_owned(), v);
168 imported_count += 1;
169 }
170 Err(err) => {
171 let redacted = !v.has_password() && !v.has_key();
173 if redacted {
174 return Err(SshCliError::InvalidArgument(format!(
175 "host '{name}' looks like a redacted export (no password/key). \
176 Use `vps export --include-secrets`, complete with `vps edit`, \
177 or `vps import --allow-incomplete`. Detail: {err}"
178 ))
179 .into());
180 }
181 return Err(SshCliError::InvalidArgument(format!(
182 "host '{name}' invalid in import: {err}"
183 ))
184 .into());
185 }
186 }
187 }
188 current.schema_version = model::CURRENT_SCHEMA_VERSION;
189 save(path, ¤t)?;
190 crate::output::emit_success(
191 "vps-import",
192 serde_json::json!({ "imported": imported_count }),
193 &crate::i18n::t(crate::i18n::Message::ImportCompleted),
194 format == OutputFormat::Json,
195 )?;
196 Ok(())
197}