#![forbid(unsafe_code)]
use super::{
load, save, validate_key_path_exists, write_atomic, ConfigFile,
};
use super::model;
use crate::cli::OutputFormat;
use crate::errors::{SshCliError, SshCliResult};
use anyhow::Result;
use secrecy::SecretString;
use std::collections::BTreeMap;
use std::path::Path;
pub(super) fn run_export(
path: &Path,
include_secrets: bool,
output: Option<&Path>,
json: bool,
i_understand_secrets_on_stdout: bool,
format: OutputFormat,
) -> Result<()> {
if include_secrets && output.is_none() && !i_understand_secrets_on_stdout {
let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
if !stdout_is_tty {
return Err(SshCliError::InvalidArgument(
"refusing --include-secrets to a pipe/non-TTY stdout; \
use `--output <file>` (mode 0o600) or pass `--i-understand-secrets-on-stdout`"
.into(),
)
.into());
}
}
let file = load(path)?;
let mut export = ConfigFile {
schema_version: model::CURRENT_SCHEMA_VERSION,
hosts: BTreeMap::new(),
};
for (k, mut v) in file.hosts {
if !include_secrets {
v.password = SecretString::from(String::new());
v.sudo_password = None;
v.su_password = None;
v.key_passphrase = None;
}
v.schema_version = model::CURRENT_SCHEMA_VERSION;
export.hosts.insert(k, v);
}
let wants_json = json || format == OutputFormat::Json;
let bytes = if wants_json {
let envelope = crate::output::export_envelope_json(
&export.hosts,
export.schema_version,
include_secrets,
);
let text = serde_json::to_string(&envelope)?;
text.into_bytes()
} else {
let text = toml::to_string_pretty(&export)?;
text.into_bytes()
};
if let Some(out_path) = output {
write_atomic(out_path, &bytes)?;
let path_display = out_path.display().to_string();
crate::output::emit_success(
"vps-export",
serde_json::json!({
"path": path_display,
"include_secrets": include_secrets,
"format": if wants_json { "json" } else { "toml" },
}),
&crate::i18n::t(crate::i18n::Message::ExportCompleted {
path: path_display,
}),
format == OutputFormat::Json,
)?;
} else {
use std::io::Write;
let mut out = std::io::stdout().lock();
out.write_all(&bytes)?;
if !bytes.ends_with(b"\n") {
out.write_all(b"\n")?;
}
}
Ok(())
}
pub fn parse_import_payload(text: &str) -> SshCliResult<ConfigFile> {
let text = crate::json_wire::strip_utf8_bom(text);
let trimmed = text.trim_start();
if trimmed.starts_with('{') {
parse_import_json(trimmed)
} else {
crate::validation::from_toml_str(text)
}
}
fn parse_import_json(text: &str) -> SshCliResult<ConfigFile> {
let envelope: crate::json_wire::ImportEnvelope =
crate::validation::from_json_str_warn_unused(text)?;
let defaults = crate::json_wire::ImportDefaults {
timeout_ms: model::DEFAULT_TIMEOUT_MS,
max_command_chars: model::DEFAULT_MAX_COMMAND_CHARS,
max_output_chars: model::DEFAULT_MAX_OUTPUT_CHARS,
schema_version: model::CURRENT_SCHEMA_VERSION,
};
let mut hosts = BTreeMap::new();
for (key, entry) in envelope.hosts {
let rec = entry
.into_record(&key, defaults)
.map_err(SshCliError::InvalidArgument)?;
hosts.insert(key, rec);
}
Ok(ConfigFile {
schema_version: envelope
.schema_version
.unwrap_or(model::CURRENT_SCHEMA_VERSION),
hosts,
})
}
pub(super) fn run_import(
path: &Path,
file: &Path,
allow_incomplete: bool,
format: OutputFormat,
) -> Result<()> {
let text = crate::paths::read_text_capped(file, crate::paths::MAX_CONFIG_TOML_BYTES)
.map_err(SshCliError::Io)?;
let imported = parse_import_payload(&text)?;
let mut current = load(path)?;
let mut imported_count = 0usize;
for (k, mut v) in imported.hosts {
let name = crate::domain::VpsName::try_new(&k).map_err(|e| {
SshCliError::InvalidArgument(format!("invalid VPS name in import '{k}': {e}"))
})?;
v.name = name.clone();
v.normalize_schema();
if let Some(ref key) = v.key_path {
validate_key_path_exists(&key.to_string_lossy_owned())?;
}
match v.validate() {
Ok(()) => {
current.hosts.insert(name.as_str().to_owned(), v);
imported_count += 1;
}
Err(ref err) if allow_incomplete => {
tracing::warn!(host = %name, error = %err, "import incomplete allowed");
current.hosts.insert(name.as_str().to_owned(), v);
imported_count += 1;
}
Err(err) => {
let redacted = !v.has_password() && !v.has_key();
if redacted {
return Err(SshCliError::InvalidArgument(format!(
"host '{name}' looks like a redacted export (no password/key). \
Use `vps export --include-secrets`, complete with `vps edit`, \
or `vps import --allow-incomplete`. Detail: {err}"
))
.into());
}
return Err(SshCliError::InvalidArgument(format!(
"host '{name}' invalid in import: {err}"
))
.into());
}
}
}
current.schema_version = model::CURRENT_SCHEMA_VERSION;
save(path, ¤t)?;
crate::output::emit_success(
"vps-import",
serde_json::json!({ "imported": imported_count }),
&crate::i18n::t(crate::i18n::Message::ImportCompleted),
format == OutputFormat::Json,
)?;
Ok(())
}