Skip to main content

ssh_cli/
output.rs

1//! Único módulo autorizado a emitir output em stdout para CRUD de VPS.
2//!
3//! Este módulo centraliza TODA formatação de CRUD: texto e JSON.
4//!
5//! Logs (tracing) vão para stderr, gerenciados por `tracing-subscriber`.
6
7use crate::mascaramento::mascarar;
8use crate::ssh::SaidaExecucao;
9use crate::vps::modelo::VpsRegistro;
10use secrecy::ExposeSecret;
11use serde_json::json;
12use std::io::{self, Write};
13use std::sync::atomic::{AtomicBool, Ordering};
14
15/// Flag global de `--quiet` (suprime mensagens humanas em stdout).
16static QUIET: AtomicBool = AtomicBool::new(false);
17
18/// Quando true, erros em `main` usam envelope JSON em stderr (IO-003).
19static JSON_ERROS: AtomicBool = AtomicBool::new(false);
20
21/// Define se a CLI está em modo quiet (GAP-SSH-IO-004).
22pub fn definir_quiet(quiet: bool) {
23    QUIET.store(quiet, Ordering::SeqCst);
24}
25
26/// Define se erros devem sair como envelope JSON em stderr.
27pub fn definir_json_erros(json: bool) {
28    JSON_ERROS.store(json, Ordering::SeqCst);
29}
30
31/// Retorna se quiet está ativo.
32#[must_use]
33pub fn esta_quiet() -> bool {
34    QUIET.load(Ordering::SeqCst)
35}
36
37/// Retorna se erros devem ser envelope JSON.
38#[must_use]
39pub fn quer_json_erros() -> bool {
40    JSON_ERROS.load(Ordering::SeqCst)
41}
42
43/// Escreve uma linha em stdout garantindo LF puro (nunca CRLF).
44///
45/// Em quiet, ainda escreve (dados/paths são API). Preferir `imprimir_sucesso`
46/// para mensagens humanas.
47///
48/// # Erros
49/// Retorna erro se o I/O em stdout falhar.
50pub fn escrever_linha(conteudo: &str) -> io::Result<()> {
51    let stdout = io::stdout();
52    let mut handle = stdout.lock();
53    handle.write_all(conteudo.as_bytes())?;
54    handle.write_all(b"\n")?;
55    handle.flush()?;
56    Ok(())
57}
58
59/// Imprime mensagem de sucesso em texto para humanos (silenciada com `--quiet`).
60pub fn imprimir_sucesso(mensagem: &str) {
61    if esta_quiet() {
62        return;
63    }
64    println!("{mensagem}");
65}
66
67/// Banner humano (tunnel etc.): só Text+TTY+!quiet+!JSON erros (GAP-SSH-IO-006).
68///
69/// Em pipes/agentes, progresso vai para `tracing` (stderr), nunca para stdout.
70pub fn imprimir_banner_humano(mensagem: &str) {
71    if esta_quiet() || quer_json_erros() {
72        return;
73    }
74    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
75        return;
76    }
77    if std::env::var_os("SSH_CLI_FORCE_TEXT").is_none() {
78        // Sem FORCE_TEXT, non-TTY já retornou; TTY humano ok.
79    }
80    println!("{mensagem}");
81}
82
83/// Imprime mensagem de erro em stderr (para humanos).
84pub fn imprimir_erro(mensagem: &str) {
85    eprintln!("{mensagem}");
86}
87
88/// Emite envelope JSON de erro em stderr (GAP-SSH-IO-003).
89pub fn imprimir_erro_envelope(
90    exit_code: i32,
91    message: &str,
92    remote_exit_code: Option<i32>,
93) -> io::Result<()> {
94    let mut v = json!({
95        "exit_code": exit_code,
96        "message": message,
97    });
98    if let Some(r) = remote_exit_code {
99        v["remote_exit_code"] = json!(r);
100    }
101    let s = serde_json::to_string(&v).unwrap_or_else(|_| {
102        format!(r#"{{"exit_code":{exit_code},"message":"erro de serialização"}}"#)
103    });
104    let mut err = io::stderr().lock();
105    err.write_all(s.as_bytes())?;
106    err.write_all(b"\n")?;
107    err.flush()?;
108    Ok(())
109}
110
111/// Imprime valor JSON pretty em stdout (dados de API; respeita quiet=false sempre).
112pub fn imprimir_json_value(v: &serde_json::Value) -> io::Result<()> {
113    let s = serde_json::to_string_pretty(v).map_err(io::Error::other)?;
114    escrever_linha(&s)
115}
116
117/// Imprime relatório doctor em texto (GAP-SSH-IO-005).
118#[allow(clippy::too_many_arguments)]
119pub fn imprimir_doctor_texto(
120    camada: &str,
121    config_path: &str,
122    existe: bool,
123    perms: &str,
124    schema_version: u32,
125    hosts: usize,
126    known_hosts: &str,
127    active_file: &str,
128    secrets_at_rest: &str,
129    secrets_key_source: &str,
130    secrets_key_file: &str,
131    plaintext_opt_out: bool,
132) {
133    if esta_quiet() {
134        return;
135    }
136    println!("Camada vencedora: {camada}");
137    println!("Config path:      {config_path}");
138    println!("Existe:           {existe}");
139    println!("Permissões:       {perms}");
140    println!("Schema:           {schema_version}");
141    println!("Hosts:            {hosts}");
142    println!("known_hosts:      {known_hosts}");
143    println!("active file:      {active_file}");
144    println!("Secrets at-rest:  {secrets_at_rest} (key source: {secrets_key_source})");
145    println!("Secrets key file: {secrets_key_file}");
146    println!(
147        "Plaintext opt-out: {}",
148        if plaintext_opt_out { "yes" } else { "no" }
149    );
150    println!("Telemetria:       desabilitada");
151}
152
153/// Imprime lista de VPS em formato texto (mascarado).
154pub fn imprimir_lista_texto(registros: &[VpsRegistro]) {
155    if esta_quiet() {
156        return;
157    }
158    if registros.is_empty() {
159        println!(
160            "{}",
161            crate::i18n::t(crate::i18n::Mensagem::VpsRegistroVazio)
162        );
163        return;
164    }
165
166    println!(
167        "{:<20} {:<30} {:<6} {:<15} {:<20}",
168        "NOME", "HOST", "PORTA", "USUÁRIO", "SENHA"
169    );
170    for r in registros {
171        println!(
172            "{:<20} {:<30} {:<6} {:<15} {:<20}",
173            r.nome,
174            r.host,
175            r.porta,
176            r.usuario,
177            mascarar(r.senha.expose_secret())
178        );
179    }
180}
181
182/// Imprime lista de VPS em formato JSON (mascarado).
183pub fn imprimir_lista_json(registros: &[VpsRegistro]) {
184    let lista: Vec<_> = registros.iter().map(registro_para_json_mascarado).collect();
185    match serde_json::to_string_pretty(&lista) {
186        Ok(s) => println!("{s}"),
187        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
188    }
189}
190
191/// Imprime detalhes de UMA VPS em texto (mascarado).
192pub fn imprimir_detalhes_texto(r: &VpsRegistro) {
193    if esta_quiet() {
194        return;
195    }
196    println!("Nome:           {}", r.nome);
197    println!("Host:           {}", r.host);
198    println!("Porta:          {}", r.porta);
199    println!("Usuário:        {}", r.usuario);
200    // GAP-SSH-JSON-001: senha vazia (key-only) não finge valor mascarado.
201    println!(
202        "Senha:          {}",
203        if r.senha.expose_secret().is_empty() {
204            "(não definida)".to_string()
205        } else {
206            mascarar(r.senha.expose_secret())
207        }
208    );
209    println!(
210        "Key path:       {}",
211        r.key_path.as_deref().unwrap_or("(não definida)")
212    );
213    println!(
214        "Senha sudo:     {}",
215        r.senha_sudo
216            .as_ref()
217            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
218    );
219    println!(
220        "Senha su:       {}",
221        r.senha_su
222            .as_ref()
223            .map_or_else(|| "(não definida)".into(), |s| mascarar(s.expose_secret()))
224    );
225    println!("Timeout (ms):   {}", r.timeout_ms);
226    println!("Max cmd chars:  {}", r.max_command_chars);
227    println!("Max out chars:  {}", r.max_output_chars);
228    println!("Disable sudo:   {}", r.disable_sudo);
229    println!("Schema version: {}", r.schema_version);
230    println!("Adicionado em:  {}", r.adicionado_em);
231}
232
233/// Imprime detalhes de UMA VPS em JSON (mascarado).
234pub fn imprimir_detalhes_json(r: &VpsRegistro) {
235    let v = registro_para_json_mascarado(r);
236    match serde_json::to_string_pretty(&v) {
237        Ok(s) => println!("{s}"),
238        Err(erro) => eprintln!("erro ao serializar JSON: {erro}"),
239    }
240}
241
242fn registro_para_json_mascarado(r: &VpsRegistro) -> serde_json::Value {
243    // GAP-SSH-JSON-001: password ausente/vazio → null (como sudo/su); presente → "***".
244    let password = if r.senha.expose_secret().is_empty() {
245        json!(null)
246    } else {
247        json!(mascarar(r.senha.expose_secret()))
248    };
249    json!({
250        "name": r.nome,
251        "host": r.host,
252        "port": r.porta,
253        "user": r.usuario,
254        "password": password,
255        "key_path": r.key_path,
256        "key_passphrase": r.key_passphrase.as_ref().map(|s| mascarar(s.expose_secret())),
257        "sudo_password": r.senha_sudo.as_ref().map(|s| mascarar(s.expose_secret())),
258        "su_password": r.senha_su.as_ref().map(|s| mascarar(s.expose_secret())),
259        "timeout_ms": r.timeout_ms,
260        "max_command_chars": r.max_command_chars,
261        "max_output_chars": r.max_output_chars,
262        "disable_sudo": r.disable_sudo,
263        "schema_version": r.schema_version,
264        "added_at": r.adicionado_em,
265    })
266}
267
268/// Imprime stdout/stderr de execução de comando SSH.
269///
270/// Formato:
271/// ```text
272/// --- stdout ---
273/// <stdout>
274/// --- stderr ---
275/// <stderr>
276/// --- exit code: <code> (<duracao_ms>ms) ---
277/// ```
278pub fn imprimir_saida_execucao(saida: &SaidaExecucao) {
279    println!("--- stdout ---");
280    if saida.stdout.is_empty() {
281        println!("(vazio)");
282    } else {
283        println!("{}", saida.stdout);
284    }
285    println!("--- stderr ---");
286    if saida.stderr.is_empty() {
287        println!("(vazio)");
288    } else {
289        println!("{}", saida.stderr);
290    }
291    let code_str = saida
292        .exit_code
293        .map(|c| c.to_string())
294        .unwrap_or_else(|| "N/A".to_string());
295    println!("--- exit code: {} ({}ms) ---", code_str, saida.duracao_ms);
296    if saida.truncado_stdout {
297        println!("(stdout foi truncado)");
298    }
299    if saida.truncado_stderr {
300        println!("(stderr foi truncado)");
301    }
302}
303
304/// Imprime stdout/stderr de execução de comando SSH em formato JSON.
305pub fn imprimir_saida_execucao_json(saida: &SaidaExecucao) {
306    let v = json!({
307        "stdout": saida.stdout,
308        "stderr": saida.stderr,
309        "exit_code": saida.exit_code,
310        "truncated_stdout": saida.truncado_stdout,
311        "truncated_stderr": saida.truncado_stderr,
312        "duration_ms": saida.duracao_ms,
313    });
314    match serde_json::to_string_pretty(&v) {
315        Ok(s) => println!("{s}"),
316        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
317    }
318}
319
320/// Imprime resultado de health-check em formato texto.
321pub fn imprimir_health_check(nome: &str, latencia_ms: u64) {
322    if esta_quiet() {
323        return;
324    }
325    println!(
326        "{}",
327        crate::i18n::t(crate::i18n::Mensagem::HealthCheckOk {
328            nome: nome.to_string(),
329        })
330    );
331    println!("  latência: {latencia_ms}ms");
332}
333
334/// Imprime resultado de health-check em formato JSON.
335pub fn imprimir_health_check_json(nome: &str, latencia_ms: u64) {
336    let v = json!({
337        "name": nome,
338        "status": "ok",
339        "latency_ms": latencia_ms,
340    });
341    match serde_json::to_string_pretty(&v) {
342        Ok(s) => println!("{s}"),
343        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
344    }
345}
346
347#[cfg(test)]
348mod testes {
349    use super::*;
350    use crate::ssh::SaidaExecucao;
351    use crate::vps::modelo::VpsRegistro;
352    use secrecy::SecretString;
353
354    fn registro_teste() -> VpsRegistro {
355        VpsRegistro::novo(
356            "vps-teste".into(),
357            "1.2.3.4".into(),
358            22,
359            "root".into(),
360            SecretString::from("senha-super-secreta".to_string()),
361            None,
362            None,
363            Some(5000),
364            Some(1000),
365            Some(1000),
366            Some(SecretString::from("sudo-password-longa-aqui".to_string())),
367            None,
368            false,
369        )
370    }
371
372    #[test]
373    fn registro_para_json_mascarado_contem_campos_obrigatorios() {
374        let r = registro_teste();
375        let json = registro_para_json_mascarado(&r);
376        assert_eq!(json["name"], "vps-teste");
377        assert_eq!(json["host"], "1.2.3.4");
378        assert_eq!(json["port"], 22);
379        assert_eq!(json["user"], "root");
380        assert_eq!(json["password"].as_str().unwrap(), "***");
381        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
382        assert!(json["su_password"].is_null());
383        assert_eq!(json["timeout_ms"], 5000);
384        assert_eq!(json["max_command_chars"], 1000);
385        assert_eq!(json["max_output_chars"], 1000);
386        assert_eq!(json["schema_version"], 2);
387    }
388
389    #[test]
390    fn registro_para_json_mascarado_senha_sudo_nula_quando_nao_definida() {
391        let mut r = registro_teste();
392        r.senha_sudo = None;
393        let json = registro_para_json_mascarado(&r);
394        assert!(json["sudo_password"].is_null());
395    }
396
397    #[test]
398    fn registro_para_json_mascarado_su_password_presente() {
399        let mut r = registro_teste();
400        r.senha_su = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
401        let json = registro_para_json_mascarado(&r);
402        assert_eq!(json["su_password"].as_str().unwrap(), "***");
403    }
404
405    #[test]
406    fn registro_para_json_mascarado_password_null_quando_vazio() {
407        let mut r = registro_teste();
408        r.senha = SecretString::from(String::new());
409        let json = registro_para_json_mascarado(&r);
410        assert!(json["password"].is_null());
411    }
412
413    #[test]
414    fn escribir_linha_ok() {
415        let resultado = escrever_linha("teste de escrita");
416        assert!(resultado.is_ok());
417    }
418
419    #[test]
420    fn escribir_linha_com_caracteres_especiais() {
421        let resultado = escrever_linha("linha com \t tab e \"aspas\"");
422        assert!(resultado.is_ok());
423    }
424
425    #[test]
426    fn salida_execucao_completa_formatada() {
427        let saida = SaidaExecucao {
428            stdout: "output do comando".to_string(),
429            stderr: "erro do comando".to_string(),
430            exit_code: Some(0),
431            truncado_stdout: false,
432            truncado_stderr: false,
433            duracao_ms: 150,
434        };
435        let resultado = escrever_linha(&format!(
436            "stdout: {}, stderr: {}, exit: {:?}",
437            saida.stdout, saida.stderr, saida.exit_code
438        ));
439        assert!(resultado.is_ok());
440    }
441
442    #[test]
443    fn salida_execucao_sem_exit_code() {
444        let saida = SaidaExecucao {
445            stdout: "".to_string(),
446            stderr: "".to_string(),
447            exit_code: None,
448            truncado_stdout: false,
449            truncado_stderr: false,
450            duracao_ms: 0,
451        };
452        let code_str = saida
453            .exit_code
454            .map(|c| c.to_string())
455            .unwrap_or_else(|| "N/A".to_string());
456        assert_eq!(code_str, "N/A");
457    }
458
459    #[test]
460    fn vps_registro_debug_nao_expoe_senha() {
461        let r = registro_teste();
462        let json = registro_para_json_mascarado(&r);
463        let json_str = serde_json::to_string(&json).unwrap();
464        assert!(!json_str.contains("senha-super-secreta"));
465        assert!(!json_str.contains("sudo-password-longa-aqui"));
466    }
467
468    #[test]
469    fn salida_execucao_truncada_mostra_aviso() {
470        let saida = SaidaExecucao {
471            stdout: "output".to_string(),
472            stderr: "erro".to_string(),
473            exit_code: Some(1),
474            truncado_stdout: true,
475            truncado_stderr: true,
476            duracao_ms: 100,
477        };
478        assert!(saida.truncado_stdout);
479        assert!(saida.truncado_stderr);
480    }
481
482    #[test]
483    fn salida_execucao_com_exit_code_numerico() {
484        let saida = SaidaExecucao {
485            stdout: "".to_string(),
486            stderr: "".to_string(),
487            exit_code: Some(127),
488            truncado_stdout: false,
489            truncado_stderr: false,
490            duracao_ms: 0,
491        };
492        let code_str = saida
493            .exit_code
494            .map(|c| c.to_string())
495            .unwrap_or_else(|| "N/A".to_string());
496        assert_eq!(code_str, "127");
497    }
498
499    #[test]
500    fn escribir_linha_string_vazia() {
501        let resultado = escrever_linha("");
502        assert!(resultado.is_ok());
503    }
504
505    #[test]
506    fn escribir_linha_com_unicode_brasileiro() {
507        let resultado = escrever_linha("ação você está Itaú");
508        assert!(resultado.is_ok());
509    }
510
511    #[test]
512    fn escribir_linha_com_emojis() {
513        let resultado = escrever_linha("texto com 🚀 e 🔐");
514        assert!(resultado.is_ok());
515    }
516
517    #[test]
518    fn escribir_linha_com_newlines() {
519        let resultado = escrever_linha("linha1\nlinha2\nlinha3");
520        assert!(resultado.is_ok());
521    }
522
523    #[test]
524    fn escribir_linha_longo_texto() {
525        let texto_longo = "a".repeat(10000);
526        let resultado = escrever_linha(&texto_longo);
527        assert!(resultado.is_ok());
528    }
529
530    #[test]
531    fn registro_para_json_mascarado_com_senha_curta_mascara_com_asteriscos() {
532        let mut r = registro_teste();
533        r.senha = SecretString::from("curta".to_string());
534        let json = registro_para_json_mascarado(&r);
535        let senha_str = json["password"].as_str().unwrap();
536        assert_eq!(senha_str, "***");
537    }
538
539    #[test]
540    fn registro_para_json_mascarado_com_sudo_e_su_definidos() {
541        let mut r = registro_teste();
542        r.senha_sudo = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
543        r.senha_su = Some(SecretString::from("su-pass-longa-aqui".to_string()));
544        let json = registro_para_json_mascarado(&r);
545        assert!(!json["sudo_password"].is_null());
546        assert!(!json["su_password"].is_null());
547        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
548        assert_eq!(json["su_password"].as_str().unwrap(), "***");
549    }
550
551    #[test]
552    fn saida_execucao_formatacao_completa() {
553        let saida = SaidaExecucao {
554            stdout: "comando executado".to_string(),
555            stderr: "aviso harmless".to_string(),
556            exit_code: Some(0),
557            truncado_stdout: false,
558            truncado_stderr: false,
559            duracao_ms: 1000,
560        };
561        assert_eq!(saida.stdout, "comando executado");
562        assert_eq!(saida.stderr, "aviso harmless");
563        assert_eq!(saida.exit_code, Some(0));
564        assert_eq!(saida.duracao_ms, 1000);
565        assert!(!saida.truncado_stdout);
566        assert!(!saida.truncado_stderr);
567    }
568
569    #[test]
570    fn saida_execucao_sem_stderr() {
571        let saida = SaidaExecucao {
572            stdout: "ok".to_string(),
573            stderr: String::new(),
574            exit_code: Some(0),
575            truncado_stdout: false,
576            truncado_stderr: false,
577            duracao_ms: 50,
578        };
579        assert!(saida.stderr.is_empty());
580    }
581
582    #[test]
583    fn saida_execucao_com_sinal_em_vez_de_exit_code() {
584        let saida = SaidaExecucao {
585            stdout: String::new(),
586            stderr: "signal received".to_string(),
587            exit_code: None,
588            truncado_stdout: false,
589            truncado_stderr: false,
590            duracao_ms: 5000,
591        };
592        assert!(saida.exit_code.is_none());
593    }
594
595    #[test]
596    fn saida_execucao_json_contem_campos_obrigatorios() {
597        let saida = SaidaExecucao {
598            stdout: "output".to_string(),
599            stderr: "erro".to_string(),
600            exit_code: Some(0),
601            truncado_stdout: false,
602            truncado_stderr: false,
603            duracao_ms: 100,
604        };
605        imprimir_saida_execucao_json(&saida);
606    }
607}