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.
259///
260/// Ordem dos parâmetros: password, sudo, su, timeout, key_path, key_passphrase.
261pub(crate) fn aplicar_overrides(
262    vps: &mut VpsRegistro,
263    password_override: Option<String>,
264    sudo_password_override: Option<String>,
265    su_password_override: Option<String>,
266    timeout_override: Option<u64>,
267    key_path_override: Option<String>,
268    key_passphrase_override: Option<String>,
269) {
270    if let Some(pwd) = password_override {
271        vps.senha = SecretString::from(pwd);
272    }
273    if let Some(spwd) = sudo_password_override {
274        vps.senha_sudo = Some(SecretString::from(spwd));
275    }
276    if let Some(sp) = su_password_override {
277        vps.senha_su = Some(SecretString::from(sp));
278    }
279    if let Some(t) = timeout_override {
280        vps.timeout_ms = t;
281    }
282    if let Some(k) = key_path_override {
283        vps.key_path = Some(k);
284    }
285    if let Some(kp) = key_passphrase_override {
286        vps.key_passphrase = Some(SecretString::from(kp));
287    }
288}
289
290fn validar_comando_tamanho(comando: &str, max_command_chars: usize) -> ResultadoSshCli<()> {
291    let lim = limite_efetivo(max_command_chars);
292    let len = comando.chars().count();
293    if len > lim {
294        return Err(ErroSshCli::ComandoMuitoLongo {
295            max: max_command_chars,
296            len,
297        });
298    }
299    if comando.trim().is_empty() {
300        return Err(ErroSshCli::ArgumentoInvalido("comando vazio".to_string()));
301    }
302    Ok(())
303}
304
305/// Dispatcher dos subcomandos `vps`.
306pub async fn executar_comando_vps(
307    acao: AcaoVps,
308    config_override: Option<PathBuf>,
309    formato: FormatoSaida,
310) -> Result<()> {
311    let caminho = resolver_caminho_config(config_override.clone())?;
312
313    match acao {
314        AcaoVps::Add {
315            name,
316            host,
317            port,
318            user,
319            password,
320            password_stdin,
321            key,
322            key_passphrase,
323            timeout,
324            max_command_chars,
325            max_output_chars,
326            max_chars,
327            sudo_password,
328            sudo_password_stdin,
329            su_password,
330            su_password_stdin,
331            disable_sudo,
332            check,
333        } => {
334            // GAP-SSH-VAL-001: validar na fronteira de escrita.
335            let name = crate::paths::validar_e_normalizar(&name)
336                .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("nome de VPS inválido: {e}")))?;
337            let mut arquivo = carregar(&caminho)?;
338            if arquivo.hosts.contains_key(&name) {
339                return Err(ErroSshCli::VpsDuplicada(name).into());
340            }
341            if password_stdin && (sudo_password_stdin || su_password_stdin) {
342                return Err(ErroSshCli::ArgumentoInvalido(
343                    "apenas um --*-stdin por invocação one-shot; rode vps edit para sudo/su".into(),
344                )
345                .into());
346            }
347            let senha = if password_stdin {
348                SecretString::from(ler_segredo_stdin()?)
349            } else {
350                SecretString::from(password.unwrap_or_default())
351            };
352            let sudo_s = if sudo_password_stdin {
353                Some(SecretString::from(ler_segredo_stdin()?))
354            } else {
355                sudo_password.map(SecretString::from)
356            };
357            let su_s = if su_password_stdin {
358                Some(SecretString::from(ler_segredo_stdin()?))
359            } else {
360                su_password.map(SecretString::from)
361            };
362            if let Some(ref k) = key {
363                validar_key_path_existe(k)?;
364            }
365            // max_chars legado → command se max_command não veio explicitamente
366            let max_cmd = max_command_chars
367                .as_deref()
368                .or(max_chars.as_deref())
369                .map(parse_limite_chars)
370                .unwrap_or(modelo::MAX_COMMAND_CHARS_PADRAO);
371            let max_out = max_output_chars
372                .as_deref()
373                .map(parse_limite_chars)
374                .unwrap_or(modelo::MAX_OUTPUT_CHARS_PADRAO);
375            let registro = VpsRegistro::novo(
376                name.clone(),
377                host,
378                port,
379                user,
380                senha,
381                key,
382                key_passphrase.map(SecretString::from),
383                Some(timeout),
384                Some(max_cmd),
385                Some(max_out),
386                sudo_s,
387                su_s,
388                disable_sudo,
389            );
390            // GAP-SSH-VAL-002 / VAL-003: domínio completo no write-path.
391            registro.validar().map_err(ErroSshCli::ArgumentoInvalido)?;
392            arquivo.hosts.insert(name.clone(), registro);
393            arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
394            salvar(&caminho, &arquivo)?;
395            crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
396            if check {
397                executar_health_check(
398                    Some(&name),
399                    config_override,
400                    formato,
401                    false,
402                    None,
403                    None,
404                    None,
405                    None,
406                    false,
407                )
408                .await?;
409            }
410        }
411        AcaoVps::List { json } => {
412            let arquivo = carregar(&caminho)?;
413            let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
414            // GAP-SSH-IO-001: respeitar formato global.
415            if usar_json(json, formato) {
416                crate::output::imprimir_lista_json(&registros);
417            } else {
418                crate::output::imprimir_lista_texto(&registros);
419            }
420        }
421        AcaoVps::Remove { nome } => {
422            let mut arquivo = carregar(&caminho)?;
423            if arquivo.hosts.remove(&nome).is_none() {
424                return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
425            }
426            salvar(&caminho, &arquivo)?;
427            // GAP-SSH-STATE-001: limpar active órfão.
428            limpar_active_se_nome(&caminho, &nome)?;
429            crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
430        }
431        AcaoVps::Edit {
432            nome,
433            host,
434            port,
435            user,
436            password,
437            password_stdin,
438            key,
439            key_passphrase,
440            timeout,
441            max_command_chars,
442            max_output_chars,
443            max_chars,
444            sudo_password,
445            sudo_password_stdin,
446            su_password,
447            su_password_stdin,
448            disable_sudo,
449        } => {
450            let mut arquivo = carregar(&caminho)?;
451            let registro = arquivo
452                .hosts
453                .get_mut(&nome)
454                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
455            if let Some(h) = host {
456                registro.host = h;
457            }
458            if let Some(p) = port {
459                registro.porta = p;
460            }
461            if let Some(u) = user {
462                registro.usuario = u;
463            }
464            if password_stdin {
465                registro.senha = SecretString::from(ler_segredo_stdin()?);
466            } else if let Some(pw) = password {
467                registro.senha = SecretString::from(pw);
468            }
469            if let Some(k) = key {
470                validar_key_path_existe(&k)?;
471                registro.key_path = Some(k);
472            }
473            if let Some(kp) = key_passphrase {
474                registro.key_passphrase = Some(SecretString::from(kp));
475            }
476            if let Some(t) = timeout {
477                registro.timeout_ms = t;
478            }
479            if let Some(m) = max_command_chars.or(max_chars) {
480                registro.max_command_chars = parse_limite_chars(&m);
481            }
482            if let Some(m) = max_output_chars {
483                registro.max_output_chars = parse_limite_chars(&m);
484            }
485            if sudo_password_stdin {
486                registro.senha_sudo = Some(SecretString::from(ler_segredo_stdin()?));
487            } else if let Some(sp) = sudo_password {
488                registro.senha_sudo = Some(SecretString::from(sp));
489            }
490            if su_password_stdin {
491                registro.senha_su = Some(SecretString::from(ler_segredo_stdin()?));
492            } else if let Some(sp) = su_password {
493                registro.senha_su = Some(SecretString::from(sp));
494            }
495            if let Some(d) = disable_sudo {
496                registro.disable_sudo = d;
497            }
498            registro.validar().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.
1086/// Health-check one-shot com paridade de auth (GAP-SSH-CLI-006) e TOFU (M1).
1087#[allow(clippy::too_many_arguments)]
1088pub async fn executar_health_check(
1089    vps_nome: Option<&str>,
1090    config_override: Option<PathBuf>,
1091    formato: FormatoSaida,
1092    json_local: bool,
1093    password_override: Option<String>,
1094    timeout_override: Option<u64>,
1095    key_override: Option<String>,
1096    key_passphrase_override: Option<String>,
1097    replace_host_key: bool,
1098) -> Result<()> {
1099    // M2: --json local ou formato global → envelope de erro JSON em falha.
1100    if json_local || formato == FormatoSaida::Json {
1101        crate::output::definir_json_erros(true);
1102    }
1103    if crate::signals::cancelado() || crate::signals::terminado() {
1104        return Err(anyhow::anyhow!(crate::i18n::t(
1105            crate::i18n::Mensagem::OperacaoCancelada
1106        )));
1107    }
1108    let nome_resolvido: String = match vps_nome {
1109        Some(n) => n.to_string(),
1110        None => {
1111            // GAP-SSH-EXIT-002: tipado → exit 66 (não anyhow string → exit 1).
1112            let ativa = ler_vps_ativa(config_override.clone())?;
1113            ativa.ok_or(ErroSshCli::NenhumaVpsAtiva)?
1114        }
1115    };
1116    let caminho = resolver_caminho_config(config_override)?;
1117    let arquivo = carregar(&caminho)?;
1118    let vps_base = arquivo
1119        .hosts
1120        .get(&nome_resolvido)
1121        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
1122
1123    let mut vps = vps_base.clone();
1124    // GAP-SSH-CLI-004: --timeout; GAP-SSH-CLI-006: key + passphrase.
1125    // Ordem: password, sudo, su, timeout, key_path, key_passphrase.
1126    aplicar_overrides(
1127        &mut vps,
1128        password_override,
1129        None,
1130        None,
1131        timeout_override,
1132        key_override,
1133        key_passphrase_override,
1134    );
1135    // M1: honra --replace-host-key global (paridade exec/scp/tunnel).
1136    let cfg = construir_configuracao(&vps, Some(&caminho), replace_host_key);
1137    let inicio = std::time::Instant::now();
1138    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
1139    let latencia_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1140    cliente.desconectar().await?;
1141
1142    if usar_json(json_local, formato) {
1143        output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
1144    } else {
1145        output::imprimir_health_check(&nome_resolvido, latencia_ms);
1146    }
1147    Ok(())
1148}
1149
1150#[cfg(test)]
1151mod testes {
1152    use super::*;
1153    use secrecy::ExposeSecret;
1154    use tempfile::TempDir;
1155
1156    fn reg_min() -> VpsRegistro {
1157        VpsRegistro::novo(
1158            "srv".into(),
1159            "host.example.com".into(),
1160            2222,
1161            "admin".into(),
1162            SecretString::from("pass".to_string()),
1163            None,
1164            None,
1165            Some(60_000),
1166            Some(1_000),
1167            Some(50_000),
1168            None,
1169            None,
1170            false,
1171        )
1172    }
1173
1174    #[test]
1175    fn arquivo_vazio_serializa_com_schema() {
1176        let arq = ArquivoConfig {
1177            schema_version: modelo::SCHEMA_VERSION_ATUAL,
1178            hosts: BTreeMap::new(),
1179        };
1180        let texto = toml::to_string(&arq).unwrap();
1181        assert!(texto.contains("schema_version = 2"));
1182    }
1183
1184    #[test]
1185    fn parse_limite_none() {
1186        assert_eq!(parse_limite_chars("none"), 0);
1187        assert_eq!(parse_limite_chars("0"), 0);
1188        assert_eq!(parse_limite_chars("1000"), 1000);
1189    }
1190
1191    #[test]
1192    fn construir_configuracao_copia_campos() {
1193        let registro = reg_min();
1194        let cfg = construir_configuracao(&registro, None, false);
1195        assert_eq!(cfg.host, "host.example.com");
1196        assert_eq!(cfg.porta, 2222);
1197        assert_eq!(cfg.usuario, "admin");
1198        assert_eq!(cfg.timeout_ms, 60_000);
1199        assert!(cfg.known_hosts_path.is_none());
1200    }
1201
1202    #[test]
1203    #[serial_test::serial]
1204    fn salvar_atomico_roundtrip() {
1205        let tmp = TempDir::new().unwrap();
1206        crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
1207        unsafe {
1208            std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
1209        }
1210        let path = tmp.path().join("config.toml");
1211        let mut arq = ArquivoConfig {
1212            schema_version: 2,
1213            hosts: BTreeMap::new(),
1214        };
1215        arq.hosts.insert("a".into(), reg_min());
1216        salvar(&path, &arq).unwrap();
1217        let lido = carregar(&path).unwrap();
1218        assert_eq!(lido.hosts.len(), 1);
1219        assert_eq!(lido.hosts["a"].senha.expose_secret(), "pass");
1220        #[cfg(unix)]
1221        {
1222            use std::os::unix::fs::PermissionsExt;
1223            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1224            assert_eq!(mode, 0o600);
1225            let lock = path.with_extension("toml.lock");
1226            if lock.exists() {
1227                let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
1228                assert_eq!(lm, 0o600);
1229            }
1230        }
1231        unsafe {
1232            std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
1233        }
1234        crate::secrets::definir_diretorio_config(None);
1235    }
1236
1237    #[test]
1238    fn resolver_caminho_config_com_override_diretorio() {
1239        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
1240        assert_eq!(
1241            resultado.unwrap(),
1242            PathBuf::from("/tmp/test-dir/config.toml")
1243        );
1244    }
1245
1246    #[test]
1247    fn validar_comando_longo() {
1248        let err = validar_comando_tamanho(&"x".repeat(20), 10).unwrap_err();
1249        assert!(matches!(err, ErroSshCli::ComandoMuitoLongo { .. }));
1250    }
1251
1252    #[test]
1253    fn empacotar_sudo_integrado() {
1254        let pack = empacotar_sudo("ls -la", None);
1255        assert_eq!(pack.comando, "sudo -n sh -c 'ls -la'");
1256        assert!(pack.stdin.is_none());
1257    }
1258
1259    #[test]
1260    fn aplicar_overrides_senha() {
1261        let mut v = reg_min();
1262        aplicar_overrides(
1263            &mut v,
1264            Some("nova".into()),
1265            Some("sudo".into()),
1266            None,
1267            Some(1000),
1268            Some("/k".into()),
1269            None,
1270        );
1271        assert_eq!(v.senha.expose_secret(), "nova");
1272        assert_eq!(v.timeout_ms, 1000);
1273        assert_eq!(v.key_path.as_deref(), Some("/k"));
1274    }
1275
1276    #[tokio::test]
1277    async fn executar_sudo_exec_with_client_ok() {
1278        use crate::ssh::cliente::mocks::MockClienteSsh;
1279        use crate::ssh::cliente::SaidaExecucao;
1280        let mut mock = MockClienteSsh::new();
1281        mock.expect_executar_comando().returning(|c, _, stdin| {
1282            assert!(c.contains("sudo -n sh -c"));
1283            assert!(stdin.is_none());
1284            Ok(SaidaExecucao {
1285                stdout: "ok".into(),
1286                stderr: String::new(),
1287                exit_code: Some(0),
1288                truncado_stdout: false,
1289                truncado_stderr: false,
1290                duracao_ms: 1,
1291            })
1292        });
1293        mock.expect_desconectar().returning(|| Ok(()));
1294
1295        let vps = reg_min();
1296        executar_sudo_exec_with_client(&vps, "id", Box::new(mock), FormatoSaida::Text, false)
1297            .await
1298            .unwrap();
1299    }
1300}