Skip to main content

ssh_cli/vps/
mod.rs

1//! CRUD e persistência de registros de VPS (XDG + TOML atômico + flock).
2//!
3//! ZERO arquivo `.env` em runtime. Schema v2 com auth senha/chave.
4
5pub mod modelo;
6
7use crate::cli::{AcaoSecrets, AcaoVps, FormatoSaida};
8use crate::erros::{ErroSshCli, ResultadoSshCli};
9use crate::output;
10use crate::ssh::cliente::{ClienteSsh, ClienteSshTrait, ConfiguracaoConexao};
11use crate::ssh::known_hosts::KnownHosts;
12use crate::ssh::packing::{anexar_description, empacotar_su, empacotar_sudo};
13use anyhow::Result;
14use modelo::{limite_efetivo, parse_limite_chars, VpsRegistro};
15use secrecy::SecretString;
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18use std::io::{Read, Write};
19use std::path::{Path, PathBuf};
20
21/// Arquivo de configuração completo.
22#[derive(Debug, Default, Serialize, Deserialize)]
23pub struct ArquivoConfig {
24    /// Versão do schema do arquivo.
25    #[serde(default)]
26    pub schema_version: u32,
27    /// Mapa de VPSs por nome.
28    #[serde(default)]
29    pub hosts: BTreeMap<String, VpsRegistro>,
30}
31
32/// Resolve o caminho do arquivo de config a partir de um override opcional.
33pub fn resolver_caminho_config(override_path: Option<PathBuf>) -> ResultadoSshCli<PathBuf> {
34    match override_path {
35        Some(p) => {
36            if p.is_dir() {
37                return Ok(p.join("config.toml"));
38            }
39            if p.extension().and_then(|e| e.to_str()) == Some("toml") {
40                return Ok(p);
41            }
42            Ok(p.join("config.toml"))
43        }
44        None => caminho_config_padrao(),
45    }
46}
47
48/// Retorna o caminho do arquivo de config respeitando `SSH_CLI_HOME`.
49pub fn caminho_config_padrao() -> ResultadoSshCli<PathBuf> {
50    if let Ok(home) = std::env::var("SSH_CLI_HOME") {
51        if home.contains("..") {
52            return Err(ErroSshCli::ArgumentoInvalido(
53                "SSH_CLI_HOME não pode conter '..'".to_string(),
54            ));
55        }
56        return Ok(PathBuf::from(home).join("config.toml"));
57    }
58
59    let dirs = directories::ProjectDirs::from("", "", "ssh-cli").ok_or_else(|| {
60        ErroSshCli::Generico("não foi possível resolver diretório de config".to_string())
61    })?;
62    Ok(dirs.config_dir().join("config.toml"))
63}
64
65/// Camada vencedora de configuração (doctor).
66#[derive(Debug, Clone)]
67pub struct CamadaConfig {
68    /// Nome da camada.
69    pub nome: &'static str,
70    /// Path resolvido.
71    pub path: PathBuf,
72}
73
74/// Resolve e descreve a camada de config vencedora.
75pub fn camada_vencedora(override_path: Option<PathBuf>) -> ResultadoSshCli<CamadaConfig> {
76    if override_path.is_some() {
77        return Ok(CamadaConfig {
78            nome: "--config-dir",
79            path: resolver_caminho_config(override_path)?,
80        });
81    }
82    if std::env::var("SSH_CLI_HOME").is_ok() {
83        return Ok(CamadaConfig {
84            nome: "SSH_CLI_HOME",
85            path: caminho_config_padrao()?,
86        });
87    }
88    Ok(CamadaConfig {
89        nome: "XDG ProjectDirs",
90        path: caminho_config_padrao()?,
91    })
92}
93
94/// Carrega o arquivo de configuração (retorna vazio se não existir).
95pub fn carregar(caminho: &PathBuf) -> ResultadoSshCli<ArquivoConfig> {
96    if !caminho.exists() {
97        return Ok(ArquivoConfig {
98            schema_version: modelo::SCHEMA_VERSION_ATUAL,
99            hosts: BTreeMap::new(),
100        });
101    }
102    let conteudo = std::fs::read_to_string(caminho)?;
103    let mut arquivo: ArquivoConfig = toml::from_str(&conteudo)?;
104    for reg in arquivo.hosts.values_mut() {
105        reg.normalizar_schema();
106    }
107    if arquivo.schema_version < modelo::SCHEMA_VERSION_ATUAL {
108        arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
109    }
110    Ok(arquivo)
111}
112
113/// Escreve bytes em `caminho` de forma atômica (tempfile + fsync + rename + 0o600).
114///
115/// Usado por `salvar` e `export` (GAP-007 residual no export).
116pub fn escrever_atomico(caminho: &Path, bytes: &[u8]) -> ResultadoSshCli<()> {
117    if let Some(pai) = caminho.parent() {
118        std::fs::create_dir_all(pai)?;
119    }
120    let pai = caminho
121        .parent()
122        .map(Path::to_path_buf)
123        .unwrap_or_else(|| PathBuf::from("."));
124    let mut tmp = tempfile::NamedTempFile::new_in(&pai)?;
125    tmp.write_all(bytes)?;
126    tmp.as_file().sync_data()?;
127    tmp.persist(caminho).map_err(|e| ErroSshCli::Io(e.error))?;
128    aplicar_permissoes_600(caminho)?;
129    #[cfg(unix)]
130    {
131        if let Ok(dir) = std::fs::File::open(&pai) {
132            let _ = dir.sync_all();
133        }
134    }
135    Ok(())
136}
137
138/// Salva o arquivo de configuração de forma atômica com flock e 0o600.
139pub fn salvar(caminho: &Path, arquivo: &ArquivoConfig) -> ResultadoSshCli<()> {
140    if let Some(pai) = caminho.parent() {
141        std::fs::create_dir_all(pai)?;
142    }
143    let texto = toml::to_string_pretty(arquivo)
144        .map_err(|e| ErroSshCli::Generico(format!("falha serializando TOML: {e}")))?;
145
146    // Lock em arquivo irmão para serializar mutações concorrentes (N one-shots).
147    let lock_path = caminho.with_extension("toml.lock");
148    let lock_file = std::fs::OpenOptions::new()
149        .create(true)
150        .truncate(false)
151        .read(true)
152        .write(true)
153        .open(&lock_path)?;
154    // GAP-SSH-PERM-001: lock com 0o600 (não 0644 do umask).
155    aplicar_permissoes_600(&lock_path)?;
156    fs2::FileExt::lock_exclusive(&lock_file)?;
157
158    escrever_atomico(caminho, texto.as_bytes())?;
159
160    let _ = fs2::FileExt::unlock(&lock_file);
161    Ok(())
162}
163
164/// Expande `~` no início do path (home do usuário).
165fn expandir_tilde(path: &str) -> PathBuf {
166    let home = std::env::var_os("HOME")
167        .or_else(|| std::env::var_os("USERPROFILE"))
168        .map(PathBuf::from);
169    if let Some(rest) = path.strip_prefix("~/") {
170        if let Some(home) = home {
171            return home.join(rest);
172        }
173    }
174    if path == "~" {
175        if let Some(home) = home {
176            return home;
177        }
178    }
179    PathBuf::from(path)
180}
181
182/// Valida que `key_path` aponta para um arquivo local existente (VAL-003)
183/// e, com `ssh-real`, que o conteúdo é uma chave OpenSSH parseável (VAL-004).
184///
185/// Chaves cifradas sem passphrase no cadastro: se o parse indicar necessidade
186/// de senha, o arquivo é aceito (formato válido). Lixo de formato → 64.
187fn validar_key_path_existe(key_path: &str) -> Result<(), ErroSshCli> {
188    validar_key_path_existe_com_passphrase(key_path, None)
189}
190
191/// Como [`validar_key_path_existe`], com passphrase opcional do add/edit.
192fn validar_key_path_existe_com_passphrase(
193    key_path: &str,
194    passphrase: Option<&str>,
195) -> Result<(), ErroSshCli> {
196    let p = expandir_tilde(key_path);
197    if !p.is_file() {
198        return Err(ErroSshCli::ArquivoNaoEncontrado(format!(
199            "chave privada não encontrada: {}",
200            p.display()
201        )));
202    }
203    #[cfg(feature = "ssh-real")]
204    {
205        match russh::keys::load_secret_key(&p, passphrase) {
206            Ok(_) => Ok(()),
207            Err(e) => {
208                let msg = e.to_string().to_lowercase();
209                // Key cifrada válida sem passphrase no write-path.
210                if msg.contains("password")
211                    || msg.contains("passphrase")
212                    || msg.contains("encrypted")
213                    || msg.contains("decrypt")
214                {
215                    return Ok(());
216                }
217                Err(ErroSshCli::ArgumentoInvalido(format!(
218                    "chave privada OpenSSH inválida em {}: {e}",
219                    p.display()
220                )))
221            }
222        }
223    }
224    #[cfg(not(feature = "ssh-real"))]
225    {
226        let _ = passphrase;
227        Ok(())
228    }
229}
230
231/// JSON efetivo a partir de flag local e formato global (IO-001/002).
232#[must_use]
233pub fn usar_json(json_local: bool, formato: FormatoSaida) -> bool {
234    json_local || formato == FormatoSaida::Json
235}
236
237#[cfg(unix)]
238fn aplicar_permissoes_600(caminho: &Path) -> ResultadoSshCli<()> {
239    use std::os::unix::fs::PermissionsExt;
240    let mut permissoes = std::fs::metadata(caminho)?.permissions();
241    permissoes.set_mode(0o600);
242    std::fs::set_permissions(caminho, permissoes)?;
243    Ok(())
244}
245
246#[cfg(not(unix))]
247fn aplicar_permissoes_600(_caminho: &Path) -> ResultadoSshCli<()> {
248    Ok(())
249}
250
251/// Lê uma linha de senha de stdin (sem eco extra).
252pub fn ler_segredo_stdin() -> ResultadoSshCli<String> {
253    let mut buf = String::new();
254    std::io::stdin().read_to_string(&mut buf)?;
255    Ok(buf.trim_end_matches(['\r', '\n']).to_string())
256}
257
258/// Aplica overrides de runtime sobre um VpsRegistro clonado.
259fn aplicar_overrides(
260    vps: &mut VpsRegistro,
261    password_override: Option<String>,
262    sudo_password_override: Option<String>,
263    su_password_override: Option<String>,
264    timeout_override: Option<u64>,
265    key_path_override: Option<String>,
266    key_passphrase_override: Option<String>,
267) {
268    if let Some(pwd) = password_override {
269        vps.senha = SecretString::from(pwd);
270    }
271    if let Some(spwd) = sudo_password_override {
272        vps.senha_sudo = Some(SecretString::from(spwd));
273    }
274    if let Some(sp) = su_password_override {
275        vps.senha_su = Some(SecretString::from(sp));
276    }
277    if let Some(t) = timeout_override {
278        vps.timeout_ms = t;
279    }
280    if let Some(k) = key_path_override {
281        vps.key_path = Some(k);
282    }
283    if let Some(kp) = key_passphrase_override {
284        vps.key_passphrase = Some(SecretString::from(kp));
285    }
286}
287
288fn validar_comando_tamanho(comando: &str, max_command_chars: usize) -> ResultadoSshCli<()> {
289    let lim = limite_efetivo(max_command_chars);
290    let len = comando.chars().count();
291    if len > lim {
292        return Err(ErroSshCli::ComandoMuitoLongo {
293            max: max_command_chars,
294            len,
295        });
296    }
297    if comando.trim().is_empty() {
298        return Err(ErroSshCli::ArgumentoInvalido("comando vazio".to_string()));
299    }
300    Ok(())
301}
302
303/// Dispatcher dos subcomandos `vps`.
304pub async fn executar_comando_vps(
305    acao: AcaoVps,
306    config_override: Option<PathBuf>,
307    formato: FormatoSaida,
308) -> Result<()> {
309    let caminho = resolver_caminho_config(config_override.clone())?;
310
311    match acao {
312        AcaoVps::Add {
313            name,
314            host,
315            port,
316            user,
317            password,
318            password_stdin,
319            key,
320            key_passphrase,
321            timeout,
322            max_command_chars,
323            max_output_chars,
324            max_chars,
325            sudo_password,
326            sudo_password_stdin,
327            su_password,
328            su_password_stdin,
329            disable_sudo,
330            check,
331        } => {
332            // GAP-SSH-VAL-001: validar na fronteira de escrita.
333            let name = crate::paths::validar_e_normalizar(&name).map_err(|e| {
334                ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido: {e}"))
335            })?;
336            let mut arquivo = carregar(&caminho)?;
337            if arquivo.hosts.contains_key(&name) {
338                return Err(ErroSshCli::VpsDuplicada(name).into());
339            }
340            if password_stdin && (sudo_password_stdin || su_password_stdin) {
341                return Err(ErroSshCli::ArgumentoInvalido(
342                    "apenas um --*-stdin por invocação one-shot; rode vps edit para sudo/su".into(),
343                )
344                .into());
345            }
346            let senha = if password_stdin {
347                SecretString::from(ler_segredo_stdin()?)
348            } else {
349                SecretString::from(password.unwrap_or_default())
350            };
351            let sudo_s = if sudo_password_stdin {
352                Some(SecretString::from(ler_segredo_stdin()?))
353            } else {
354                sudo_password.map(SecretString::from)
355            };
356            let su_s = if su_password_stdin {
357                Some(SecretString::from(ler_segredo_stdin()?))
358            } else {
359                su_password.map(SecretString::from)
360            };
361            if let Some(ref k) = key {
362                validar_key_path_existe(k)?;
363            }
364            // max_chars legado → command se max_command não veio explicitamente
365            let max_cmd = max_command_chars
366                .as_deref()
367                .or(max_chars.as_deref())
368                .map(parse_limite_chars)
369                .unwrap_or(modelo::MAX_COMMAND_CHARS_PADRAO);
370            let max_out = max_output_chars
371                .as_deref()
372                .map(parse_limite_chars)
373                .unwrap_or(modelo::MAX_OUTPUT_CHARS_PADRAO);
374            let registro = VpsRegistro::novo(
375                name.clone(),
376                host,
377                port,
378                user,
379                senha,
380                key,
381                key_passphrase.map(SecretString::from),
382                Some(timeout),
383                Some(max_cmd),
384                Some(max_out),
385                sudo_s,
386                su_s,
387                disable_sudo,
388            );
389            // GAP-SSH-VAL-002 / VAL-003: domínio completo no write-path.
390            registro
391                .validar()
392                .map_err(ErroSshCli::ArgumentoInvalido)?;
393            arquivo.hosts.insert(name.clone(), registro);
394            arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
395            salvar(&caminho, &arquivo)?;
396            crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
397            if check {
398                executar_health_check(
399                    Some(&name),
400                    config_override,
401                    formato,
402                    false,
403                    None,
404                    None,
405                )
406                .await?;
407            }
408        }
409        AcaoVps::List { json } => {
410            let arquivo = carregar(&caminho)?;
411            let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
412            // GAP-SSH-IO-001: respeitar formato global.
413            if usar_json(json, formato) {
414                crate::output::imprimir_lista_json(&registros);
415            } else {
416                crate::output::imprimir_lista_texto(&registros);
417            }
418        }
419        AcaoVps::Remove { nome } => {
420            let mut arquivo = carregar(&caminho)?;
421            if arquivo.hosts.remove(&nome).is_none() {
422                return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
423            }
424            salvar(&caminho, &arquivo)?;
425            // GAP-SSH-STATE-001: limpar active órfão.
426            limpar_active_se_nome(&caminho, &nome)?;
427            crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
428        }
429        AcaoVps::Edit {
430            nome,
431            host,
432            port,
433            user,
434            password,
435            password_stdin,
436            key,
437            key_passphrase,
438            timeout,
439            max_command_chars,
440            max_output_chars,
441            max_chars,
442            sudo_password,
443            sudo_password_stdin,
444            su_password,
445            su_password_stdin,
446            disable_sudo,
447        } => {
448            let mut arquivo = carregar(&caminho)?;
449            let registro = arquivo
450                .hosts
451                .get_mut(&nome)
452                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
453            if let Some(h) = host {
454                registro.host = h;
455            }
456            if let Some(p) = port {
457                registro.porta = p;
458            }
459            if let Some(u) = user {
460                registro.usuario = u;
461            }
462            if password_stdin {
463                registro.senha = SecretString::from(ler_segredo_stdin()?);
464            } else if let Some(pw) = password {
465                registro.senha = SecretString::from(pw);
466            }
467            if let Some(k) = key {
468                validar_key_path_existe(&k)?;
469                registro.key_path = Some(k);
470            }
471            if let Some(kp) = key_passphrase {
472                registro.key_passphrase = Some(SecretString::from(kp));
473            }
474            if let Some(t) = timeout {
475                registro.timeout_ms = t;
476            }
477            if let Some(m) = max_command_chars.or(max_chars) {
478                registro.max_command_chars = parse_limite_chars(&m);
479            }
480            if let Some(m) = max_output_chars {
481                registro.max_output_chars = parse_limite_chars(&m);
482            }
483            if sudo_password_stdin {
484                registro.senha_sudo = Some(SecretString::from(ler_segredo_stdin()?));
485            } else if let Some(sp) = sudo_password {
486                registro.senha_sudo = Some(SecretString::from(sp));
487            }
488            if su_password_stdin {
489                registro.senha_su = Some(SecretString::from(ler_segredo_stdin()?));
490            } else if let Some(sp) = su_password {
491                registro.senha_su = Some(SecretString::from(sp));
492            }
493            if let Some(d) = disable_sudo {
494                registro.disable_sudo = d;
495            }
496            registro
497                .validar()
498                .map_err(ErroSshCli::ArgumentoInvalido)?;
499            salvar(&caminho, &arquivo)?;
500            crate::output::imprimir_sucesso(&format!("VPS '{nome}' editada"));
501        }
502        AcaoVps::Show { nome, json } => {
503            let arquivo = carregar(&caminho)?;
504            let registro = arquivo
505                .hosts
506                .get(&nome)
507                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
508            if usar_json(json, formato) {
509                crate::output::imprimir_detalhes_json(registro);
510            } else {
511                crate::output::imprimir_detalhes_texto(registro);
512            }
513        }
514        AcaoVps::Path => {
515            crate::output::escrever_linha(&caminho.display().to_string())?;
516        }
517        AcaoVps::Doctor { json } => {
518            executar_doctor(config_override, usar_json(json, formato))?;
519        }
520        AcaoVps::Export {
521            include_secrets,
522            output,
523        } => {
524            executar_export(&caminho, include_secrets, output.as_deref())?;
525        }
526        AcaoVps::Import {
527            file,
528            allow_incomplete,
529        } => {
530            executar_import(&caminho, &file, allow_incomplete)?;
531        }
532    }
533    Ok(())
534}
535
536/// Remove o arquivo `active` se o conteúdo for o nome removido (STATE-001).
537fn limpar_active_se_nome(caminho_config: &Path, nome: &str) -> Result<()> {
538    let active = caminho_config
539        .parent()
540        .map(|p| p.join("active"))
541        .unwrap_or_else(|| PathBuf::from("active"));
542    if !active.exists() {
543        return Ok(());
544    }
545    let conteudo = std::fs::read_to_string(&active).unwrap_or_default();
546    if conteudo.trim() == nome {
547        let _ = std::fs::remove_file(&active);
548    }
549    Ok(())
550}
551
552fn executar_doctor(config_override: Option<PathBuf>, json: bool) -> Result<()> {
553    let camada = camada_vencedora(config_override.clone())?;
554    let caminho = camada.path.clone();
555    let existe = caminho.exists();
556    let arquivo = carregar(&caminho)?;
557    let kh = KnownHosts::caminho_ao_lado_config(&caminho);
558    let active = caminho
559        .parent()
560        .map(|p| p.join("active"))
561        .unwrap_or_else(|| PathBuf::from("active"));
562    let perms = if existe {
563        #[cfg(unix)]
564        {
565            use std::os::unix::fs::PermissionsExt;
566            format!(
567                "{:o}",
568                std::fs::metadata(&caminho)?.permissions().mode() & 0o777
569            )
570        }
571        #[cfg(not(unix))]
572        {
573            "n/a".to_string()
574        }
575    } else {
576        "ausente".to_string()
577    };
578
579    let seg = crate::secrets::status_segredos()?;
580    if json {
581        let v = serde_json::json!({
582            "layer": camada.nome,
583            "config_path": caminho.display().to_string(),
584            "exists": existe,
585            "permissions": perms,
586            "schema_version": arquivo.schema_version,
587            "hosts": arquivo.hosts.len(),
588            "known_hosts": kh.display().to_string(),
589            "active_file": active.display().to_string(),
590            "secrets_at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
591            "secrets_key_source": seg.fonte.as_str(),
592            "secrets_key_file": seg.key_file_path.display().to_string(),
593            "secrets_plaintext_opt_out": seg.plaintext_opt_out,
594            "telemetry": false,
595        });
596        // GAP-SSH-IO-005: println só em output.
597        crate::output::imprimir_json_value(&v)?;
598    } else {
599        let config_path_s = caminho.display().to_string();
600        let kh_s = kh.display().to_string();
601        let active_s = active.display().to_string();
602        let key_file_s = seg.key_file_path.display().to_string();
603        crate::output::imprimir_doctor_texto(
604            camada.nome,
605            &config_path_s,
606            existe,
607            &perms,
608            arquivo.schema_version,
609            arquivo.hosts.len(),
610            &kh_s,
611            &active_s,
612            if seg.cifragem_ativa {
613                "encrypted"
614            } else {
615                "plaintext"
616            },
617            seg.fonte.as_str(),
618            &key_file_s,
619            seg.plaintext_opt_out,
620        );
621    }
622    Ok(())
623}
624
625fn executar_export(caminho: &PathBuf, include_secrets: bool, output: Option<&str>) -> Result<()> {
626    let arquivo = carregar(caminho)?;
627    let mut export = ArquivoConfig {
628        schema_version: arquivo.schema_version,
629        hosts: BTreeMap::new(),
630    };
631    for (k, mut v) in arquivo.hosts {
632        if !include_secrets {
633            v.senha = SecretString::from(String::new());
634            v.senha_sudo = None;
635            v.senha_su = None;
636            v.key_passphrase = None;
637        }
638        export.hosts.insert(k, v);
639    }
640    let texto = toml::to_string_pretty(&export)?;
641    if let Some(path) = output {
642        escrever_atomico(Path::new(path), texto.as_bytes())?;
643        crate::output::imprimir_sucesso(&format!("exportado para {path}"));
644    } else {
645        print!("{texto}");
646    }
647    Ok(())
648}
649
650fn executar_import(caminho: &PathBuf, file: &Path, allow_incomplete: bool) -> Result<()> {
651    let texto = std::fs::read_to_string(file)?;
652    let importado: ArquivoConfig = toml::from_str(&texto)?;
653    let mut atual = carregar(caminho)?;
654    for (k, mut v) in importado.hosts {
655        // VAL-001 no import.
656        let nome = crate::paths::validar_e_normalizar(&k).map_err(|e| {
657            ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido no import '{k}': {e}"))
658        })?;
659        v.nome = nome.clone();
660        if let Some(ref key) = v.key_path {
661            if !key.trim().is_empty() {
662                validar_key_path_existe(key)?;
663            }
664        }
665        match v.validar() {
666            Ok(()) => {
667                atual.hosts.insert(nome, v);
668            }
669            Err(msg) if allow_incomplete => {
670                // GAP-SSH-IMP-001: esqueleto incompleto permitido.
671                tracing::warn!(host = %nome, %msg, "import incomplete permitido");
672                atual.hosts.insert(nome, v);
673            }
674            Err(msg) => {
675                // Detectar export redacted.
676                let redacted = !v.tem_senha() && !v.tem_chave();
677                if redacted {
678                    return Err(ErroSshCli::ArgumentoInvalido(format!(
679                        "host '{nome}' parece export redacted (sem password/key). \
680                         Use `vps export --include-secrets`, complete com `vps edit`, \
681                         ou `vps import --allow-incomplete`. Detalhe: {msg}"
682                    ))
683                    .into());
684                }
685                return Err(ErroSshCli::ArgumentoInvalido(format!(
686                    "host '{nome}' inválido no import: {msg}"
687                ))
688                .into());
689            }
690        }
691    }
692    atual.schema_version = modelo::SCHEMA_VERSION_ATUAL;
693    salvar(caminho, &atual)?;
694    crate::output::imprimir_sucesso("importação concluída");
695    Ok(())
696}
697
698/// Dispatcher one-shot de `secrets status|init|reencrypt`.
699pub async fn executar_comando_secrets(
700    acao: AcaoSecrets,
701    config_override: Option<PathBuf>,
702    formato: FormatoSaida,
703) -> Result<()> {
704    // Garante alinhamento do secrets.key com --config-dir.
705    crate::secrets::definir_diretorio_config(config_override.clone());
706    match acao {
707        AcaoSecrets::Status { json } => {
708            let seg = crate::secrets::status_segredos()?;
709            let usar_json = json || formato == FormatoSaida::Json;
710            if usar_json {
711                let v = serde_json::json!({
712                    "encryption_active": seg.cifragem_ativa,
713                    "key_source": seg.fonte.as_str(),
714                    "key_file": seg.key_file_path.display().to_string(),
715                    "plaintext_opt_out": seg.plaintext_opt_out,
716                    "at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
717                });
718                crate::output::imprimir_json_value(&v)?;
719            } else {
720                crate::output::imprimir_sucesso(&format!(
721                    "at-rest: {} | source: {} | key_file: {} | plaintext_opt_out: {}",
722                    if seg.cifragem_ativa {
723                        "encrypted"
724                    } else {
725                        "plaintext"
726                    },
727                    seg.fonte.as_str(),
728                    seg.key_file_path.display(),
729                    seg.plaintext_opt_out
730                ));
731            }
732            Ok(())
733        }
734        AcaoSecrets::Init { keyring, force } => {
735            let seg = crate::secrets::init_master_key(keyring, force)?;
736            crate::output::imprimir_sucesso(&format!(
737                "master-key pronta (source={}; key_file={})",
738                seg.fonte.as_str(),
739                seg.key_file_path.display()
740            ));
741            Ok(())
742        }
743        AcaoSecrets::Reencrypt => {
744            let caminho = resolver_caminho_config(config_override)?;
745            executar_reencrypt(&caminho)?;
746            Ok(())
747        }
748    }
749}
750
751/// Recarrega e regrava o config, re-cifando secrets com a chave atual.
752fn executar_reencrypt(caminho: &PathBuf) -> Result<()> {
753    let (chave, fonte) = crate::secrets::garantir_chave_para_escrita()?;
754    if chave.is_none() {
755        return Err(ErroSshCli::ArgumentoInvalido(
756            "sem master-key; rode `ssh-cli secrets init` ou remova SSH_CLI_ALLOW_PLAINTEXT_SECRETS"
757                .to_string(),
758        )
759        .into());
760    }
761    if let Some(mut k) = chave {
762        use zeroize::Zeroize;
763        k.zeroize();
764    }
765    let arquivo = carregar(caminho)?;
766    salvar(caminho, &arquivo)?;
767    crate::output::imprimir_sucesso(&format!(
768        "reencrypt ok (source={}; hosts={})",
769        fonte.as_str(),
770        arquivo.hosts.len()
771    ));
772    Ok(())
773}
774
775/// Define a VPS ativa gravando seu nome em `<config_dir>/active` (arquivo irmão).
776pub async fn executar_connect(nome: &str, config_override: Option<PathBuf>) -> Result<()> {
777    let caminho = resolver_caminho_config(config_override)?;
778    let arquivo = carregar(&caminho)?;
779    if !arquivo.hosts.contains_key(nome) {
780        return Err(ErroSshCli::VpsNaoEncontrada(nome.to_string()).into());
781    }
782
783    let arquivo_ativo = caminho
784        .parent()
785        .map(|p| p.join("active"))
786        .unwrap_or_else(|| PathBuf::from("active"));
787    if let Some(pai) = arquivo_ativo.parent() {
788        std::fs::create_dir_all(pai)?;
789    }
790    // escrita atômica do active
791    let pai = arquivo_ativo
792        .parent()
793        .map(Path::to_path_buf)
794        .unwrap_or_else(|| PathBuf::from("."));
795    let mut tmp = tempfile::NamedTempFile::new_in(&pai)?;
796    tmp.write_all(nome.as_bytes())?;
797    tmp.as_file().sync_data()?;
798    tmp.persist(&arquivo_ativo)
799        .map_err(|e| ErroSshCli::Io(e.error))?;
800    crate::output::imprimir_sucesso(&format!("VPS ativa definida: '{nome}'"));
801    Ok(())
802}
803
804/// Busca um registro de VPS por nome.
805pub fn buscar_por_nome(
806    config_override: Option<PathBuf>,
807    nome: &str,
808) -> ResultadoSshCli<Option<VpsRegistro>> {
809    let caminho = resolver_caminho_config(config_override)?;
810    let arquivo = carregar(&caminho)?;
811    Ok(arquivo.hosts.get(nome).cloned())
812}
813
814/// Lê o nome da VPS ativa.
815pub fn ler_vps_ativa(config_override: Option<PathBuf>) -> ResultadoSshCli<Option<String>> {
816    let caminho = resolver_caminho_config(config_override)?;
817    let arquivo_ativo = caminho
818        .parent()
819        .map(|p| p.join("active"))
820        .unwrap_or_else(|| PathBuf::from("active"));
821    if !arquivo_ativo.exists() {
822        return Ok(None);
823    }
824    let nome = std::fs::read_to_string(&arquivo_ativo)?;
825    Ok(Some(nome.trim().to_string()))
826}
827
828/// Constrói `ConfiguracaoConexao` a partir de um `VpsRegistro`.
829pub fn construir_configuracao(
830    vps: &VpsRegistro,
831    config_toml: Option<&Path>,
832    replace_host_key: bool,
833) -> ConfiguracaoConexao {
834    let known_hosts_path = config_toml.map(KnownHosts::caminho_ao_lado_config);
835    ConfiguracaoConexao {
836        host: vps.host.clone(),
837        porta: vps.porta,
838        usuario: vps.usuario.clone(),
839        senha: vps.senha.clone(),
840        key_path: vps.key_path.clone(),
841        key_passphrase: vps.key_passphrase.clone(),
842        timeout_ms: vps.timeout_ms,
843        known_hosts_path,
844        replace_host_key,
845    }
846}
847
848/// Opções comuns de execução remota.
849#[derive(Debug, Default, Clone)]
850pub struct OpcoesExec {
851    /// Override senha.
852    pub password: Option<String>,
853    /// Override sudo.
854    pub sudo_password: Option<String>,
855    /// Override su.
856    pub su_password: Option<String>,
857    /// Override timeout.
858    pub timeout: Option<u64>,
859    /// Override key path.
860    pub key: Option<String>,
861    /// Override key passphrase.
862    pub key_passphrase: Option<String>,
863    /// Optional shell description comment.
864    pub description: Option<String>,
865    /// replace host key.
866    pub replace_host_key: bool,
867    /// disable sudo global.
868    pub disable_sudo: bool,
869}
870
871/// Executa um comando em uma VPS via SSH.
872pub async fn executar_exec(
873    vps_nome: &str,
874    comando: &str,
875    config_override: Option<PathBuf>,
876    formato: FormatoSaida,
877    json: bool,
878    opts: OpcoesExec,
879) -> Result<()> {
880    if crate::signals::cancelado() || crate::signals::terminado() {
881        return Err(anyhow::anyhow!(crate::i18n::t(
882            crate::i18n::Mensagem::OperacaoCancelada
883        )));
884    }
885    let caminho = resolver_caminho_config(config_override)?;
886    let arquivo = carregar(&caminho)?;
887    let vps_base = arquivo
888        .hosts
889        .get(vps_nome)
890        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
891
892    let mut vps = vps_base.clone();
893    aplicar_overrides(
894        &mut vps,
895        opts.password,
896        opts.sudo_password,
897        opts.su_password,
898        opts.timeout,
899        opts.key,
900        opts.key_passphrase,
901    );
902    let cmd = anexar_description(comando, opts.description.as_deref());
903    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
904    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
905    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
906    executar_exec_with_client(&vps, &cmd, cliente, formato, json).await
907}
908
909/// Versão testável de executar_exec.
910pub async fn executar_exec_with_client(
911    vps: &VpsRegistro,
912    comando: &str,
913    mut cliente: Box<dyn ClienteSshTrait>,
914    formato: FormatoSaida,
915    json: bool,
916) -> Result<()> {
917    if crate::signals::cancelado() || crate::signals::terminado() {
918        return Err(anyhow::anyhow!(crate::i18n::t(
919            crate::i18n::Mensagem::OperacaoCancelada
920        )));
921    }
922    let max_out = limite_efetivo(vps.max_output_chars);
923    let saida = cliente.executar_comando(comando, max_out, None).await?;
924    cliente.desconectar().await?;
925    if formato == FormatoSaida::Json || json {
926        output::imprimir_saida_execucao_json(&saida);
927    } else {
928        output::imprimir_saida_execucao(&saida);
929    }
930    if let Some(code) = saida.exit_code {
931        if code != 0 {
932            return Err(ErroSshCli::ComandoFalhou {
933                exit_code: code,
934                stderr: saida.stderr.clone(),
935            }
936            .into());
937        }
938    }
939    Ok(())
940}
941
942/// Executa um comando com `sudo` (packing `sh -c`).
943pub async fn executar_sudo_exec(
944    vps_nome: &str,
945    comando: &str,
946    config_override: Option<PathBuf>,
947    formato: FormatoSaida,
948    json: bool,
949    opts: OpcoesExec,
950) -> Result<()> {
951    if crate::signals::cancelado() || crate::signals::terminado() {
952        return Err(anyhow::anyhow!(crate::i18n::t(
953            crate::i18n::Mensagem::OperacaoCancelada
954        )));
955    }
956    let caminho = resolver_caminho_config(config_override)?;
957    let arquivo = carregar(&caminho)?;
958    let vps_base = arquivo
959        .hosts
960        .get(vps_nome)
961        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
962
963    let mut vps = vps_base.clone();
964    aplicar_overrides(
965        &mut vps,
966        opts.password.clone(),
967        opts.sudo_password.clone(),
968        opts.su_password.clone(),
969        opts.timeout,
970        opts.key.clone(),
971        opts.key_passphrase.clone(),
972    );
973    if opts.disable_sudo || vps.disable_sudo {
974        return Err(ErroSshCli::SudoDesabilitado.into());
975    }
976    let cmd = anexar_description(comando, opts.description.as_deref());
977    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
978    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
979    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
980    executar_sudo_exec_with_client(&vps, &cmd, cliente, formato, json).await
981}
982
983/// Versão testável de sudo-exec.
984pub async fn executar_sudo_exec_with_client(
985    vps: &VpsRegistro,
986    comando: &str,
987    mut cliente: Box<dyn ClienteSshTrait>,
988    formato: FormatoSaida,
989    json: bool,
990) -> Result<()> {
991    if crate::signals::cancelado() || crate::signals::terminado() {
992        return Err(anyhow::anyhow!(crate::i18n::t(
993            crate::i18n::Mensagem::OperacaoCancelada
994        )));
995    }
996    if vps.disable_sudo {
997        return Err(ErroSshCli::SudoDesabilitado.into());
998    }
999    let pack = empacotar_sudo(comando, vps.senha_sudo.as_ref());
1000    let max_out = limite_efetivo(vps.max_output_chars);
1001    let saida = cliente
1002        .executar_comando(&pack.comando, max_out, pack.stdin)
1003        .await?;
1004    cliente.desconectar().await?;
1005    if formato == FormatoSaida::Json || json {
1006        output::imprimir_saida_execucao_json(&saida);
1007    } else {
1008        output::imprimir_saida_execucao(&saida);
1009    }
1010    if let Some(code) = saida.exit_code {
1011        if code != 0 {
1012            return Err(ErroSshCli::ComandoFalhou {
1013                exit_code: code,
1014                stderr: saida.stderr.clone(),
1015            }
1016            .into());
1017        }
1018    }
1019    Ok(())
1020}
1021
1022/// Executa comando via `su -` one-shot (consome `senha_su`).
1023pub async fn executar_su_exec(
1024    vps_nome: &str,
1025    comando: &str,
1026    config_override: Option<PathBuf>,
1027    formato: FormatoSaida,
1028    json: bool,
1029    opts: OpcoesExec,
1030) -> Result<()> {
1031    if crate::signals::cancelado() || crate::signals::terminado() {
1032        return Err(anyhow::anyhow!(crate::i18n::t(
1033            crate::i18n::Mensagem::OperacaoCancelada
1034        )));
1035    }
1036    let caminho = resolver_caminho_config(config_override)?;
1037    let arquivo = carregar(&caminho)?;
1038    let vps_base = arquivo
1039        .hosts
1040        .get(vps_nome)
1041        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
1042
1043    let mut vps = vps_base.clone();
1044    aplicar_overrides(
1045        &mut vps,
1046        opts.password,
1047        opts.sudo_password,
1048        opts.su_password,
1049        opts.timeout,
1050        opts.key,
1051        opts.key_passphrase,
1052    );
1053    if opts.disable_sudo || vps.disable_sudo {
1054        return Err(ErroSshCli::SudoDesabilitado.into());
1055    }
1056    let senha_su = vps.senha_su.clone().ok_or(ErroSshCli::SenhaSuAusente)?;
1057    let cmd = anexar_description(comando, opts.description.as_deref());
1058    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
1059    let pack = empacotar_su(&cmd, &senha_su);
1060    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
1061    let mut cliente: Box<dyn ClienteSshTrait> =
1062        <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1063    let max_out = limite_efetivo(vps.max_output_chars);
1064    let saida = cliente
1065        .executar_comando(&pack.comando, max_out, pack.stdin)
1066        .await?;
1067    cliente.desconectar().await?;
1068    if formato == FormatoSaida::Json || json {
1069        output::imprimir_saida_execucao_json(&saida);
1070    } else {
1071        output::imprimir_saida_execucao(&saida);
1072    }
1073    if let Some(code) = saida.exit_code {
1074        if code != 0 {
1075            return Err(ErroSshCli::ComandoFalhou {
1076                exit_code: code,
1077                stderr: saida.stderr.clone(),
1078            }
1079            .into());
1080        }
1081    }
1082    Ok(())
1083}
1084
1085/// Health-check SSH.
1086pub async fn executar_health_check(
1087    vps_nome: Option<&str>,
1088    config_override: Option<PathBuf>,
1089    formato: FormatoSaida,
1090    json_local: bool,
1091    password_override: Option<String>,
1092    timeout_override: Option<u64>,
1093) -> Result<()> {
1094    let _ = json_local; // merged below with formato
1095    if crate::signals::cancelado() || crate::signals::terminado() {
1096        return Err(anyhow::anyhow!(crate::i18n::t(
1097            crate::i18n::Mensagem::OperacaoCancelada
1098        )));
1099    }
1100    let nome_resolvido: String = match vps_nome {
1101        Some(n) => n.to_string(),
1102        None => {
1103            // GAP-SSH-EXIT-002: tipado → exit 66 (não anyhow string → exit 1).
1104            let ativa = ler_vps_ativa(config_override.clone())?;
1105            ativa.ok_or(ErroSshCli::NenhumaVpsAtiva)?
1106        }
1107    };
1108    let caminho = resolver_caminho_config(config_override)?;
1109    let arquivo = carregar(&caminho)?;
1110    let vps_base = arquivo
1111        .hosts
1112        .get(&nome_resolvido)
1113        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
1114
1115    let mut vps = vps_base.clone();
1116    // GAP-SSH-CLI-004: --timeout alinha health-check a exec/tunnel.
1117    aplicar_overrides(
1118        &mut vps,
1119        password_override,
1120        None,
1121        None,
1122        timeout_override,
1123        None,
1124        None,
1125    );
1126    let cfg = construir_configuracao(&vps, Some(&caminho), false);
1127    let inicio = std::time::Instant::now();
1128    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1129    let latencia_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1130    cliente.desconectar().await?;
1131
1132    if usar_json(json_local, formato) {
1133        output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
1134    } else {
1135        output::imprimir_health_check(&nome_resolvido, latencia_ms);
1136    }
1137    Ok(())
1138}
1139
1140#[cfg(test)]
1141mod testes {
1142    use super::*;
1143    use secrecy::ExposeSecret;
1144    use tempfile::TempDir;
1145
1146    fn reg_min() -> VpsRegistro {
1147        VpsRegistro::novo(
1148            "srv".into(),
1149            "host.example.com".into(),
1150            2222,
1151            "admin".into(),
1152            SecretString::from("pass".to_string()),
1153            None,
1154            None,
1155            Some(60_000),
1156            Some(1_000),
1157            Some(50_000),
1158            None,
1159            None,
1160            false,
1161        )
1162    }
1163
1164    #[test]
1165    fn arquivo_vazio_serializa_com_schema() {
1166        let arq = ArquivoConfig {
1167            schema_version: modelo::SCHEMA_VERSION_ATUAL,
1168            hosts: BTreeMap::new(),
1169        };
1170        let texto = toml::to_string(&arq).unwrap();
1171        assert!(texto.contains("schema_version = 2"));
1172    }
1173
1174    #[test]
1175    fn parse_limite_none() {
1176        assert_eq!(parse_limite_chars("none"), 0);
1177        assert_eq!(parse_limite_chars("0"), 0);
1178        assert_eq!(parse_limite_chars("1000"), 1000);
1179    }
1180
1181    #[test]
1182    fn construir_configuracao_copia_campos() {
1183        let registro = reg_min();
1184        let cfg = construir_configuracao(&registro, None, false);
1185        assert_eq!(cfg.host, "host.example.com");
1186        assert_eq!(cfg.porta, 2222);
1187        assert_eq!(cfg.usuario, "admin");
1188        assert_eq!(cfg.timeout_ms, 60_000);
1189        assert!(cfg.known_hosts_path.is_none());
1190    }
1191
1192    #[test]
1193    #[serial_test::serial]
1194    fn salvar_atomico_roundtrip() {
1195        let tmp = TempDir::new().unwrap();
1196        crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
1197        unsafe {
1198            std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
1199        }
1200        let path = tmp.path().join("config.toml");
1201        let mut arq = ArquivoConfig {
1202            schema_version: 2,
1203            hosts: BTreeMap::new(),
1204        };
1205        arq.hosts.insert("a".into(), reg_min());
1206        salvar(&path, &arq).unwrap();
1207        let lido = carregar(&path).unwrap();
1208        assert_eq!(lido.hosts.len(), 1);
1209        assert_eq!(lido.hosts["a"].senha.expose_secret(), "pass");
1210        #[cfg(unix)]
1211        {
1212            use std::os::unix::fs::PermissionsExt;
1213            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1214            assert_eq!(mode, 0o600);
1215            let lock = path.with_extension("toml.lock");
1216            if lock.exists() {
1217                let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
1218                assert_eq!(lm, 0o600);
1219            }
1220        }
1221        unsafe {
1222            std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
1223        }
1224        crate::secrets::definir_diretorio_config(None);
1225    }
1226
1227    #[test]
1228    fn resolver_caminho_config_com_override_diretorio() {
1229        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
1230        assert_eq!(
1231            resultado.unwrap(),
1232            PathBuf::from("/tmp/test-dir/config.toml")
1233        );
1234    }
1235
1236    #[test]
1237    fn validar_comando_longo() {
1238        let err = validar_comando_tamanho(&"x".repeat(20), 10).unwrap_err();
1239        assert!(matches!(err, ErroSshCli::ComandoMuitoLongo { .. }));
1240    }
1241
1242    #[test]
1243    fn empacotar_sudo_integrado() {
1244        let pack = empacotar_sudo("ls -la", None);
1245        assert_eq!(pack.comando, "sudo -n sh -c 'ls -la'");
1246        assert!(pack.stdin.is_none());
1247    }
1248
1249    #[test]
1250    fn aplicar_overrides_senha() {
1251        let mut v = reg_min();
1252        aplicar_overrides(
1253            &mut v,
1254            Some("nova".into()),
1255            Some("sudo".into()),
1256            None,
1257            Some(1000),
1258            Some("/k".into()),
1259            None,
1260        );
1261        assert_eq!(v.senha.expose_secret(), "nova");
1262        assert_eq!(v.timeout_ms, 1000);
1263        assert_eq!(v.key_path.as_deref(), Some("/k"));
1264    }
1265
1266    #[tokio::test]
1267    async fn executar_sudo_exec_with_client_ok() {
1268        use crate::ssh::cliente::mocks::MockClienteSsh;
1269        use crate::ssh::cliente::SaidaExecucao;
1270        let mut mock = MockClienteSsh::new();
1271        mock.expect_executar_comando()
1272            .returning(|c, _, stdin| {
1273                assert!(c.contains("sudo -n sh -c"));
1274                assert!(stdin.is_none());
1275                Ok(SaidaExecucao {
1276                    stdout: "ok".into(),
1277                    stderr: String::new(),
1278                    exit_code: Some(0),
1279                    truncado_stdout: false,
1280                    truncado_stderr: false,
1281                    duracao_ms: 1,
1282                })
1283            });
1284        mock.expect_desconectar().returning(|| Ok(()));
1285
1286        let vps = reg_min();
1287        executar_sudo_exec_with_client(&vps, "id", Box::new(mock), FormatoSaida::Text, false)
1288            .await
1289            .unwrap();
1290    }
1291}