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/// Imprime resultado de transferência SCP em JSON (GAP-SSH-IO-007 / SCP-021 / IO-009).
348pub fn imprimir_transferencia_json(
349    direction: &str,
350    vps: &str,
351    local: &str,
352    remote: &str,
353    bytes: u64,
354    duration_ms: u64,
355) {
356    // GAP-SSH-IO-009: discriminador de evento (paridade com tunnel_listening).
357    let v = json!({
358        "ok": true,
359        "event": "scp-transfer",
360        "direction": direction,
361        "vps": vps,
362        "local": local,
363        "remote": remote,
364        "bytes": bytes,
365        "duration_ms": duration_ms,
366    });
367    match serde_json::to_string_pretty(&v) {
368        Ok(s) => {
369            let _ = escrever_linha(&s);
370        }
371        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
372    }
373}
374
375/// Evento JSON quando o listener local do tunnel sobe (GAP-SSH-IO-008).
376pub fn imprimir_tunnel_listening_json(
377    vps: &str,
378    local_port: u16,
379    remote_host: &str,
380    remote_port: u16,
381    timeout_ms: u64,
382) {
383    let v = json!({
384        "ok": true,
385        "event": "tunnel_listening",
386        "vps": vps,
387        "local_port": local_port,
388        "remote_host": remote_host,
389        "remote_port": remote_port,
390        "timeout_ms": timeout_ms,
391    });
392    match serde_json::to_string_pretty(&v) {
393        Ok(s) => {
394            let _ = escrever_linha(&s);
395        }
396        Err(e) => eprintln!("erro ao serializar JSON: {e}"),
397    }
398}
399
400#[cfg(test)]
401mod testes {
402    use super::*;
403    use crate::ssh::SaidaExecucao;
404    use crate::vps::modelo::VpsRegistro;
405    use secrecy::SecretString;
406
407    fn registro_teste() -> VpsRegistro {
408        VpsRegistro::novo(
409            "vps-teste".into(),
410            "1.2.3.4".into(),
411            22,
412            "root".into(),
413            SecretString::from("senha-super-secreta".to_string()),
414            None,
415            None,
416            Some(5000),
417            Some(1000),
418            Some(1000),
419            Some(SecretString::from("sudo-password-longa-aqui".to_string())),
420            None,
421            false,
422        )
423    }
424
425    #[test]
426    fn registro_para_json_mascarado_contem_campos_obrigatorios() {
427        let r = registro_teste();
428        let json = registro_para_json_mascarado(&r);
429        assert_eq!(json["name"], "vps-teste");
430        assert_eq!(json["host"], "1.2.3.4");
431        assert_eq!(json["port"], 22);
432        assert_eq!(json["user"], "root");
433        assert_eq!(json["password"].as_str().unwrap(), "***");
434        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
435        assert!(json["su_password"].is_null());
436        assert_eq!(json["timeout_ms"], 5000);
437        assert_eq!(json["max_command_chars"], 1000);
438        assert_eq!(json["max_output_chars"], 1000);
439        assert_eq!(json["schema_version"], 2);
440    }
441
442    #[test]
443    fn registro_para_json_mascarado_senha_sudo_nula_quando_nao_definida() {
444        let mut r = registro_teste();
445        r.senha_sudo = None;
446        let json = registro_para_json_mascarado(&r);
447        assert!(json["sudo_password"].is_null());
448    }
449
450    #[test]
451    fn registro_para_json_mascarado_su_password_presente() {
452        let mut r = registro_teste();
453        r.senha_su = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
454        let json = registro_para_json_mascarado(&r);
455        assert_eq!(json["su_password"].as_str().unwrap(), "***");
456    }
457
458    #[test]
459    fn registro_para_json_mascarado_password_null_quando_vazio() {
460        let mut r = registro_teste();
461        r.senha = SecretString::from(String::new());
462        let json = registro_para_json_mascarado(&r);
463        assert!(json["password"].is_null());
464    }
465
466    #[test]
467    fn escribir_linha_ok() {
468        let resultado = escrever_linha("teste de escrita");
469        assert!(resultado.is_ok());
470    }
471
472    #[test]
473    fn escribir_linha_com_caracteres_especiais() {
474        let resultado = escrever_linha("linha com \t tab e \"aspas\"");
475        assert!(resultado.is_ok());
476    }
477
478    #[test]
479    fn salida_execucao_completa_formatada() {
480        let saida = SaidaExecucao {
481            stdout: "output do comando".to_string(),
482            stderr: "erro do comando".to_string(),
483            exit_code: Some(0),
484            truncado_stdout: false,
485            truncado_stderr: false,
486            duracao_ms: 150,
487        };
488        let resultado = escrever_linha(&format!(
489            "stdout: {}, stderr: {}, exit: {:?}",
490            saida.stdout, saida.stderr, saida.exit_code
491        ));
492        assert!(resultado.is_ok());
493    }
494
495    #[test]
496    fn salida_execucao_sem_exit_code() {
497        let saida = SaidaExecucao {
498            stdout: "".to_string(),
499            stderr: "".to_string(),
500            exit_code: None,
501            truncado_stdout: false,
502            truncado_stderr: false,
503            duracao_ms: 0,
504        };
505        let code_str = saida
506            .exit_code
507            .map(|c| c.to_string())
508            .unwrap_or_else(|| "N/A".to_string());
509        assert_eq!(code_str, "N/A");
510    }
511
512    #[test]
513    fn vps_registro_debug_nao_expoe_senha() {
514        let r = registro_teste();
515        let json = registro_para_json_mascarado(&r);
516        let json_str = serde_json::to_string(&json).unwrap();
517        assert!(!json_str.contains("senha-super-secreta"));
518        assert!(!json_str.contains("sudo-password-longa-aqui"));
519    }
520
521    #[test]
522    fn salida_execucao_truncada_mostra_aviso() {
523        let saida = SaidaExecucao {
524            stdout: "output".to_string(),
525            stderr: "erro".to_string(),
526            exit_code: Some(1),
527            truncado_stdout: true,
528            truncado_stderr: true,
529            duracao_ms: 100,
530        };
531        assert!(saida.truncado_stdout);
532        assert!(saida.truncado_stderr);
533    }
534
535    #[test]
536    fn salida_execucao_com_exit_code_numerico() {
537        let saida = SaidaExecucao {
538            stdout: "".to_string(),
539            stderr: "".to_string(),
540            exit_code: Some(127),
541            truncado_stdout: false,
542            truncado_stderr: false,
543            duracao_ms: 0,
544        };
545        let code_str = saida
546            .exit_code
547            .map(|c| c.to_string())
548            .unwrap_or_else(|| "N/A".to_string());
549        assert_eq!(code_str, "127");
550    }
551
552    #[test]
553    fn escribir_linha_string_vazia() {
554        let resultado = escrever_linha("");
555        assert!(resultado.is_ok());
556    }
557
558    #[test]
559    fn escribir_linha_com_unicode_brasileiro() {
560        let resultado = escrever_linha("ação você está Itaú");
561        assert!(resultado.is_ok());
562    }
563
564    #[test]
565    fn escribir_linha_com_emojis() {
566        let resultado = escrever_linha("texto com 🚀 e 🔐");
567        assert!(resultado.is_ok());
568    }
569
570    #[test]
571    fn escribir_linha_com_newlines() {
572        let resultado = escrever_linha("linha1\nlinha2\nlinha3");
573        assert!(resultado.is_ok());
574    }
575
576    #[test]
577    fn escribir_linha_longo_texto() {
578        let texto_longo = "a".repeat(10000);
579        let resultado = escrever_linha(&texto_longo);
580        assert!(resultado.is_ok());
581    }
582
583    #[test]
584    fn registro_para_json_mascarado_com_senha_curta_mascara_com_asteriscos() {
585        let mut r = registro_teste();
586        r.senha = SecretString::from("curta".to_string());
587        let json = registro_para_json_mascarado(&r);
588        let senha_str = json["password"].as_str().unwrap();
589        assert_eq!(senha_str, "***");
590    }
591
592    #[test]
593    fn registro_para_json_mascarado_com_sudo_e_su_definidos() {
594        let mut r = registro_teste();
595        r.senha_sudo = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
596        r.senha_su = Some(SecretString::from("su-pass-longa-aqui".to_string()));
597        let json = registro_para_json_mascarado(&r);
598        assert!(!json["sudo_password"].is_null());
599        assert!(!json["su_password"].is_null());
600        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
601        assert_eq!(json["su_password"].as_str().unwrap(), "***");
602    }
603
604    #[test]
605    fn saida_execucao_formatacao_completa() {
606        let saida = SaidaExecucao {
607            stdout: "comando executado".to_string(),
608            stderr: "aviso harmless".to_string(),
609            exit_code: Some(0),
610            truncado_stdout: false,
611            truncado_stderr: false,
612            duracao_ms: 1000,
613        };
614        assert_eq!(saida.stdout, "comando executado");
615        assert_eq!(saida.stderr, "aviso harmless");
616        assert_eq!(saida.exit_code, Some(0));
617        assert_eq!(saida.duracao_ms, 1000);
618        assert!(!saida.truncado_stdout);
619        assert!(!saida.truncado_stderr);
620    }
621
622    #[test]
623    fn saida_execucao_sem_stderr() {
624        let saida = SaidaExecucao {
625            stdout: "ok".to_string(),
626            stderr: String::new(),
627            exit_code: Some(0),
628            truncado_stdout: false,
629            truncado_stderr: false,
630            duracao_ms: 50,
631        };
632        assert!(saida.stderr.is_empty());
633    }
634
635    #[test]
636    fn saida_execucao_com_sinal_em_vez_de_exit_code() {
637        let saida = SaidaExecucao {
638            stdout: String::new(),
639            stderr: "signal received".to_string(),
640            exit_code: None,
641            truncado_stdout: false,
642            truncado_stderr: false,
643            duracao_ms: 5000,
644        };
645        assert!(saida.exit_code.is_none());
646    }
647
648    #[test]
649    fn saida_execucao_json_contem_campos_obrigatorios() {
650        let saida = SaidaExecucao {
651            stdout: "output".to_string(),
652            stderr: "erro".to_string(),
653            exit_code: Some(0),
654            truncado_stdout: false,
655            truncado_stderr: false,
656            duracao_ms: 100,
657        };
658        imprimir_saida_execucao_json(&saida);
659    }
660}