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)
334                .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido: {e}")))?;
335            let mut arquivo = carregar(&caminho)?;
336            if arquivo.hosts.contains_key(&name) {
337                return Err(ErroSshCli::VpsDuplicada(name).into());
338            }
339            if password_stdin && (sudo_password_stdin || su_password_stdin) {
340                return Err(ErroSshCli::ArgumentoInvalido(
341                    "apenas um --*-stdin por invocação one-shot; rode vps edit para sudo/su".into(),
342                )
343                .into());
344            }
345            let senha = if password_stdin {
346                SecretString::from(ler_segredo_stdin()?)
347            } else {
348                SecretString::from(password.unwrap_or_default())
349            };
350            let sudo_s = if sudo_password_stdin {
351                Some(SecretString::from(ler_segredo_stdin()?))
352            } else {
353                sudo_password.map(SecretString::from)
354            };
355            let su_s = if su_password_stdin {
356                Some(SecretString::from(ler_segredo_stdin()?))
357            } else {
358                su_password.map(SecretString::from)
359            };
360            if let Some(ref k) = key {
361                validar_key_path_existe(k)?;
362            }
363            // max_chars legado → command se max_command não veio explicitamente
364            let max_cmd = max_command_chars
365                .as_deref()
366                .or(max_chars.as_deref())
367                .map(parse_limite_chars)
368                .unwrap_or(modelo::MAX_COMMAND_CHARS_PADRAO);
369            let max_out = max_output_chars
370                .as_deref()
371                .map(parse_limite_chars)
372                .unwrap_or(modelo::MAX_OUTPUT_CHARS_PADRAO);
373            let registro = VpsRegistro::novo(
374                name.clone(),
375                host,
376                port,
377                user,
378                senha,
379                key,
380                key_passphrase.map(SecretString::from),
381                Some(timeout),
382                Some(max_cmd),
383                Some(max_out),
384                sudo_s,
385                su_s,
386                disable_sudo,
387            );
388            // GAP-SSH-VAL-002 / VAL-003: domínio completo no write-path.
389            registro.validar().map_err(ErroSshCli::ArgumentoInvalido)?;
390            arquivo.hosts.insert(name.clone(), registro);
391            arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
392            salvar(&caminho, &arquivo)?;
393            crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
394            if check {
395                executar_health_check(Some(&name), config_override, formato, false, None, None)
396                    .await?;
397            }
398        }
399        AcaoVps::List { json } => {
400            let arquivo = carregar(&caminho)?;
401            let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
402            // GAP-SSH-IO-001: respeitar formato global.
403            if usar_json(json, formato) {
404                crate::output::imprimir_lista_json(&registros);
405            } else {
406                crate::output::imprimir_lista_texto(&registros);
407            }
408        }
409        AcaoVps::Remove { nome } => {
410            let mut arquivo = carregar(&caminho)?;
411            if arquivo.hosts.remove(&nome).is_none() {
412                return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
413            }
414            salvar(&caminho, &arquivo)?;
415            // GAP-SSH-STATE-001: limpar active órfão.
416            limpar_active_se_nome(&caminho, &nome)?;
417            crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
418        }
419        AcaoVps::Edit {
420            nome,
421            host,
422            port,
423            user,
424            password,
425            password_stdin,
426            key,
427            key_passphrase,
428            timeout,
429            max_command_chars,
430            max_output_chars,
431            max_chars,
432            sudo_password,
433            sudo_password_stdin,
434            su_password,
435            su_password_stdin,
436            disable_sudo,
437        } => {
438            let mut arquivo = carregar(&caminho)?;
439            let registro = arquivo
440                .hosts
441                .get_mut(&nome)
442                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
443            if let Some(h) = host {
444                registro.host = h;
445            }
446            if let Some(p) = port {
447                registro.porta = p;
448            }
449            if let Some(u) = user {
450                registro.usuario = u;
451            }
452            if password_stdin {
453                registro.senha = SecretString::from(ler_segredo_stdin()?);
454            } else if let Some(pw) = password {
455                registro.senha = SecretString::from(pw);
456            }
457            if let Some(k) = key {
458                validar_key_path_existe(&k)?;
459                registro.key_path = Some(k);
460            }
461            if let Some(kp) = key_passphrase {
462                registro.key_passphrase = Some(SecretString::from(kp));
463            }
464            if let Some(t) = timeout {
465                registro.timeout_ms = t;
466            }
467            if let Some(m) = max_command_chars.or(max_chars) {
468                registro.max_command_chars = parse_limite_chars(&m);
469            }
470            if let Some(m) = max_output_chars {
471                registro.max_output_chars = parse_limite_chars(&m);
472            }
473            if sudo_password_stdin {
474                registro.senha_sudo = Some(SecretString::from(ler_segredo_stdin()?));
475            } else if let Some(sp) = sudo_password {
476                registro.senha_sudo = Some(SecretString::from(sp));
477            }
478            if su_password_stdin {
479                registro.senha_su = Some(SecretString::from(ler_segredo_stdin()?));
480            } else if let Some(sp) = su_password {
481                registro.senha_su = Some(SecretString::from(sp));
482            }
483            if let Some(d) = disable_sudo {
484                registro.disable_sudo = d;
485            }
486            registro.validar().map_err(ErroSshCli::ArgumentoInvalido)?;
487            salvar(&caminho, &arquivo)?;
488            crate::output::imprimir_sucesso(&format!("VPS '{nome}' editada"));
489        }
490        AcaoVps::Show { nome, json } => {
491            let arquivo = carregar(&caminho)?;
492            let registro = arquivo
493                .hosts
494                .get(&nome)
495                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
496            if usar_json(json, formato) {
497                crate::output::imprimir_detalhes_json(registro);
498            } else {
499                crate::output::imprimir_detalhes_texto(registro);
500            }
501        }
502        AcaoVps::Path => {
503            crate::output::escrever_linha(&caminho.display().to_string())?;
504        }
505        AcaoVps::Doctor { json } => {
506            executar_doctor(config_override, usar_json(json, formato))?;
507        }
508        AcaoVps::Export {
509            include_secrets,
510            output,
511        } => {
512            executar_export(&caminho, include_secrets, output.as_deref())?;
513        }
514        AcaoVps::Import {
515            file,
516            allow_incomplete,
517        } => {
518            executar_import(&caminho, &file, allow_incomplete)?;
519        }
520    }
521    Ok(())
522}
523
524/// Remove o arquivo `active` se o conteúdo for o nome removido (STATE-001).
525fn limpar_active_se_nome(caminho_config: &Path, nome: &str) -> Result<()> {
526    let active = caminho_config
527        .parent()
528        .map(|p| p.join("active"))
529        .unwrap_or_else(|| PathBuf::from("active"));
530    if !active.exists() {
531        return Ok(());
532    }
533    let conteudo = std::fs::read_to_string(&active).unwrap_or_default();
534    if conteudo.trim() == nome {
535        let _ = std::fs::remove_file(&active);
536    }
537    Ok(())
538}
539
540fn executar_doctor(config_override: Option<PathBuf>, json: bool) -> Result<()> {
541    let camada = camada_vencedora(config_override.clone())?;
542    let caminho = camada.path.clone();
543    let existe = caminho.exists();
544    let arquivo = carregar(&caminho)?;
545    let kh = KnownHosts::caminho_ao_lado_config(&caminho);
546    let active = caminho
547        .parent()
548        .map(|p| p.join("active"))
549        .unwrap_or_else(|| PathBuf::from("active"));
550    let perms = if existe {
551        #[cfg(unix)]
552        {
553            use std::os::unix::fs::PermissionsExt;
554            format!(
555                "{:o}",
556                std::fs::metadata(&caminho)?.permissions().mode() & 0o777
557            )
558        }
559        #[cfg(not(unix))]
560        {
561            "n/a".to_string()
562        }
563    } else {
564        "ausente".to_string()
565    };
566
567    let seg = crate::secrets::status_segredos()?;
568    if json {
569        let v = serde_json::json!({
570            "layer": camada.nome,
571            "config_path": caminho.display().to_string(),
572            "exists": existe,
573            "permissions": perms,
574            "schema_version": arquivo.schema_version,
575            "hosts": arquivo.hosts.len(),
576            "known_hosts": kh.display().to_string(),
577            "active_file": active.display().to_string(),
578            "secrets_at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
579            "secrets_key_source": seg.fonte.as_str(),
580            "secrets_key_file": seg.key_file_path.display().to_string(),
581            "secrets_plaintext_opt_out": seg.plaintext_opt_out,
582            "telemetry": false,
583        });
584        // GAP-SSH-IO-005: println só em output.
585        crate::output::imprimir_json_value(&v)?;
586    } else {
587        let config_path_s = caminho.display().to_string();
588        let kh_s = kh.display().to_string();
589        let active_s = active.display().to_string();
590        let key_file_s = seg.key_file_path.display().to_string();
591        crate::output::imprimir_doctor_texto(
592            camada.nome,
593            &config_path_s,
594            existe,
595            &perms,
596            arquivo.schema_version,
597            arquivo.hosts.len(),
598            &kh_s,
599            &active_s,
600            if seg.cifragem_ativa {
601                "encrypted"
602            } else {
603                "plaintext"
604            },
605            seg.fonte.as_str(),
606            &key_file_s,
607            seg.plaintext_opt_out,
608        );
609    }
610    Ok(())
611}
612
613fn executar_export(caminho: &PathBuf, include_secrets: bool, output: Option<&str>) -> Result<()> {
614    let arquivo = carregar(caminho)?;
615    let mut export = ArquivoConfig {
616        schema_version: arquivo.schema_version,
617        hosts: BTreeMap::new(),
618    };
619    for (k, mut v) in arquivo.hosts {
620        if !include_secrets {
621            v.senha = SecretString::from(String::new());
622            v.senha_sudo = None;
623            v.senha_su = None;
624            v.key_passphrase = None;
625        }
626        export.hosts.insert(k, v);
627    }
628    let texto = toml::to_string_pretty(&export)?;
629    if let Some(path) = output {
630        escrever_atomico(Path::new(path), texto.as_bytes())?;
631        crate::output::imprimir_sucesso(&format!("exportado para {path}"));
632    } else {
633        print!("{texto}");
634    }
635    Ok(())
636}
637
638fn executar_import(caminho: &PathBuf, file: &Path, allow_incomplete: bool) -> Result<()> {
639    let texto = std::fs::read_to_string(file)?;
640    let importado: ArquivoConfig = toml::from_str(&texto)?;
641    let mut atual = carregar(caminho)?;
642    for (k, mut v) in importado.hosts {
643        // VAL-001 no import.
644        let nome = crate::paths::validar_e_normalizar(&k).map_err(|e| {
645            ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido no import '{k}': {e}"))
646        })?;
647        v.nome = nome.clone();
648        if let Some(ref key) = v.key_path {
649            if !key.trim().is_empty() {
650                validar_key_path_existe(key)?;
651            }
652        }
653        match v.validar() {
654            Ok(()) => {
655                atual.hosts.insert(nome, v);
656            }
657            Err(msg) if allow_incomplete => {
658                // GAP-SSH-IMP-001: esqueleto incompleto permitido.
659                tracing::warn!(host = %nome, %msg, "import incomplete permitido");
660                atual.hosts.insert(nome, v);
661            }
662            Err(msg) => {
663                // Detectar export redacted.
664                let redacted = !v.tem_senha() && !v.tem_chave();
665                if redacted {
666                    return Err(ErroSshCli::ArgumentoInvalido(format!(
667                        "host '{nome}' parece export redacted (sem password/key). \
668                         Use `vps export --include-secrets`, complete com `vps edit`, \
669                         ou `vps import --allow-incomplete`. Detalhe: {msg}"
670                    ))
671                    .into());
672                }
673                return Err(ErroSshCli::ArgumentoInvalido(format!(
674                    "host '{nome}' inválido no import: {msg}"
675                ))
676                .into());
677            }
678        }
679    }
680    atual.schema_version = modelo::SCHEMA_VERSION_ATUAL;
681    salvar(caminho, &atual)?;
682    crate::output::imprimir_sucesso("importação concluída");
683    Ok(())
684}
685
686/// Dispatcher one-shot de `secrets status|init|reencrypt`.
687pub async fn executar_comando_secrets(
688    acao: AcaoSecrets,
689    config_override: Option<PathBuf>,
690    formato: FormatoSaida,
691) -> Result<()> {
692    // Garante alinhamento do secrets.key com --config-dir.
693    crate::secrets::definir_diretorio_config(config_override.clone());
694    match acao {
695        AcaoSecrets::Status { json } => {
696            let seg = crate::secrets::status_segredos()?;
697            let usar_json = json || formato == FormatoSaida::Json;
698            if usar_json {
699                let v = serde_json::json!({
700                    "encryption_active": seg.cifragem_ativa,
701                    "key_source": seg.fonte.as_str(),
702                    "key_file": seg.key_file_path.display().to_string(),
703                    "plaintext_opt_out": seg.plaintext_opt_out,
704                    "at_rest": if seg.cifragem_ativa { "encrypted" } else { "plaintext" },
705                });
706                crate::output::imprimir_json_value(&v)?;
707            } else {
708                crate::output::imprimir_sucesso(&format!(
709                    "at-rest: {} | source: {} | key_file: {} | plaintext_opt_out: {}",
710                    if seg.cifragem_ativa {
711                        "encrypted"
712                    } else {
713                        "plaintext"
714                    },
715                    seg.fonte.as_str(),
716                    seg.key_file_path.display(),
717                    seg.plaintext_opt_out
718                ));
719            }
720            Ok(())
721        }
722        AcaoSecrets::Init { keyring, force } => {
723            let seg = crate::secrets::init_master_key(keyring, force)?;
724            crate::output::imprimir_sucesso(&format!(
725                "master-key pronta (source={}; key_file={})",
726                seg.fonte.as_str(),
727                seg.key_file_path.display()
728            ));
729            Ok(())
730        }
731        AcaoSecrets::Reencrypt => {
732            let caminho = resolver_caminho_config(config_override)?;
733            executar_reencrypt(&caminho)?;
734            Ok(())
735        }
736    }
737}
738
739/// Recarrega e regrava o config, re-cifando secrets com a chave atual.
740fn executar_reencrypt(caminho: &PathBuf) -> Result<()> {
741    let (chave, fonte) = crate::secrets::garantir_chave_para_escrita()?;
742    if chave.is_none() {
743        return Err(ErroSshCli::ArgumentoInvalido(
744            "sem master-key; rode `ssh-cli secrets init` ou remova SSH_CLI_ALLOW_PLAINTEXT_SECRETS"
745                .to_string(),
746        )
747        .into());
748    }
749    if let Some(mut k) = chave {
750        use zeroize::Zeroize;
751        k.zeroize();
752    }
753    let arquivo = carregar(caminho)?;
754    salvar(caminho, &arquivo)?;
755    crate::output::imprimir_sucesso(&format!(
756        "reencrypt ok (source={}; hosts={})",
757        fonte.as_str(),
758        arquivo.hosts.len()
759    ));
760    Ok(())
761}
762
763/// Define a VPS ativa gravando seu nome em `<config_dir>/active` (arquivo irmão).
764pub async fn executar_connect(nome: &str, config_override: Option<PathBuf>) -> Result<()> {
765    let caminho = resolver_caminho_config(config_override)?;
766    let arquivo = carregar(&caminho)?;
767    if !arquivo.hosts.contains_key(nome) {
768        return Err(ErroSshCli::VpsNaoEncontrada(nome.to_string()).into());
769    }
770
771    let arquivo_ativo = caminho
772        .parent()
773        .map(|p| p.join("active"))
774        .unwrap_or_else(|| PathBuf::from("active"));
775    if let Some(pai) = arquivo_ativo.parent() {
776        std::fs::create_dir_all(pai)?;
777    }
778    // escrita atômica do active
779    let pai = arquivo_ativo
780        .parent()
781        .map(Path::to_path_buf)
782        .unwrap_or_else(|| PathBuf::from("."));
783    let mut tmp = tempfile::NamedTempFile::new_in(&pai)?;
784    tmp.write_all(nome.as_bytes())?;
785    tmp.as_file().sync_data()?;
786    tmp.persist(&arquivo_ativo)
787        .map_err(|e| ErroSshCli::Io(e.error))?;
788    crate::output::imprimir_sucesso(&format!("VPS ativa definida: '{nome}'"));
789    Ok(())
790}
791
792/// Busca um registro de VPS por nome.
793pub fn buscar_por_nome(
794    config_override: Option<PathBuf>,
795    nome: &str,
796) -> ResultadoSshCli<Option<VpsRegistro>> {
797    let caminho = resolver_caminho_config(config_override)?;
798    let arquivo = carregar(&caminho)?;
799    Ok(arquivo.hosts.get(nome).cloned())
800}
801
802/// Lê o nome da VPS ativa.
803pub fn ler_vps_ativa(config_override: Option<PathBuf>) -> ResultadoSshCli<Option<String>> {
804    let caminho = resolver_caminho_config(config_override)?;
805    let arquivo_ativo = caminho
806        .parent()
807        .map(|p| p.join("active"))
808        .unwrap_or_else(|| PathBuf::from("active"));
809    if !arquivo_ativo.exists() {
810        return Ok(None);
811    }
812    let nome = std::fs::read_to_string(&arquivo_ativo)?;
813    Ok(Some(nome.trim().to_string()))
814}
815
816/// Constrói `ConfiguracaoConexao` a partir de um `VpsRegistro`.
817pub fn construir_configuracao(
818    vps: &VpsRegistro,
819    config_toml: Option<&Path>,
820    replace_host_key: bool,
821) -> ConfiguracaoConexao {
822    let known_hosts_path = config_toml.map(KnownHosts::caminho_ao_lado_config);
823    ConfiguracaoConexao {
824        host: vps.host.clone(),
825        porta: vps.porta,
826        usuario: vps.usuario.clone(),
827        senha: vps.senha.clone(),
828        key_path: vps.key_path.clone(),
829        key_passphrase: vps.key_passphrase.clone(),
830        timeout_ms: vps.timeout_ms,
831        known_hosts_path,
832        replace_host_key,
833    }
834}
835
836/// Opções comuns de execução remota.
837#[derive(Debug, Default, Clone)]
838pub struct OpcoesExec {
839    /// Override senha.
840    pub password: Option<String>,
841    /// Override sudo.
842    pub sudo_password: Option<String>,
843    /// Override su.
844    pub su_password: Option<String>,
845    /// Override timeout.
846    pub timeout: Option<u64>,
847    /// Override key path.
848    pub key: Option<String>,
849    /// Override key passphrase.
850    pub key_passphrase: Option<String>,
851    /// Optional shell description comment.
852    pub description: Option<String>,
853    /// replace host key.
854    pub replace_host_key: bool,
855    /// disable sudo global.
856    pub disable_sudo: bool,
857}
858
859/// Executa um comando em uma VPS via SSH.
860pub async fn executar_exec(
861    vps_nome: &str,
862    comando: &str,
863    config_override: Option<PathBuf>,
864    formato: FormatoSaida,
865    json: bool,
866    opts: OpcoesExec,
867) -> Result<()> {
868    if crate::signals::cancelado() || crate::signals::terminado() {
869        return Err(anyhow::anyhow!(crate::i18n::t(
870            crate::i18n::Mensagem::OperacaoCancelada
871        )));
872    }
873    let caminho = resolver_caminho_config(config_override)?;
874    let arquivo = carregar(&caminho)?;
875    let vps_base = arquivo
876        .hosts
877        .get(vps_nome)
878        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
879
880    let mut vps = vps_base.clone();
881    aplicar_overrides(
882        &mut vps,
883        opts.password,
884        opts.sudo_password,
885        opts.su_password,
886        opts.timeout,
887        opts.key,
888        opts.key_passphrase,
889    );
890    let cmd = anexar_description(comando, opts.description.as_deref());
891    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
892    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
893    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
894    executar_exec_with_client(&vps, &cmd, cliente, formato, json).await
895}
896
897/// Versão testável de executar_exec.
898pub async fn executar_exec_with_client(
899    vps: &VpsRegistro,
900    comando: &str,
901    mut cliente: Box<dyn ClienteSshTrait>,
902    formato: FormatoSaida,
903    json: bool,
904) -> Result<()> {
905    if crate::signals::cancelado() || crate::signals::terminado() {
906        return Err(anyhow::anyhow!(crate::i18n::t(
907            crate::i18n::Mensagem::OperacaoCancelada
908        )));
909    }
910    let max_out = limite_efetivo(vps.max_output_chars);
911    let saida = cliente.executar_comando(comando, max_out, None).await?;
912    cliente.desconectar().await?;
913    if formato == FormatoSaida::Json || json {
914        output::imprimir_saida_execucao_json(&saida);
915    } else {
916        output::imprimir_saida_execucao(&saida);
917    }
918    if let Some(code) = saida.exit_code {
919        if code != 0 {
920            return Err(ErroSshCli::ComandoFalhou {
921                exit_code: code,
922                stderr: saida.stderr.clone(),
923            }
924            .into());
925        }
926    }
927    Ok(())
928}
929
930/// Executa um comando com `sudo` (packing `sh -c`).
931pub async fn executar_sudo_exec(
932    vps_nome: &str,
933    comando: &str,
934    config_override: Option<PathBuf>,
935    formato: FormatoSaida,
936    json: bool,
937    opts: OpcoesExec,
938) -> Result<()> {
939    if crate::signals::cancelado() || crate::signals::terminado() {
940        return Err(anyhow::anyhow!(crate::i18n::t(
941            crate::i18n::Mensagem::OperacaoCancelada
942        )));
943    }
944    let caminho = resolver_caminho_config(config_override)?;
945    let arquivo = carregar(&caminho)?;
946    let vps_base = arquivo
947        .hosts
948        .get(vps_nome)
949        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
950
951    let mut vps = vps_base.clone();
952    aplicar_overrides(
953        &mut vps,
954        opts.password.clone(),
955        opts.sudo_password.clone(),
956        opts.su_password.clone(),
957        opts.timeout,
958        opts.key.clone(),
959        opts.key_passphrase.clone(),
960    );
961    if opts.disable_sudo || vps.disable_sudo {
962        return Err(ErroSshCli::SudoDesabilitado.into());
963    }
964    let cmd = anexar_description(comando, opts.description.as_deref());
965    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
966    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
967    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
968    executar_sudo_exec_with_client(&vps, &cmd, cliente, formato, json).await
969}
970
971/// Versão testável de sudo-exec.
972pub async fn executar_sudo_exec_with_client(
973    vps: &VpsRegistro,
974    comando: &str,
975    mut cliente: Box<dyn ClienteSshTrait>,
976    formato: FormatoSaida,
977    json: bool,
978) -> Result<()> {
979    if crate::signals::cancelado() || crate::signals::terminado() {
980        return Err(anyhow::anyhow!(crate::i18n::t(
981            crate::i18n::Mensagem::OperacaoCancelada
982        )));
983    }
984    if vps.disable_sudo {
985        return Err(ErroSshCli::SudoDesabilitado.into());
986    }
987    let pack = empacotar_sudo(comando, vps.senha_sudo.as_ref());
988    let max_out = limite_efetivo(vps.max_output_chars);
989    let saida = cliente
990        .executar_comando(&pack.comando, max_out, pack.stdin)
991        .await?;
992    cliente.desconectar().await?;
993    if formato == FormatoSaida::Json || json {
994        output::imprimir_saida_execucao_json(&saida);
995    } else {
996        output::imprimir_saida_execucao(&saida);
997    }
998    if let Some(code) = saida.exit_code {
999        if code != 0 {
1000            return Err(ErroSshCli::ComandoFalhou {
1001                exit_code: code,
1002                stderr: saida.stderr.clone(),
1003            }
1004            .into());
1005        }
1006    }
1007    Ok(())
1008}
1009
1010/// Executa comando via `su -` one-shot (consome `senha_su`).
1011pub async fn executar_su_exec(
1012    vps_nome: &str,
1013    comando: &str,
1014    config_override: Option<PathBuf>,
1015    formato: FormatoSaida,
1016    json: bool,
1017    opts: OpcoesExec,
1018) -> Result<()> {
1019    if crate::signals::cancelado() || crate::signals::terminado() {
1020        return Err(anyhow::anyhow!(crate::i18n::t(
1021            crate::i18n::Mensagem::OperacaoCancelada
1022        )));
1023    }
1024    let caminho = resolver_caminho_config(config_override)?;
1025    let arquivo = carregar(&caminho)?;
1026    let vps_base = arquivo
1027        .hosts
1028        .get(vps_nome)
1029        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
1030
1031    let mut vps = vps_base.clone();
1032    aplicar_overrides(
1033        &mut vps,
1034        opts.password,
1035        opts.sudo_password,
1036        opts.su_password,
1037        opts.timeout,
1038        opts.key,
1039        opts.key_passphrase,
1040    );
1041    if opts.disable_sudo || vps.disable_sudo {
1042        return Err(ErroSshCli::SudoDesabilitado.into());
1043    }
1044    let senha_su = vps.senha_su.clone().ok_or(ErroSshCli::SenhaSuAusente)?;
1045    let cmd = anexar_description(comando, opts.description.as_deref());
1046    validar_comando_tamanho(&cmd, vps.max_command_chars)?;
1047    let pack = empacotar_su(&cmd, &senha_su);
1048    let cfg = construir_configuracao(&vps, Some(&caminho), opts.replace_host_key);
1049    let mut cliente: Box<dyn ClienteSshTrait> =
1050        <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1051    let max_out = limite_efetivo(vps.max_output_chars);
1052    let saida = cliente
1053        .executar_comando(&pack.comando, max_out, pack.stdin)
1054        .await?;
1055    cliente.desconectar().await?;
1056    if formato == FormatoSaida::Json || json {
1057        output::imprimir_saida_execucao_json(&saida);
1058    } else {
1059        output::imprimir_saida_execucao(&saida);
1060    }
1061    if let Some(code) = saida.exit_code {
1062        if code != 0 {
1063            return Err(ErroSshCli::ComandoFalhou {
1064                exit_code: code,
1065                stderr: saida.stderr.clone(),
1066            }
1067            .into());
1068        }
1069    }
1070    Ok(())
1071}
1072
1073/// Health-check SSH.
1074pub async fn executar_health_check(
1075    vps_nome: Option<&str>,
1076    config_override: Option<PathBuf>,
1077    formato: FormatoSaida,
1078    json_local: bool,
1079    password_override: Option<String>,
1080    timeout_override: Option<u64>,
1081) -> Result<()> {
1082    let _ = json_local; // merged below with formato
1083    if crate::signals::cancelado() || crate::signals::terminado() {
1084        return Err(anyhow::anyhow!(crate::i18n::t(
1085            crate::i18n::Mensagem::OperacaoCancelada
1086        )));
1087    }
1088    let nome_resolvido: String = match vps_nome {
1089        Some(n) => n.to_string(),
1090        None => {
1091            // GAP-SSH-EXIT-002: tipado → exit 66 (não anyhow string → exit 1).
1092            let ativa = ler_vps_ativa(config_override.clone())?;
1093            ativa.ok_or(ErroSshCli::NenhumaVpsAtiva)?
1094        }
1095    };
1096    let caminho = resolver_caminho_config(config_override)?;
1097    let arquivo = carregar(&caminho)?;
1098    let vps_base = arquivo
1099        .hosts
1100        .get(&nome_resolvido)
1101        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
1102
1103    let mut vps = vps_base.clone();
1104    // GAP-SSH-CLI-004: --timeout alinha health-check a exec/tunnel.
1105    aplicar_overrides(
1106        &mut vps,
1107        password_override,
1108        None,
1109        None,
1110        timeout_override,
1111        None,
1112        None,
1113    );
1114    let cfg = construir_configuracao(&vps, Some(&caminho), false);
1115    let inicio = std::time::Instant::now();
1116    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1117    let latencia_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1118    cliente.desconectar().await?;
1119
1120    if usar_json(json_local, formato) {
1121        output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
1122    } else {
1123        output::imprimir_health_check(&nome_resolvido, latencia_ms);
1124    }
1125    Ok(())
1126}
1127
1128#[cfg(test)]
1129mod testes {
1130    use super::*;
1131    use secrecy::ExposeSecret;
1132    use tempfile::TempDir;
1133
1134    fn reg_min() -> VpsRegistro {
1135        VpsRegistro::novo(
1136            "srv".into(),
1137            "host.example.com".into(),
1138            2222,
1139            "admin".into(),
1140            SecretString::from("pass".to_string()),
1141            None,
1142            None,
1143            Some(60_000),
1144            Some(1_000),
1145            Some(50_000),
1146            None,
1147            None,
1148            false,
1149        )
1150    }
1151
1152    #[test]
1153    fn arquivo_vazio_serializa_com_schema() {
1154        let arq = ArquivoConfig {
1155            schema_version: modelo::SCHEMA_VERSION_ATUAL,
1156            hosts: BTreeMap::new(),
1157        };
1158        let texto = toml::to_string(&arq).unwrap();
1159        assert!(texto.contains("schema_version = 2"));
1160    }
1161
1162    #[test]
1163    fn parse_limite_none() {
1164        assert_eq!(parse_limite_chars("none"), 0);
1165        assert_eq!(parse_limite_chars("0"), 0);
1166        assert_eq!(parse_limite_chars("1000"), 1000);
1167    }
1168
1169    #[test]
1170    fn construir_configuracao_copia_campos() {
1171        let registro = reg_min();
1172        let cfg = construir_configuracao(&registro, None, false);
1173        assert_eq!(cfg.host, "host.example.com");
1174        assert_eq!(cfg.porta, 2222);
1175        assert_eq!(cfg.usuario, "admin");
1176        assert_eq!(cfg.timeout_ms, 60_000);
1177        assert!(cfg.known_hosts_path.is_none());
1178    }
1179
1180    #[test]
1181    #[serial_test::serial]
1182    fn salvar_atomico_roundtrip() {
1183        let tmp = TempDir::new().unwrap();
1184        crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
1185        unsafe {
1186            std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
1187        }
1188        let path = tmp.path().join("config.toml");
1189        let mut arq = ArquivoConfig {
1190            schema_version: 2,
1191            hosts: BTreeMap::new(),
1192        };
1193        arq.hosts.insert("a".into(), reg_min());
1194        salvar(&path, &arq).unwrap();
1195        let lido = carregar(&path).unwrap();
1196        assert_eq!(lido.hosts.len(), 1);
1197        assert_eq!(lido.hosts["a"].senha.expose_secret(), "pass");
1198        #[cfg(unix)]
1199        {
1200            use std::os::unix::fs::PermissionsExt;
1201            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1202            assert_eq!(mode, 0o600);
1203            let lock = path.with_extension("toml.lock");
1204            if lock.exists() {
1205                let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
1206                assert_eq!(lm, 0o600);
1207            }
1208        }
1209        unsafe {
1210            std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
1211        }
1212        crate::secrets::definir_diretorio_config(None);
1213    }
1214
1215    #[test]
1216    fn resolver_caminho_config_com_override_diretorio() {
1217        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
1218        assert_eq!(
1219            resultado.unwrap(),
1220            PathBuf::from("/tmp/test-dir/config.toml")
1221        );
1222    }
1223
1224    #[test]
1225    fn validar_comando_longo() {
1226        let err = validar_comando_tamanho(&"x".repeat(20), 10).unwrap_err();
1227        assert!(matches!(err, ErroSshCli::ComandoMuitoLongo { .. }));
1228    }
1229
1230    #[test]
1231    fn empacotar_sudo_integrado() {
1232        let pack = empacotar_sudo("ls -la", None);
1233        assert_eq!(pack.comando, "sudo -n sh -c 'ls -la'");
1234        assert!(pack.stdin.is_none());
1235    }
1236
1237    #[test]
1238    fn aplicar_overrides_senha() {
1239        let mut v = reg_min();
1240        aplicar_overrides(
1241            &mut v,
1242            Some("nova".into()),
1243            Some("sudo".into()),
1244            None,
1245            Some(1000),
1246            Some("/k".into()),
1247            None,
1248        );
1249        assert_eq!(v.senha.expose_secret(), "nova");
1250        assert_eq!(v.timeout_ms, 1000);
1251        assert_eq!(v.key_path.as_deref(), Some("/k"));
1252    }
1253
1254    #[tokio::test]
1255    async fn executar_sudo_exec_with_client_ok() {
1256        use crate::ssh::cliente::mocks::MockClienteSsh;
1257        use crate::ssh::cliente::SaidaExecucao;
1258        let mut mock = MockClienteSsh::new();
1259        mock.expect_executar_comando().returning(|c, _, stdin| {
1260            assert!(c.contains("sudo -n sh -c"));
1261            assert!(stdin.is_none());
1262            Ok(SaidaExecucao {
1263                stdout: "ok".into(),
1264                stderr: String::new(),
1265                exit_code: Some(0),
1266                truncado_stdout: false,
1267                truncado_stderr: false,
1268                duracao_ms: 1,
1269            })
1270        });
1271        mock.expect_desconectar().returning(|| Ok(()));
1272
1273        let vps = reg_min();
1274        executar_sudo_exec_with_client(&vps, "id", Box::new(mock), FormatoSaida::Text, false)
1275            .await
1276            .unwrap();
1277    }
1278}