Skip to main content

ssh_cli/vps/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! VPS record CRUD and persistence (XDG + atomic TOML + flock).
3//!
4//! No `.env` at runtime. Schema v3 English wire (dual-read PT legacy).
5
6pub mod model;
7
8use crate::cli::{SecretsAction, VpsAction, OutputFormat};
9use crate::erros::{SshCliError, SshCliResult};
10use crate::output;
11use crate::ssh::client::{SshClient, SshClientTrait, ConnectionConfig};
12use crate::ssh::known_hosts::KnownHosts;
13use crate::ssh::packing::{append_description, pack_su, pack_sudo};
14use anyhow::Result;
15use model::{effective_limit, parse_char_limit, VpsRecord};
16use secrecy::SecretString;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::io::{Read, Write};
20use std::path::{Path, PathBuf};
21
22/// Full configuration file.
23#[derive(Debug, Default, Serialize, Deserialize)]
24pub struct ConfigFile {
25    /// File schema version.
26    #[serde(default)]
27    pub schema_version: u32,
28    /// Host map keyed by VPS name.
29    #[serde(default)]
30    pub hosts: BTreeMap<String, VpsRecord>,
31}
32
33/// Resolves the config file path from an optional override.
34pub fn resolve_config_path(override_path: Option<PathBuf>) -> SshCliResult<PathBuf> {
35    match override_path {
36        Some(p) => {
37            if p.is_dir() {
38                return Ok(p.join("config.toml"));
39            }
40            if p.extension().and_then(|e| e.to_str()) == Some("toml") {
41                return Ok(p);
42            }
43            Ok(p.join("config.toml"))
44        }
45        None => default_config_path(),
46    }
47}
48
49/// Returns the config file path honoring `SSH_CLI_HOME`.
50pub fn default_config_path() -> SshCliResult<PathBuf> {
51    if let Ok(home) = std::env::var("SSH_CLI_HOME") {
52        if home.contains("..") {
53            return Err(SshCliError::InvalidArgument(
54                "SSH_CLI_HOME must not contain '..'".to_string(),
55            ));
56        }
57        return Ok(PathBuf::from(home).join("config.toml"));
58    }
59
60    let dirs = directories::ProjectDirs::from("", "", "ssh-cli").ok_or_else(|| {
61        SshCliError::Generic("could not resolve config directory".to_string())
62    })?;
63    Ok(dirs.config_dir().join("config.toml"))
64}
65
66/// Winning configuration layer (doctor).
67#[derive(Debug, Clone)]
68pub struct ConfigLayer {
69    /// Layer name.
70    pub name: &'static str,
71    /// Resolved path.
72    pub path: PathBuf,
73}
74
75/// Resolves and describes the winning config layer.
76pub fn winning_layer(override_path: Option<PathBuf>) -> SshCliResult<ConfigLayer> {
77    if override_path.is_some() {
78        return Ok(ConfigLayer {
79            name: "--config-dir",
80            path: resolve_config_path(override_path)?,
81        });
82    }
83    if std::env::var("SSH_CLI_HOME").is_ok() {
84        return Ok(ConfigLayer {
85            name: "SSH_CLI_HOME",
86            path: default_config_path()?,
87        });
88    }
89    Ok(ConfigLayer {
90        name: "XDG ProjectDirs",
91        path: default_config_path()?,
92    })
93}
94
95/// Loads the configuration file (returns empty if missing).
96///
97/// # Errors
98/// Returns an error if the file cannot be read or TOML/secret decryption fails.
99pub fn load(path: &PathBuf) -> SshCliResult<ConfigFile> {
100    if !path.exists() {
101        return Ok(ConfigFile {
102            schema_version: model::CURRENT_SCHEMA_VERSION,
103            hosts: BTreeMap::new(),
104        });
105    }
106    let content = std::fs::read_to_string(path)?;
107    let mut file: ConfigFile = toml::from_str(&content)?;
108    for reg in file.hosts.values_mut() {
109        reg.normalize_schema();
110    }
111    if file.schema_version < model::CURRENT_SCHEMA_VERSION {
112        file.schema_version = model::CURRENT_SCHEMA_VERSION;
113    }
114    Ok(file)
115}
116
117/// Writes bytes to `path` atomically (tempfile + fsync + rename + 0o600).
118///
119/// Used by `save` and `export -o` (atomwrite rule).
120pub fn write_atomic(path: &Path, bytes: &[u8]) -> SshCliResult<()> {
121    if let Some(parent_dir) = path.parent() {
122        std::fs::create_dir_all(parent_dir)?;
123    }
124    let parent_dir = path
125        .parent()
126        .map(Path::to_path_buf)
127        .unwrap_or_else(|| PathBuf::from("."));
128    let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
129    tmp.write_all(bytes)?;
130    tmp.as_file().sync_data()?;
131    tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
132    apply_permissions_600(path)?;
133    #[cfg(unix)]
134    {
135        if let Ok(dir) = std::fs::File::open(&parent_dir) {
136            let _ = dir.sync_all();
137        }
138    }
139    Ok(())
140}
141
142/// Saves the configuration file atomically with flock and 0o600.
143///
144/// # Errors
145/// Returns an error if serialization, atomic write, or permission hardening fails.
146pub fn save(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
147    if let Some(parent_dir) = path.parent() {
148        std::fs::create_dir_all(parent_dir)?;
149    }
150    let text = toml::to_string_pretty(file)
151        .map_err(|e| SshCliError::Generic(format!("failed to serialize TOML: {e}")))?;
152
153    // Sibling lock file to serialize concurrent mutations (N one-shots).
154    let lock_path = path.with_extension("toml.lock");
155    let lock_file = std::fs::OpenOptions::new()
156        .create(true)
157        .truncate(false)
158        .read(true)
159        .write(true)
160        .open(&lock_path)?;
161    // GAP-SSH-PERM-001: lock with 0o600 (not umask 0644).
162    apply_permissions_600(&lock_path)?;
163    fs2::FileExt::lock_exclusive(&lock_file)?;
164
165    write_atomic(path, text.as_bytes())?;
166
167    let _ = fs2::FileExt::unlock(&lock_file);
168    Ok(())
169}
170
171/// Expands leading `~` in a path (user home).
172fn expand_tilde(path: &str) -> PathBuf {
173    let home = std::env::var_os("HOME")
174        .or_else(|| std::env::var_os("USERPROFILE"))
175        .map(PathBuf::from);
176    if let Some(rest) = path.strip_prefix("~/") {
177        if let Some(home) = home {
178            return home.join(rest);
179        }
180    }
181    if path == "~" {
182        if let Some(home) = home {
183            return home;
184        }
185    }
186    PathBuf::from(path)
187}
188
189/// Validates that `key_path` points to an existing local file (VAL-003)
190/// and, with `ssh-real`, that the content is a parseable OpenSSH key (VAL-004).
191///
192/// Encrypted keys without a registration passphrase: if parse indicates a need
193/// for password, the file is accepted (valid format). Format garbage → 64.
194fn validate_key_path_exists(key_path: &str) -> Result<(), SshCliError> {
195    validate_key_path_exists_with_passphrase(key_path, None)
196}
197
198/// Like [`validate_key_path_exists`], with optional passphrase from add/edit.
199fn validate_key_path_exists_with_passphrase(
200    key_path: &str,
201    passphrase: Option<&str>,
202) -> Result<(), SshCliError> {
203    let p = expand_tilde(key_path);
204    if !p.is_file() {
205        return Err(SshCliError::FileNotFound(format!(
206            "private key not found: {}",
207            p.display()
208        )));
209    }
210    #[cfg(feature = "ssh-real")]
211    {
212        match russh::keys::load_secret_key(&p, passphrase) {
213            Ok(_) => Ok(()),
214            Err(e) => {
215                let msg = e.to_string().to_lowercase();
216                // Valid encrypted key without passphrase on the write-path.
217                if msg.contains("password")
218                    || msg.contains("passphrase")
219                    || msg.contains("encrypted")
220                    || msg.contains("decrypt")
221                {
222                    return Ok(());
223                }
224                Err(SshCliError::InvalidArgument(format!(
225                    "invalid OpenSSH private key at {}: {e}",
226                    p.display()
227                )))
228            }
229        }
230    }
231    #[cfg(not(feature = "ssh-real"))]
232    {
233        let _ = passphrase;
234        Ok(())
235    }
236}
237
238/// JSON efetivo a partir de flag local e format global (IO-001/002).
239#[must_use]
240pub fn use_json(json_local: bool, format: OutputFormat) -> bool {
241    json_local || format == OutputFormat::Json
242}
243
244#[cfg(unix)]
245fn apply_permissions_600(path: &Path) -> SshCliResult<()> {
246    use std::os::unix::fs::PermissionsExt;
247    let mut permissions = std::fs::metadata(path)?.permissions();
248    permissions.set_mode(0o600);
249    std::fs::set_permissions(path, permissions)?;
250    Ok(())
251}
252
253#[cfg(not(unix))]
254fn apply_permissions_600(_caminho: &Path) -> SshCliResult<()> {
255    Ok(())
256}
257
258/// Reads a password line from stdin (no extra echo).
259pub fn read_secret_stdin() -> SshCliResult<String> {
260    let mut buf = String::new();
261    std::io::stdin().read_to_string(&mut buf)?;
262    Ok(buf.trim_end_matches(['\r', '\n']).to_string())
263}
264
265/// Aplica overrides de runtime sobre um VpsRecord clonado.
266///
267/// Parameter order: password, sudo, su, timeout, key_path, key_passphrase.
268pub(crate) fn apply_overrides(
269    vps: &mut VpsRecord,
270    password_override: Option<String>,
271    sudo_password_override: Option<String>,
272    su_password_override: Option<String>,
273    timeout_override: Option<u64>,
274    key_path_override: Option<String>,
275    key_passphrase_override: Option<String>,
276) {
277    if let Some(pwd) = password_override {
278        vps.password = SecretString::from(pwd);
279    }
280    if let Some(spwd) = sudo_password_override {
281        vps.sudo_password = Some(SecretString::from(spwd));
282    }
283    if let Some(sp) = su_password_override {
284        vps.su_password = Some(SecretString::from(sp));
285    }
286    if let Some(t) = timeout_override {
287        vps.timeout_ms = t;
288    }
289    if let Some(k) = key_path_override {
290        vps.key_path = Some(k);
291    }
292    if let Some(kp) = key_passphrase_override {
293        vps.key_passphrase = Some(SecretString::from(kp));
294    }
295}
296
297fn validate_command_length(command: &str, max_command_chars: usize) -> SshCliResult<()> {
298    let lim = effective_limit(max_command_chars);
299    let len = command.chars().count();
300    if len > lim {
301        return Err(SshCliError::CommandTooLong {
302            max: max_command_chars,
303            len,
304        });
305    }
306    if command.trim().is_empty() {
307        return Err(SshCliError::InvalidArgument("empty command".to_string()));
308    }
309    Ok(())
310}
311
312/// Dispatcher dos subcomandos `vps`.
313pub async fn run_vps_command(
314    action: VpsAction,
315    config_override: Option<PathBuf>,
316    format: OutputFormat,
317) -> Result<()> {
318    let path = resolve_config_path(config_override.clone())?;
319
320    match action {
321        VpsAction::Add {
322            name,
323            host,
324            port,
325            user,
326            password,
327            password_stdin,
328            key,
329            key_passphrase,
330            timeout,
331            max_command_chars,
332            max_output_chars,
333            max_chars,
334            sudo_password,
335            sudo_password_stdin,
336            su_password,
337            su_password_stdin,
338            disable_sudo,
339            check,
340        } => {
341            // GAP-SSH-VAL-001: validate na fronteira de escrita.
342            let name = crate::paths::validate_and_normalize(&name)
343                .map_err(|e| SshCliError::InvalidArgument(format!("invalid VPS name: {e}")))?;
344            let mut file = load(&path)?;
345            if file.hosts.contains_key(&name) {
346                return Err(SshCliError::VpsDuplicate(name).into());
347            }
348            if password_stdin && (sudo_password_stdin || su_password_stdin) {
349                return Err(SshCliError::InvalidArgument(
350                    "only one --*-stdin per one-shot invocation; use vps edit for sudo/su".into(),
351                )
352                .into());
353            }
354            let password = if password_stdin {
355                SecretString::from(read_secret_stdin()?)
356            } else {
357                SecretString::from(password.unwrap_or_default())
358            };
359            let sudo_s = if sudo_password_stdin {
360                Some(SecretString::from(read_secret_stdin()?))
361            } else {
362                sudo_password.map(SecretString::from)
363            };
364            let su_s = if su_password_stdin {
365                Some(SecretString::from(read_secret_stdin()?))
366            } else {
367                su_password.map(SecretString::from)
368            };
369            if let Some(ref k) = key {
370                validate_key_path_exists(k)?;
371            }
372            // legacy max_chars → command if max_command was not set explicitly
373            let max_cmd = max_command_chars
374                .as_deref()
375                .or(max_chars.as_deref())
376                .map(parse_char_limit)
377                .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
378            let max_out = max_output_chars
379                .as_deref()
380                .map(parse_char_limit)
381                .unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
382            // GAP-AUD-009: timeout is milliseconds; warn agents that use "5" meaning seconds.
383            if timeout > 0 && timeout < 1000 {
384                eprintln!(
385                    "warning: --timeout {timeout} is only {timeout}ms (< 1s); did you mean seconds? Use e.g. --timeout 5000 for 5s"
386                );
387            }
388            let registro = VpsRecord::new(
389                name.clone(),
390                host,
391                port,
392                user,
393                password,
394                key,
395                key_passphrase.map(SecretString::from),
396                Some(timeout),
397                Some(max_cmd),
398                Some(max_out),
399                sudo_s,
400                su_s,
401                disable_sudo,
402            );
403            // GAP-SSH-VAL-002 / VAL-003: full domain validation on the write-path.
404            registro.validate().map_err(SshCliError::InvalidArgument)?;
405            file.hosts.insert(name.clone(), registro);
406            file.schema_version = model::CURRENT_SCHEMA_VERSION;
407            save(&path, &file)?;
408            emit_auto_key_if_needed(format == OutputFormat::Json)?;
409            crate::output::emit_success(
410                "vps-added",
411                serde_json::json!({ "name": name }),
412                &crate::i18n::t(crate::i18n::Message::VpsAdded { name: name.clone() }),
413                format == OutputFormat::Json,
414            )?;
415            if check {
416                run_health_check(
417                    Some(&name),
418                    config_override,
419                    format,
420                    false,
421                    None,
422                    None,
423                    None,
424                    None,
425                    false,
426                )
427                .await?;
428            }
429        }
430        VpsAction::List { json } => {
431            let file = load(&path)?;
432            let records: Vec<_> = file.hosts.values().cloned().collect();
433            // GAP-SSH-IO-001: respeitar format global.
434            if use_json(json, format) {
435                crate::output::print_list_json(&records);
436            } else {
437                crate::output::print_list_text(&records);
438            }
439        }
440        VpsAction::Remove { name } => {
441            let mut file = load(&path)?;
442            if file.hosts.remove(&name).is_none() {
443                return Err(SshCliError::VpsNotFound(name).into());
444            }
445            save(&path, &file)?;
446            // GAP-SSH-STATE-001: clear orphan active marker.
447            clear_active_if_name(&path, &name)?;
448            crate::output::emit_success(
449                "vps-removed",
450                serde_json::json!({ "name": name }),
451                &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
452                format == OutputFormat::Json,
453            )?;
454        }
455        VpsAction::Edit {
456            name,
457            host,
458            port,
459            user,
460            password,
461            password_stdin,
462            key,
463            key_passphrase,
464            timeout,
465            max_command_chars,
466            max_output_chars,
467            max_chars,
468            sudo_password,
469            sudo_password_stdin,
470            su_password,
471            su_password_stdin,
472            disable_sudo,
473        } => {
474            let mut file = load(&path)?;
475            let registro = file
476                .hosts
477                .get_mut(&name)
478                .ok_or_else(|| SshCliError::VpsNotFound(name.clone()))?;
479            if let Some(h) = host {
480                registro.host = h;
481            }
482            if let Some(p) = port {
483                registro.port = p;
484            }
485            if let Some(u) = user {
486                registro.username = u;
487            }
488            if password_stdin {
489                registro.password = SecretString::from(read_secret_stdin()?);
490            } else if let Some(pw) = password {
491                registro.password = SecretString::from(pw);
492            }
493            if let Some(k) = key {
494                validate_key_path_exists(&k)?;
495                registro.key_path = Some(k);
496            }
497            if let Some(kp) = key_passphrase {
498                registro.key_passphrase = Some(SecretString::from(kp));
499            }
500            if let Some(t) = timeout {
501                registro.timeout_ms = t;
502            }
503            if let Some(m) = max_command_chars.or(max_chars) {
504                registro.max_command_chars = parse_char_limit(&m);
505            }
506            if let Some(m) = max_output_chars {
507                registro.max_output_chars = parse_char_limit(&m);
508            }
509            if sudo_password_stdin {
510                registro.sudo_password = Some(SecretString::from(read_secret_stdin()?));
511            } else if let Some(sp) = sudo_password {
512                registro.sudo_password = Some(SecretString::from(sp));
513            }
514            if su_password_stdin {
515                registro.su_password = Some(SecretString::from(read_secret_stdin()?));
516            } else if let Some(sp) = su_password {
517                registro.su_password = Some(SecretString::from(sp));
518            }
519            if let Some(d) = disable_sudo {
520                registro.disable_sudo = d;
521            }
522            registro.validate().map_err(SshCliError::InvalidArgument)?;
523            save(&path, &file)?;
524            crate::output::emit_success(
525                "vps-edited",
526                serde_json::json!({ "name": name }),
527                &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
528                format == OutputFormat::Json,
529            )?;
530        }
531        VpsAction::Show { name, json } => {
532            let file = load(&path)?;
533            let registro = file
534                .hosts
535                .get(&name)
536                .ok_or_else(|| SshCliError::VpsNotFound(name.clone()))?;
537            if use_json(json, format) {
538                crate::output::print_details_json(registro);
539            } else {
540                crate::output::print_details_text(registro);
541            }
542        }
543        VpsAction::Path => {
544            crate::output::write_line(&path.display().to_string())?;
545        }
546        VpsAction::Doctor { json } => {
547            run_doctor(config_override, use_json(json, format))?;
548        }
549        VpsAction::Export {
550            include_secrets,
551            output,
552            json,
553            i_understand_secrets_on_stdout,
554        } => {
555            // GAP-AUD-001/022: export body is TOML unless local `--json` (not non-TTY global).
556            run_export(
557                &path,
558                include_secrets,
559                output.as_deref(),
560                json,
561                i_understand_secrets_on_stdout,
562                format,
563            )?;
564        }
565        VpsAction::Import {
566            file,
567            allow_incomplete,
568        } => {
569            run_import(&path, &file, allow_incomplete, format)?;
570        }
571    }
572    Ok(())
573}
574
575/// Removes the `active` file if its content matches the removed name (STATE-001).
576fn clear_active_if_name(caminho_config: &Path, name: &str) -> Result<()> {
577    let active = caminho_config
578        .parent()
579        .map(|p| p.join("active"))
580        .unwrap_or_else(|| PathBuf::from("active"));
581    if !active.exists() {
582        return Ok(());
583    }
584    let content = std::fs::read_to_string(&active).unwrap_or_default();
585    if content.trim() == name {
586        let _ = std::fs::remove_file(&active);
587    }
588    Ok(())
589}
590
591fn run_doctor(config_override: Option<PathBuf>, json: bool) -> Result<()> {
592    let layer = winning_layer(config_override.clone())?;
593    let path = layer.path.clone();
594    let exists = path.exists();
595    let file = load(&path)?;
596    let kh = KnownHosts::path_beside_config(&path);
597    let active = path
598        .parent()
599        .map(|p| p.join("active"))
600        .unwrap_or_else(|| PathBuf::from("active"));
601    let perms = if exists {
602        #[cfg(unix)]
603        {
604            use std::os::unix::fs::PermissionsExt;
605            format!(
606                "{:o}",
607                std::fs::metadata(&path)?.permissions().mode() & 0o777
608            )
609        }
610        #[cfg(not(unix))]
611        {
612            "n/a".to_string()
613        }
614    } else {
615        "missing".to_string()
616    };
617
618    let seg = crate::secrets::secrets_status()?;
619    if json {
620        let v = serde_json::json!({
621            "layer": layer.name,
622            "config_path": path.display().to_string(),
623            "exists": exists,
624            "permissions": perms,
625            "schema_version": file.schema_version,
626            "hosts": file.hosts.len(),
627            "known_hosts": kh.display().to_string(),
628            "active_file": active.display().to_string(),
629            "secrets_at_rest": if seg.encryption_active { "encrypted" } else { "plaintext" },
630            "secrets_key_source": seg.source.as_str(),
631            "secrets_key_file": seg.key_file_path.display().to_string(),
632            "secrets_plaintext_opt_out": seg.plaintext_opt_out,
633            "telemetry": false,
634        });
635        // GAP-SSH-IO-005: println only in output module.
636        crate::output::print_json_value(&v)?;
637    } else {
638        let config_path_s = path.display().to_string();
639        let kh_s = kh.display().to_string();
640        let active_s = active.display().to_string();
641        let key_file_s = seg.key_file_path.display().to_string();
642        crate::output::print_doctor_text(
643            layer.name,
644            &config_path_s,
645            exists,
646            &perms,
647            file.schema_version,
648            file.hosts.len(),
649            &kh_s,
650            &active_s,
651            if seg.encryption_active {
652                "encrypted"
653            } else {
654                "plaintext"
655            },
656            seg.source.as_str(),
657            &key_file_s,
658            seg.plaintext_opt_out,
659        );
660    }
661    Ok(())
662}
663
664fn run_export(
665    path: &PathBuf,
666    include_secrets: bool,
667    output: Option<&str>,
668    json: bool,
669    i_understand_secrets_on_stdout: bool,
670    format: OutputFormat,
671) -> Result<()> {
672    // GAP-AUD-011: refuse plaintext secrets on non-file stdout without explicit ack.
673    if include_secrets && output.is_none() && !i_understand_secrets_on_stdout {
674        let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
675        if !stdout_is_tty {
676            return Err(SshCliError::InvalidArgument(
677                "refusing --include-secrets to a pipe/non-TTY stdout; \
678                 use `--output <file>` (mode 0o600) or pass `--i-understand-secrets-on-stdout`"
679                    .into(),
680            )
681            .into());
682        }
683    }
684
685    let file = load(path)?;
686    let mut export = ConfigFile {
687        schema_version: model::CURRENT_SCHEMA_VERSION,
688        hosts: BTreeMap::new(),
689    };
690    for (k, mut v) in file.hosts {
691        if !include_secrets {
692            // EXP-001 parity: redacted clears secrets (never sshcli-enc of empty).
693            v.password = SecretString::from(String::new());
694            v.sudo_password = None;
695            v.su_password = None;
696            v.key_passphrase = None;
697        }
698        v.schema_version = model::CURRENT_SCHEMA_VERSION;
699        export.hosts.insert(k, v);
700    }
701
702    // GAP-AUD-001/022: JSON body only when local `--json` is set (not global non-TTY).
703    let bytes = if json {
704        let hosts_json = crate::output::export_hosts_to_json(&export.hosts, include_secrets);
705        let v = serde_json::json!({
706            "ok": true,
707            "event": "vps-export",
708            "schema_version": export.schema_version,
709            "include_secrets": include_secrets,
710            "hosts": hosts_json,
711        });
712        let text = serde_json::to_string_pretty(&v)?;
713        text.into_bytes()
714    } else {
715        let text = toml::to_string_pretty(&export)?;
716        text.into_bytes()
717    };
718
719    if let Some(out_path) = output {
720        write_atomic(Path::new(out_path), &bytes)?;
721        crate::output::emit_success(
722            "vps-export",
723            serde_json::json!({
724                "path": out_path,
725                "include_secrets": include_secrets,
726                "format": if json { "json" } else { "toml" },
727            }),
728            &crate::i18n::t(crate::i18n::Message::ExportCompleted {
729                path: out_path.to_string(),
730            }),
731            format == OutputFormat::Json,
732        )?;
733    } else {
734        // TOML/JSON body to stdout (agent-first: single payload).
735        use std::io::Write;
736        let mut out = std::io::stdout().lock();
737        out.write_all(&bytes)?;
738        if !bytes.ends_with(b"\n") {
739            out.write_all(b"\n")?;
740        }
741    }
742    Ok(())
743}
744
745/// Parses import source: TOML wire or JSON `vps-export` envelope / hosts map.
746fn parse_import_payload(text: &str) -> SshCliResult<ConfigFile> {
747    let trimmed = text.trim_start();
748    if trimmed.starts_with('{') {
749        parse_import_json(trimmed)
750    } else {
751        toml::from_str(text).map_err(SshCliError::TomlDe)
752    }
753}
754
755fn parse_import_json(text: &str) -> SshCliResult<ConfigFile> {
756    let v: serde_json::Value = serde_json::from_str(text).map_err(SshCliError::Json)?;
757    // Envelope: { event, hosts: { name: {…} } } or bare { hosts: … }
758    let hosts_val = v.get("hosts").cloned().ok_or_else(|| {
759        SshCliError::InvalidArgument(
760            "JSON import requires a top-level `hosts` object (vps-export envelope or bare map)"
761                .into(),
762        )
763    })?;
764    let obj = hosts_val.as_object().ok_or_else(|| {
765        SshCliError::InvalidArgument("JSON import `hosts` must be an object map".into())
766    })?;
767    let mut hosts = BTreeMap::new();
768    for (key, entry) in obj {
769        let rec = vps_record_from_export_json(key, entry)?;
770        hosts.insert(key.clone(), rec);
771    }
772    let schema_version = v
773        .get("schema_version")
774        .and_then(|x| x.as_u64())
775        .map(|n| n as u32)
776        .unwrap_or(model::CURRENT_SCHEMA_VERSION);
777    Ok(ConfigFile {
778        schema_version,
779        hosts,
780    })
781}
782
783/// Builds a [`VpsRecord`] from a `vps-export` JSON host entry (`user` alias for username).
784fn vps_record_from_export_json(key: &str, entry: &serde_json::Value) -> SshCliResult<VpsRecord> {
785    let str_field = |names: &[&str]| -> Option<String> {
786        for n in names {
787            if let Some(s) = entry.get(*n).and_then(|x| x.as_str()) {
788                return Some(s.to_string());
789            }
790        }
791        None
792    };
793    let name = str_field(&["name", "nome"]).unwrap_or_else(|| key.to_string());
794    let host = str_field(&["host"]).unwrap_or_default();
795    let port = entry
796        .get("port")
797        .or_else(|| entry.get("porta"))
798        .and_then(|x| x.as_u64())
799        .unwrap_or(22) as u16;
800    let username = str_field(&["username", "user", "usuario"]).unwrap_or_default();
801    let password = SecretString::from(str_field(&["password", "senha"]).unwrap_or_default());
802    let key_path = str_field(&["key_path"]);
803    let key_passphrase = str_field(&["key_passphrase"]).map(SecretString::from);
804    let timeout_ms = entry
805        .get("timeout_ms")
806        .and_then(|x| x.as_u64())
807        .unwrap_or(model::DEFAULT_TIMEOUT_MS);
808    let max_command_chars = entry
809        .get("max_command_chars")
810        .and_then(|x| x.as_u64())
811        .map(|n| n as usize)
812        .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
813    let max_output_chars = entry
814        .get("max_output_chars")
815        .or_else(|| entry.get("max_chars"))
816        .and_then(|x| x.as_u64())
817        .map(|n| n as usize)
818        .unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
819    let sudo_password = str_field(&["sudo_password", "senha_sudo"]).map(SecretString::from);
820    let su_password = str_field(&["su_password", "senha_su"]).map(SecretString::from);
821    let disable_sudo = entry
822        .get("disable_sudo")
823        .and_then(|x| x.as_bool())
824        .unwrap_or(false);
825    let added_at = str_field(&["added_at", "adicionado_em"])
826        .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
827    let schema_version = entry
828        .get("schema_version")
829        .and_then(|x| x.as_u64())
830        .map(|n| n as u32)
831        .unwrap_or(model::CURRENT_SCHEMA_VERSION);
832    Ok(VpsRecord {
833        name,
834        host,
835        port,
836        username,
837        password,
838        key_path,
839        key_passphrase,
840        timeout_ms,
841        max_command_chars,
842        max_output_chars,
843        sudo_password,
844        su_password,
845        disable_sudo,
846        schema_version,
847        added_at,
848    })
849}
850
851fn run_import(
852    path: &PathBuf,
853    file: &Path,
854    allow_incomplete: bool,
855    format: OutputFormat,
856) -> Result<()> {
857    let text = std::fs::read_to_string(file).map_err(SshCliError::Io)?;
858    let imported = parse_import_payload(&text)?;
859    let mut current = load(path)?;
860    let mut imported_count = 0usize;
861    for (k, mut v) in imported.hosts {
862        // VAL-001 on import.
863        let name = crate::paths::validate_and_normalize(&k).map_err(|e| {
864            SshCliError::InvalidArgument(format!("invalid VPS name in import '{k}': {e}"))
865        })?;
866        v.name = name.clone();
867        v.normalize_schema();
868        if let Some(ref key) = v.key_path {
869            if !key.trim().is_empty() {
870                validate_key_path_exists(key)?;
871            }
872        }
873        match v.validate() {
874            Ok(()) => {
875                current.hosts.insert(name, v);
876                imported_count += 1;
877            }
878            Err(msg) if allow_incomplete => {
879                // GAP-SSH-IMP-001: incomplete skeleton allowed.
880                tracing::warn!(host = %name, %msg, "import incomplete allowed");
881                current.hosts.insert(name, v);
882                imported_count += 1;
883            }
884            Err(msg) => {
885                // Detect redacted export.
886                let redacted = !v.has_password() && !v.has_key();
887                if redacted {
888                    return Err(SshCliError::InvalidArgument(format!(
889                        "host '{name}' looks like a redacted export (no password/key). \
890                         Use `vps export --include-secrets`, complete with `vps edit`, \
891                         or `vps import --allow-incomplete`. Detail: {msg}"
892                    ))
893                    .into());
894                }
895                return Err(SshCliError::InvalidArgument(format!(
896                    "host '{name}' invalid in import: {msg}"
897                ))
898                .into());
899            }
900        }
901    }
902    current.schema_version = model::CURRENT_SCHEMA_VERSION;
903    save(path, &current)?;
904    crate::output::emit_success(
905        "vps-import",
906        serde_json::json!({ "imported": imported_count }),
907        &crate::i18n::t(crate::i18n::Message::ImportCompleted),
908        format == OutputFormat::Json,
909    )?;
910    Ok(())
911}
912
913/// Dispatcher one-shot de `secrets status|init|reencrypt`.
914pub async fn run_secrets_command(
915    action: SecretsAction,
916    config_override: Option<PathBuf>,
917    format: OutputFormat,
918) -> Result<()> {
919    // Garante alinhamento do secrets.key com --config-dir.
920    crate::secrets::set_config_dir(config_override.clone());
921    match action {
922        SecretsAction::Status { json } => {
923            let seg = crate::secrets::secrets_status()?;
924            let use_json = json || format == OutputFormat::Json;
925            if use_json {
926                let v = serde_json::json!({
927                    "encryption_active": seg.encryption_active,
928                    "key_source": seg.source.as_str(),
929                    "key_file": seg.key_file_path.display().to_string(),
930                    "plaintext_opt_out": seg.plaintext_opt_out,
931                    "at_rest": if seg.encryption_active { "encrypted" } else { "plaintext" },
932                });
933                crate::output::print_json_value(&v)?;
934            } else {
935                crate::output::print_success(&format!(
936                    "at-rest: {} | source: {} | key_file: {} | plaintext_opt_out: {}",
937                    if seg.encryption_active {
938                        "encrypted"
939                    } else {
940                        "plaintext"
941                    },
942                    seg.source.as_str(),
943                    seg.key_file_path.display(),
944                    seg.plaintext_opt_out
945                ));
946            }
947            Ok(())
948        }
949        SecretsAction::Init {
950            keyring,
951            force,
952            json,
953        } => {
954            // GAP-AUD-SEC-001: rotating the primary key without re-encrypting hosts
955            // permanently loses at-rest secrets. Load/decrypt BEFORE overwriting the key,
956            // then save under the new key after rotation.
957            let path = resolve_config_path(config_override.clone())?;
958            let hosts_to_reencrypt = if force && path.is_file() {
959                Some(load(&path)?)
960            } else {
961                None
962            };
963
964            let seg = crate::secrets::init_primary_key(keyring, force)?;
965            let mut reencrypted_hosts = 0usize;
966
967            if let Some(file) = hosts_to_reencrypt {
968                reencrypted_hosts = file.hosts.len();
969                save(&path, &file).map_err(|e| {
970                    SshCliError::Generic(format!(
971                        "primary key was rotated but re-encrypting config failed: {e}; \
972                         restore secrets.key.bak if present and re-run `secrets reencrypt`"
973                    ))
974                })?;
975            }
976
977            let use_json = json || format == OutputFormat::Json;
978            crate::output::emit_success(
979                "secrets-init",
980                serde_json::json!({
981                    "key_source": seg.source.as_str(),
982                    "key_file": seg.key_file_path.display().to_string(),
983                    "reencrypted_hosts": reencrypted_hosts,
984                    "force": force,
985                }),
986                &crate::i18n::t(crate::i18n::Message::PrimaryKeyReady {
987                    source: seg.source.as_str().to_string(),
988                    key_file: seg.key_file_path.display().to_string(),
989                }),
990                use_json,
991            )?;
992            Ok(())
993        }
994        SecretsAction::Reencrypt { json } => {
995            let path = resolve_config_path(config_override)?;
996            run_reencrypt(&path, json || format == OutputFormat::Json)?;
997            Ok(())
998        }
999    }
1000}
1001
1002/// Reloads and rewrites config, re-encrypting secrets with the current key.
1003fn run_reencrypt(path: &PathBuf, json: bool) -> Result<()> {
1004    let (key, _source) = crate::secrets::ensure_key_for_write()?;
1005    if key.is_none() {
1006        return Err(SshCliError::InvalidArgument(
1007            "no primary-key; run `ssh-cli secrets init` or unset SSH_CLI_ALLOW_PLAINTEXT_SECRETS"
1008                .to_string(),
1009        )
1010        .into());
1011    }
1012    if let Some(mut k) = key {
1013        use zeroize::Zeroize;
1014        k.zeroize();
1015    }
1016    let file = load(path)?;
1017    let hosts = file.hosts.len();
1018    save(path, &file)?;
1019    crate::output::emit_success(
1020        "secrets-reencrypt",
1021        serde_json::json!({ "hosts": hosts }),
1022        &crate::i18n::t(crate::i18n::Message::ReencryptCompleted { hosts }),
1023        json,
1024    )?;
1025    Ok(())
1026}
1027
1028fn emit_auto_key_if_needed(json: bool) -> Result<()> {
1029    if crate::secrets::take_auto_key_created() {
1030        let path = crate::secrets::secrets_key_path().unwrap_or_default();
1031        crate::output::emit_success(
1032            "secrets-key-auto-created",
1033            serde_json::json!({
1034                "key_file": path.display().to_string(),
1035                "key_source": "xdg_file",
1036            }),
1037            &format!("primary-key auto-created at {}", path.display()),
1038            json,
1039        )?;
1040    }
1041    Ok(())
1042}
1043
1044/// Sets the active VPS by writing its name to `<config_dir>/active` (sibling file).
1045pub async fn run_connect(
1046    name: &str,
1047    config_override: Option<PathBuf>,
1048    format: OutputFormat,
1049) -> Result<()> {
1050    let path = resolve_config_path(config_override)?;
1051    let file = load(&path)?;
1052    if !file.hosts.contains_key(name) {
1053        return Err(SshCliError::VpsNotFound(name.to_string()).into());
1054    }
1055
1056    let active_file = path
1057        .parent()
1058        .map(|p| p.join("active"))
1059        .unwrap_or_else(|| PathBuf::from("active"));
1060    if let Some(parent_dir) = active_file.parent() {
1061        std::fs::create_dir_all(parent_dir)?;
1062    }
1063    // atomic write of active marker
1064    let parent_dir = active_file
1065        .parent()
1066        .map(Path::to_path_buf)
1067        .unwrap_or_else(|| PathBuf::from("."));
1068    let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
1069    tmp.write_all(name.as_bytes())?;
1070    tmp.as_file().sync_data()?;
1071    tmp.persist(&active_file)
1072        .map_err(|e| SshCliError::Io(e.error))?;
1073    crate::output::emit_success(
1074        "vps-connected",
1075        serde_json::json!({ "name": name }),
1076        &crate::i18n::t(crate::i18n::Message::VpsActiveSelected {
1077            name: name.to_string(),
1078        }),
1079        format == OutputFormat::Json,
1080    )?;
1081    Ok(())
1082}
1083
1084/// Busca um registro de VPS por name.
1085pub fn find_by_name(
1086    config_override: Option<PathBuf>,
1087    name: &str,
1088) -> SshCliResult<Option<VpsRecord>> {
1089    let path = resolve_config_path(config_override)?;
1090    let file = load(&path)?;
1091    Ok(file.hosts.get(name).cloned())
1092}
1093
1094/// Reads the active VPS name.
1095pub fn read_active_vps(config_override: Option<PathBuf>) -> SshCliResult<Option<String>> {
1096    let path = resolve_config_path(config_override)?;
1097    let active_file = path
1098        .parent()
1099        .map(|p| p.join("active"))
1100        .unwrap_or_else(|| PathBuf::from("active"));
1101    if !active_file.exists() {
1102        return Ok(None);
1103    }
1104    let name = std::fs::read_to_string(&active_file)?;
1105    Ok(Some(name.trim().to_string()))
1106}
1107
1108/// Builds `ConnectionConfig` from a `VpsRecord`.
1109pub fn build_connection_config(
1110    vps: &VpsRecord,
1111    config_toml: Option<&Path>,
1112    replace_host_key: bool,
1113) -> ConnectionConfig {
1114    let known_hosts_path = config_toml.map(KnownHosts::path_beside_config);
1115    ConnectionConfig {
1116        host: vps.host.clone(),
1117        port: vps.port,
1118        username: vps.username.clone(),
1119        password: vps.password.clone(),
1120        key_path: vps.key_path.clone(),
1121        key_passphrase: vps.key_passphrase.clone(),
1122        timeout_ms: vps.timeout_ms,
1123        known_hosts_path,
1124        replace_host_key,
1125    }
1126}
1127
1128/// Common remote execution options.
1129#[derive(Debug, Default, Clone)]
1130pub struct ExecOptions {
1131    /// Override password.
1132    pub password: Option<String>,
1133    /// Override sudo.
1134    pub sudo_password: Option<String>,
1135    /// Override su.
1136    pub su_password: Option<String>,
1137    /// Override timeout.
1138    pub timeout: Option<u64>,
1139    /// Override key path.
1140    pub key: Option<String>,
1141    /// Override key passphrase.
1142    pub key_passphrase: Option<String>,
1143    /// Optional shell description comment.
1144    pub description: Option<String>,
1145    /// replace host key.
1146    pub replace_host_key: bool,
1147    /// disable sudo global.
1148    pub disable_sudo: bool,
1149}
1150
1151/// Runs a shell command on a VPS over SSH.
1152pub async fn run_exec(
1153    vps_name: &str,
1154    command: &str,
1155    config_override: Option<PathBuf>,
1156    format: OutputFormat,
1157    json: bool,
1158    opts: ExecOptions,
1159) -> Result<()> {
1160    if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1161        return Err(anyhow::anyhow!(crate::i18n::t(
1162            crate::i18n::Message::OperationCancelled
1163        )));
1164    }
1165    let path = resolve_config_path(config_override)?;
1166    let file = load(&path)?;
1167    let vps_base = file
1168        .hosts
1169        .get(vps_name)
1170        .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
1171
1172    let mut vps = vps_base.clone();
1173    apply_overrides(
1174        &mut vps,
1175        opts.password,
1176        opts.sudo_password,
1177        opts.su_password,
1178        opts.timeout,
1179        opts.key,
1180        opts.key_passphrase,
1181    );
1182    let cmd = append_description(command, opts.description.as_deref());
1183    validate_command_length(&cmd, vps.max_command_chars)?;
1184    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
1185    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
1186    run_exec_with_client(&vps, &cmd, client, format, json).await
1187}
1188
1189/// Testable version of run_exec.
1190pub async fn run_exec_with_client(
1191    vps: &VpsRecord,
1192    command: &str,
1193    mut client: Box<dyn SshClientTrait>,
1194    format: OutputFormat,
1195    json: bool,
1196) -> Result<()> {
1197    if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1198        return Err(anyhow::anyhow!(crate::i18n::t(
1199            crate::i18n::Message::OperationCancelled
1200        )));
1201    }
1202    let max_out = effective_limit(vps.max_output_chars);
1203    let output = client.run_command(command, max_out, None).await?;
1204    client.disconnect().await?;
1205    if format == OutputFormat::Json || json {
1206        output::print_execution_output_json(&output);
1207    } else {
1208        output::print_execution_output(&output);
1209    }
1210    if let Some(code) = output.exit_code {
1211        if code != 0 {
1212            return Err(SshCliError::CommandFailed {
1213                exit_code: code,
1214                stderr: output.stderr.clone(),
1215            }
1216            .into());
1217        }
1218    }
1219    Ok(())
1220}
1221
1222/// Runs a command with `sudo` (packed via `sh -c`).
1223pub async fn run_sudo_exec(
1224    vps_name: &str,
1225    command: &str,
1226    config_override: Option<PathBuf>,
1227    format: OutputFormat,
1228    json: bool,
1229    opts: ExecOptions,
1230) -> Result<()> {
1231    if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1232        return Err(anyhow::anyhow!(crate::i18n::t(
1233            crate::i18n::Message::OperationCancelled
1234        )));
1235    }
1236    let path = resolve_config_path(config_override)?;
1237    let file = load(&path)?;
1238    let vps_base = file
1239        .hosts
1240        .get(vps_name)
1241        .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
1242
1243    let mut vps = vps_base.clone();
1244    apply_overrides(
1245        &mut vps,
1246        opts.password.clone(),
1247        opts.sudo_password.clone(),
1248        opts.su_password.clone(),
1249        opts.timeout,
1250        opts.key.clone(),
1251        opts.key_passphrase.clone(),
1252    );
1253    if opts.disable_sudo || vps.disable_sudo {
1254        return Err(SshCliError::SudoDisabled.into());
1255    }
1256    let cmd = append_description(command, opts.description.as_deref());
1257    validate_command_length(&cmd, vps.max_command_chars)?;
1258    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
1259    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
1260    run_sudo_exec_with_client(&vps, &cmd, client, format, json).await
1261}
1262
1263/// Testable version of sudo-exec.
1264pub async fn run_sudo_exec_with_client(
1265    vps: &VpsRecord,
1266    command: &str,
1267    mut client: Box<dyn SshClientTrait>,
1268    format: OutputFormat,
1269    json: bool,
1270) -> Result<()> {
1271    if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1272        return Err(anyhow::anyhow!(crate::i18n::t(
1273            crate::i18n::Message::OperationCancelled
1274        )));
1275    }
1276    if vps.disable_sudo {
1277        return Err(SshCliError::SudoDisabled.into());
1278    }
1279    let pack = pack_sudo(command, vps.sudo_password.as_ref());
1280    let max_out = effective_limit(vps.max_output_chars);
1281    let output = client
1282        .run_command(&pack.command, max_out, pack.stdin)
1283        .await?;
1284    client.disconnect().await?;
1285    if format == OutputFormat::Json || json {
1286        output::print_execution_output_json(&output);
1287    } else {
1288        output::print_execution_output(&output);
1289    }
1290    if let Some(code) = output.exit_code {
1291        if code != 0 {
1292            return Err(SshCliError::CommandFailed {
1293                exit_code: code,
1294                stderr: output.stderr.clone(),
1295            }
1296            .into());
1297        }
1298    }
1299    Ok(())
1300}
1301
1302/// Runs a command via `su -` one-shot (consumes `su_password`).
1303pub async fn run_su_exec(
1304    vps_name: &str,
1305    command: &str,
1306    config_override: Option<PathBuf>,
1307    format: OutputFormat,
1308    json: bool,
1309    opts: ExecOptions,
1310) -> Result<()> {
1311    if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1312        return Err(anyhow::anyhow!(crate::i18n::t(
1313            crate::i18n::Message::OperationCancelled
1314        )));
1315    }
1316    let path = resolve_config_path(config_override)?;
1317    let file = load(&path)?;
1318    let vps_base = file
1319        .hosts
1320        .get(vps_name)
1321        .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
1322
1323    let mut vps = vps_base.clone();
1324    apply_overrides(
1325        &mut vps,
1326        opts.password,
1327        opts.sudo_password,
1328        opts.su_password,
1329        opts.timeout,
1330        opts.key,
1331        opts.key_passphrase,
1332    );
1333    if opts.disable_sudo || vps.disable_sudo {
1334        return Err(SshCliError::SudoDisabled.into());
1335    }
1336    let su_password = vps.su_password.clone().ok_or(SshCliError::SuPasswordMissing)?;
1337    let cmd = append_description(command, opts.description.as_deref());
1338    validate_command_length(&cmd, vps.max_command_chars)?;
1339    let pack = pack_su(&cmd, &su_password);
1340    let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
1341    let mut client: Box<dyn SshClientTrait> =
1342        <SshClient as SshClientTrait>::connect(cfg).await?;
1343    let max_out = effective_limit(vps.max_output_chars);
1344    let output = client
1345        .run_command(&pack.command, max_out, pack.stdin)
1346        .await?;
1347    client.disconnect().await?;
1348    if format == OutputFormat::Json || json {
1349        output::print_execution_output_json(&output);
1350    } else {
1351        output::print_execution_output(&output);
1352    }
1353    if let Some(code) = output.exit_code {
1354        if code != 0 {
1355            return Err(SshCliError::CommandFailed {
1356                exit_code: code,
1357                stderr: output.stderr.clone(),
1358            }
1359            .into());
1360        }
1361    }
1362    Ok(())
1363}
1364
1365/// Health-check SSH.
1366/// One-shot health-check with auth parity (GAP-SSH-CLI-006) and TOFU (M1).
1367#[allow(clippy::too_many_arguments)]
1368pub async fn run_health_check(
1369    vps_name: Option<&str>,
1370    config_override: Option<PathBuf>,
1371    format: OutputFormat,
1372    json_local: bool,
1373    password_override: Option<String>,
1374    timeout_override: Option<u64>,
1375    key_override: Option<String>,
1376    key_passphrase_override: Option<String>,
1377    replace_host_key: bool,
1378) -> Result<()> {
1379    // M2: --json local ou format global → envelope de err JSON em falha.
1380    if json_local || format == OutputFormat::Json {
1381        crate::output::set_json_errors(true);
1382    }
1383    if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1384        return Err(anyhow::anyhow!(crate::i18n::t(
1385            crate::i18n::Message::OperationCancelled
1386        )));
1387    }
1388    let resolved_name: String = match vps_name {
1389        Some(n) => n.to_string(),
1390        None => {
1391            // GAP-SSH-EXIT-002: typed → exit 66 (not anyhow string → exit 1).
1392            let ativa = read_active_vps(config_override.clone())?;
1393            ativa.ok_or(SshCliError::NoActiveVps)?
1394        }
1395    };
1396    let path = resolve_config_path(config_override)?;
1397    let file = load(&path)?;
1398    let vps_base = file
1399        .hosts
1400        .get(&resolved_name)
1401        .ok_or_else(|| SshCliError::VpsNotFound(resolved_name.clone()))?;
1402
1403    let mut vps = vps_base.clone();
1404    // GAP-SSH-CLI-004: --timeout; GAP-SSH-CLI-006: key + passphrase.
1405    // Ordem: password, sudo, su, timeout, key_path, key_passphrase.
1406    apply_overrides(
1407        &mut vps,
1408        password_override,
1409        None,
1410        None,
1411        timeout_override,
1412        key_override,
1413        key_passphrase_override,
1414    );
1415    // M1: honra --replace-host-key global (paridade exec/scp/tunnel).
1416    let cfg = build_connection_config(&vps, Some(&path), replace_host_key);
1417    let start = std::time::Instant::now();
1418    let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
1419    let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1420    client.disconnect().await?;
1421
1422    if use_json(json_local, format) {
1423        output::print_health_check_json(&resolved_name, latency_ms);
1424    } else {
1425        output::print_health_check(&resolved_name, latency_ms);
1426    }
1427    Ok(())
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432    use super::*;
1433    use secrecy::ExposeSecret;
1434    use tempfile::TempDir;
1435
1436    fn reg_min() -> VpsRecord {
1437        VpsRecord::new(
1438            "srv".into(),
1439            "host.example.com".into(),
1440            2222,
1441            "admin".into(),
1442            SecretString::from("pass".to_string()),
1443            None,
1444            None,
1445            Some(60_000),
1446            Some(1_000),
1447            Some(50_000),
1448            None,
1449            None,
1450            false,
1451        )
1452    }
1453
1454    #[test]
1455    fn empty_file_serializes_with_schema() {
1456        let arq = ConfigFile {
1457            schema_version: model::CURRENT_SCHEMA_VERSION,
1458            hosts: BTreeMap::new(),
1459        };
1460        let text = toml::to_string(&arq).unwrap();
1461        assert!(text.contains("schema_version = 3"));
1462    }
1463
1464    #[test]
1465    fn parse_limit_none() {
1466        assert_eq!(parse_char_limit("none"), 0);
1467        assert_eq!(parse_char_limit("0"), 0);
1468        assert_eq!(parse_char_limit("1000"), 1000);
1469    }
1470
1471    #[test]
1472    fn build_config_copies_fields() {
1473        let registro = reg_min();
1474        let cfg = build_connection_config(&registro, None, false);
1475        assert_eq!(cfg.host, "host.example.com");
1476        assert_eq!(cfg.port, 2222);
1477        assert_eq!(cfg.username, "admin");
1478        assert_eq!(cfg.timeout_ms, 60_000);
1479        assert!(cfg.known_hosts_path.is_none());
1480    }
1481
1482    #[test]
1483    #[serial_test::serial]
1484    fn atomic_save_roundtrip() {
1485        let tmp = TempDir::new().unwrap();
1486        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
1487        // SAFETY:
1488
1489        // 1. Contract: temporary mutation of process environment for a serial test/setup path.
1490
1491        // 2. Invariant: no concurrent threads in this process mutate the same env keys.
1492
1493        // 3. Caller guarantees serial_test::serial (or single-threaded test) around this block.
1494
1495        // 4. See std::env::set_var / remove_var safety notes for multi-threaded processes.
1496
1497        unsafe {
1498            std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
1499        }
1500        let path = tmp.path().join("config.toml");
1501        let mut arq = ConfigFile {
1502            schema_version: 2,
1503            hosts: BTreeMap::new(),
1504        };
1505        arq.hosts.insert("a".into(), reg_min());
1506        save(&path, &arq).unwrap();
1507        let loaded = load(&path).unwrap();
1508        assert_eq!(loaded.hosts.len(), 1);
1509        assert_eq!(loaded.hosts["a"].password.expose_secret(), "pass");
1510        #[cfg(unix)]
1511        {
1512            use std::os::unix::fs::PermissionsExt;
1513            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1514            assert_eq!(mode, 0o600);
1515            let lock = path.with_extension("toml.lock");
1516            if lock.exists() {
1517                let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
1518                assert_eq!(lm, 0o600);
1519            }
1520        }
1521        // SAFETY:
1522
1523        // 1. Contract: temporary mutation of process environment for a serial test/setup path.
1524
1525        // 2. Invariant: no concurrent threads in this process mutate the same env keys.
1526
1527        // 3. Caller guarantees serial_test::serial (or single-threaded test) around this block.
1528
1529        // 4. See std::env::set_var / remove_var safety notes for multi-threaded processes.
1530
1531        unsafe {
1532            std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
1533        }
1534        crate::secrets::set_config_dir(None);
1535    }
1536
1537    #[test]
1538    fn resolve_config_path_with_dir_override() {
1539        let result = resolve_config_path(Some(PathBuf::from("/tmp/test-dir")));
1540        assert_eq!(
1541            result.unwrap(),
1542            PathBuf::from("/tmp/test-dir/config.toml")
1543        );
1544    }
1545
1546    #[test]
1547    fn validate_long_command() {
1548        let err = validate_command_length(&"x".repeat(20), 10).unwrap_err();
1549        assert!(matches!(err, SshCliError::CommandTooLong { .. }));
1550    }
1551
1552    #[test]
1553    fn pack_sudo_integrated() {
1554        let pack = pack_sudo("ls -la", None);
1555        assert_eq!(pack.command, "sudo -n sh -c 'ls -la'");
1556        assert!(pack.stdin.is_none());
1557    }
1558
1559    #[test]
1560    fn apply_overrides_password() {
1561        let mut v = reg_min();
1562        apply_overrides(
1563            &mut v,
1564            Some("nova".into()),
1565            Some("sudo".into()),
1566            None,
1567            Some(1000),
1568            Some("/k".into()),
1569            None,
1570        );
1571        assert_eq!(v.password.expose_secret(), "nova");
1572        assert_eq!(v.timeout_ms, 1000);
1573        assert_eq!(v.key_path.as_deref(), Some("/k"));
1574    }
1575
1576    #[tokio::test]
1577    async fn sudo_exec_with_client_ok() {
1578        use crate::ssh::client::mocks::MockSshClient;
1579        use crate::ssh::client::ExecutionOutput;
1580        let mut mock = MockSshClient::new();
1581        mock.expect_run_command().returning(|c, _, stdin| {
1582            assert!(c.contains("sudo -n sh -c"));
1583            assert!(stdin.is_none());
1584            Ok(ExecutionOutput {
1585                stdout: "ok".into(),
1586                stderr: String::new(),
1587                exit_code: Some(0),
1588                truncated_stdout: false,
1589                truncated_stderr: false,
1590                duration_ms: 1,
1591            })
1592        });
1593        mock.expect_disconnect().returning(|| Ok(()));
1594
1595        let vps = reg_min();
1596        run_sudo_exec_with_client(&vps, "id", Box::new(mock), OutputFormat::Text, false)
1597            .await
1598            .unwrap();
1599    }
1600}