Skip to main content

ssh_cli/
output.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Only module allowed to emit stdout for VPS CRUD.
3//!
4//! This module centralizes ALL CRUD formatting: text and JSON.
5//!
6//! Logs (tracing) go to stderr, managed by `tracing-subscriber`.
7
8use crate::masking::mask;
9use crate::ssh::ExecutionOutput;
10use crate::vps::model::VpsRecord;
11use secrecy::ExposeSecret;
12use serde_json::json;
13use std::io::{self, Write};
14use std::sync::atomic::{AtomicBool, Ordering};
15
16/// Global `--quiet` flag (suppresses human messages on stdout).
17static QUIET: AtomicBool = AtomicBool::new(false);
18
19/// When true, errors in `main` use a JSON envelope on stderr (IO-003).
20static JSON_ERRORS: AtomicBool = AtomicBool::new(false);
21
22/// Sets whether the CLI is in quiet mode (GAP-SSH-IO-004).
23pub fn set_quiet(quiet: bool) {
24    QUIET.store(quiet, Ordering::SeqCst);
25}
26
27/// Sets whether errors are emitted as a JSON envelope on stderr.
28pub fn set_json_errors(json: bool) {
29    JSON_ERRORS.store(json, Ordering::SeqCst);
30}
31
32/// Returns whether quiet mode is active.
33#[must_use]
34pub fn is_quiet() -> bool {
35    QUIET.load(Ordering::SeqCst)
36}
37
38/// Returns whether errors should use a JSON envelope.
39#[must_use]
40pub fn wants_json_errors() -> bool {
41    JSON_ERRORS.load(Ordering::SeqCst)
42}
43
44/// Writes a line to stdout with pure LF (never CRLF).
45///
46/// In quiet mode still writes (data/paths are API). Prefer `print_success`
47/// for human messages.
48///
49/// # Errors
50/// Returns an error if stdout I/O fails.
51pub fn write_line(content: &str) -> io::Result<()> {
52    let stdout = io::stdout();
53    let mut handle = stdout.lock();
54    handle.write_all(content.as_bytes())?;
55    handle.write_all(b"\n")?;
56    handle.flush()?;
57    Ok(())
58}
59
60/// Prints a human success message (suppressed with `--quiet`).
61pub fn print_success(message: &str) {
62    if is_quiet() {
63        return;
64    }
65    println!("{message}");
66}
67
68/// Agent-first success emitter (GAP-AUD-003/008).
69///
70/// When `json` is true, writes a single stdout envelope:
71/// `{ "ok": true, "event": <event>, …fields }`.
72/// Otherwise prints the human `message` (respecting `--quiet`).
73///
74/// # Errors
75/// Returns I/O errors from writing stdout.
76pub fn emit_success(
77    event: &str,
78    fields: serde_json::Value,
79    human: &str,
80    json: bool,
81) -> io::Result<()> {
82    if json {
83        let mut v = match fields {
84            serde_json::Value::Object(map) => serde_json::Value::Object(map),
85            other => json!({ "data": other }),
86        };
87        if let Some(obj) = v.as_object_mut() {
88            obj.insert("ok".into(), json!(true));
89            obj.insert("event".into(), json!(event));
90        }
91        print_json_value(&v)?;
92    } else {
93        print_success(human);
94    }
95    Ok(())
96}
97
98/// Human banner (tunnel etc.): Text+TTY+!quiet+!JSON errors only (GAP-SSH-IO-006).
99///
100/// In pipes/agents, progress goes to `tracing` (stderr), never stdout.
101pub fn print_human_banner(message: &str) {
102    if is_quiet() || wants_json_errors() {
103        return;
104    }
105    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
106        return;
107    }
108    if std::env::var_os("SSH_CLI_FORCE_TEXT").is_none() {
109        // Without FORCE_TEXT, non-TTY already returned; human TTY is ok.
110    }
111    println!("{message}");
112}
113
114/// Prints an error message on stderr (human-facing).
115pub fn print_error(message: &str) {
116    eprintln!("{message}");
117}
118
119/// Emits a JSON error envelope on stderr (GAP-SSH-IO-003).
120pub fn print_error_envelope(
121    exit_code: i32,
122    message: &str,
123    remote_exit_code: Option<i32>,
124) -> io::Result<()> {
125    let mut v = json!({
126        "exit_code": exit_code,
127        "message": message,
128    });
129    if let Some(r) = remote_exit_code {
130        v["remote_exit_code"] = json!(r);
131    }
132    let s = serde_json::to_string(&v).unwrap_or_else(|_| {
133        format!(r#"{{"exit_code":{exit_code},"message":"serialization error"}}"#)
134    });
135    let mut err = io::stderr().lock();
136    err.write_all(s.as_bytes())?;
137    err.write_all(b"\n")?;
138    err.flush()?;
139    Ok(())
140}
141
142/// Prints pretty JSON on stdout (API data; always respects quiet=false).
143pub fn print_json_value(v: &serde_json::Value) -> io::Result<()> {
144    let s = serde_json::to_string_pretty(v).map_err(io::Error::other)?;
145    write_line(&s)
146}
147
148/// Prints the doctor report as text (GAP-SSH-IO-005).
149#[allow(clippy::too_many_arguments)]
150pub fn print_doctor_text(
151    layer: &str,
152    config_path: &str,
153    exists: bool,
154    perms: &str,
155    schema_version: u32,
156    hosts: usize,
157    known_hosts: &str,
158    active_file: &str,
159    secrets_at_rest: &str,
160    secrets_key_source: &str,
161    secrets_key_file: &str,
162    plaintext_opt_out: bool,
163) {
164    if is_quiet() {
165        return;
166    }
167    println!("Winning layer:   {layer}");
168    println!("Config path:      {config_path}");
169    println!("Exists:           {exists}");
170    println!("Permissions:      {perms}");
171    println!("Schema:           {schema_version}");
172    println!("Hosts:            {hosts}");
173    println!("known_hosts:      {known_hosts}");
174    println!("active file:      {active_file}");
175    println!("Secrets at-rest:  {secrets_at_rest} (key source: {secrets_key_source})");
176    println!("Secrets key file: {secrets_key_file}");
177    println!(
178        "Plaintext opt-out: {}",
179        if plaintext_opt_out { "yes" } else { "no" }
180    );
181    println!("Telemetry:        disabled");
182}
183
184/// Prints the VPS list as masked text.
185pub fn print_list_text(records: &[VpsRecord]) {
186    if is_quiet() {
187        return;
188    }
189    if records.is_empty() {
190        println!(
191            "{}",
192            crate::i18n::t(crate::i18n::Message::VpsRegistryEmpty)
193        );
194        return;
195    }
196
197    println!(
198        "{:<20} {:<30} {:<6} {:<15} {:<20}",
199        "NAME", "HOST", "PORT", "USER", "PASSWORD"
200    );
201    for r in records {
202        println!(
203            "{:<20} {:<30} {:<6} {:<15} {:<20}",
204            r.name,
205            r.host,
206            r.port,
207            r.username,
208            mask(r.password.expose_secret())
209        );
210    }
211}
212
213/// Prints the VPS list as masked JSON.
214pub fn print_list_json(records: &[VpsRecord]) {
215    let list: Vec<_> = records.iter().map(record_to_masked_json).collect();
216    match serde_json::to_string_pretty(&list) {
217        Ok(s) => println!("{s}"),
218        Err(err) => eprintln!("failed to serialize JSON: {err}"),
219    }
220}
221
222/// Prints a single VPS record as masked text.
223pub fn print_details_text(r: &VpsRecord) {
224    if is_quiet() {
225        return;
226    }
227    println!("Name:            {}", r.name);
228    println!("Host:           {}", r.host);
229    println!("Port:            {}", r.port);
230    println!("User:            {}", r.username);
231    // GAP-SSH-JSON-001: empty password (key-only) does not fake a masked value.
232    println!(
233        "Password:       {}",
234        if r.password.expose_secret().is_empty() {
235            "(not set)".to_string()
236        } else {
237            mask(r.password.expose_secret())
238        }
239    );
240    println!(
241        "Key path:       {}",
242        r.key_path.as_deref().unwrap_or("(not set)")
243    );
244    println!(
245        "Sudo password:  {}",
246        r.sudo_password
247            .as_ref()
248            .map_or_else(|| "(not set)".into(), |s| mask(s.expose_secret()))
249    );
250    println!(
251        "Su password:    {}",
252        r.su_password
253            .as_ref()
254            .map_or_else(|| "(not set)".into(), |s| mask(s.expose_secret()))
255    );
256    println!("Timeout (ms):   {}", r.timeout_ms);
257    println!("Max cmd chars:  {}", r.max_command_chars);
258    println!("Max out chars:  {}", r.max_output_chars);
259    println!("Disable sudo:   {}", r.disable_sudo);
260    println!("Schema version: {}", r.schema_version);
261    println!("Added at:        {}", r.added_at);
262}
263
264/// Prints a single VPS record as masked JSON.
265pub fn print_details_json(r: &VpsRecord) {
266    let v = record_to_masked_json(r);
267    match serde_json::to_string_pretty(&v) {
268        Ok(s) => println!("{s}"),
269        Err(err) => eprintln!("failed to serialize JSON: {err}"),
270    }
271}
272
273fn record_to_masked_json(r: &VpsRecord) -> serde_json::Value {
274    // GAP-SSH-JSON-001: missing/empty password → null (like sudo/su); present → "***".
275    let password = if r.password.expose_secret().is_empty() {
276        json!(null)
277    } else {
278        json!(mask(r.password.expose_secret()))
279    };
280    json!({
281        "name": r.name,
282        "host": r.host,
283        "port": r.port,
284        "user": r.username,
285        "password": password,
286        "key_path": r.key_path,
287        "key_passphrase": r.key_passphrase.as_ref().map(|s| mask(s.expose_secret())),
288        "sudo_password": r.sudo_password.as_ref().map(|s| mask(s.expose_secret())),
289        "su_password": r.su_password.as_ref().map(|s| mask(s.expose_secret())),
290        "timeout_ms": r.timeout_ms,
291        "max_command_chars": r.max_command_chars,
292        "max_output_chars": r.max_output_chars,
293        "disable_sudo": r.disable_sudo,
294        "schema_version": r.schema_version,
295        "added_at": r.added_at,
296    })
297}
298
299/// GAP-SSH-UX-001: hosts for `vps export --json`.
300///
301/// - Redacted (`include_secrets=false`): secrets vazios/null, **nunca** ciphertext `sshcli-enc:`
302///   (EXP-001 parity). Empty password → `""` in export envelope (honest skeleton).
303/// - With secrets: plaintext password only if `--include-secrets` (same risk as TOML).
304pub fn export_hosts_to_json(
305    hosts: &std::collections::BTreeMap<String, VpsRecord>,
306    include_secrets: bool,
307) -> serde_json::Value {
308    let mut map = serde_json::Map::new();
309    for (name, r) in hosts {
310        let entry = if include_secrets {
311            json!({
312                "name": r.name,
313                "host": r.host,
314                "port": r.port,
315                "user": r.username,
316                "password": r.password.expose_secret(),
317                "key_path": r.key_path,
318                "key_passphrase": r.key_passphrase.as_ref().map(|s| s.expose_secret().to_string()),
319                "sudo_password": r.sudo_password.as_ref().map(|s| s.expose_secret().to_string()),
320                "su_password": r.su_password.as_ref().map(|s| s.expose_secret().to_string()),
321                "timeout_ms": r.timeout_ms,
322                "max_command_chars": r.max_command_chars,
323                "max_output_chars": r.max_output_chars,
324                "disable_sudo": r.disable_sudo,
325                "schema_version": r.schema_version,
326                "added_at": r.added_at,
327            })
328        } else {
329            // Redacted: empty password string (import skeleton), null optional secrets.
330            json!({
331                "name": r.name,
332                "host": r.host,
333                "port": r.port,
334                "user": r.username,
335                "password": "",
336                "key_path": r.key_path,
337                "key_passphrase": null,
338                "sudo_password": null,
339                "su_password": null,
340                "timeout_ms": r.timeout_ms,
341                "max_command_chars": r.max_command_chars,
342                "max_output_chars": r.max_output_chars,
343                "disable_sudo": r.disable_sudo,
344                "schema_version": r.schema_version,
345                "added_at": r.added_at,
346            })
347        };
348        map.insert(name.clone(), entry);
349    }
350    serde_json::Value::Object(map)
351}
352
353/// Prints stdout/stderr from an SSH command execution.
354///
355/// Formato:
356/// ```text
357/// --- stdout ---
358/// <stdout>
359/// --- stderr ---
360/// <stderr>
361/// --- exit code: <code> (<duration_ms>ms) ---
362/// ```
363pub fn print_execution_output(output: &ExecutionOutput) {
364    println!("--- stdout ---");
365    if output.stdout.is_empty() {
366        println!("(empty)");
367    } else {
368        println!("{}", output.stdout);
369    }
370    println!("--- stderr ---");
371    if output.stderr.is_empty() {
372        println!("(empty)");
373    } else {
374        println!("{}", output.stderr);
375    }
376    let code_str = output
377        .exit_code
378        .map(|c| c.to_string())
379        .unwrap_or_else(|| "N/A".to_string());
380    println!("--- exit code: {} ({}ms) ---", code_str, output.duration_ms);
381    if output.truncated_stdout {
382        println!("(stdout foi truncado)");
383    }
384    if output.truncated_stderr {
385        println!("(stderr foi truncado)");
386    }
387}
388
389/// Prints SSH command execution output as JSON.
390pub fn print_execution_output_json(output: &ExecutionOutput) {
391    let v = json!({
392        "stdout": output.stdout,
393        "stderr": output.stderr,
394        "exit_code": output.exit_code,
395        "truncated_stdout": output.truncated_stdout,
396        "truncated_stderr": output.truncated_stderr,
397        "duration_ms": output.duration_ms,
398    });
399    match serde_json::to_string_pretty(&v) {
400        Ok(s) => println!("{s}"),
401        Err(e) => eprintln!("failed to serialize JSON: {e}"),
402    }
403}
404
405/// Prints a health-check result as text.
406pub fn print_health_check(name: &str, latency_ms: u64) {
407    if is_quiet() {
408        return;
409    }
410    println!(
411        "{}",
412        crate::i18n::t(crate::i18n::Message::HealthCheckOk {
413            name: name.to_string(),
414        })
415    );
416    println!("  latência: {latency_ms}ms");
417}
418
419/// Prints a health-check result as JSON.
420pub fn print_health_check_json(name: &str, latency_ms: u64) {
421    let v = json!({
422        "name": name,
423        "status": "ok",
424        "latency_ms": latency_ms,
425    });
426    match serde_json::to_string_pretty(&v) {
427        Ok(s) => println!("{s}"),
428        Err(e) => eprintln!("failed to serialize JSON: {e}"),
429    }
430}
431
432/// Prints an SCP transfer result as JSON (GAP-SSH-IO-007 / SCP-021 / IO-009).
433pub fn print_transfer_json(
434    direction: &str,
435    vps: &str,
436    local: &str,
437    remote: &str,
438    bytes: u64,
439    duration_ms: u64,
440) {
441    // GAP-SSH-IO-009: event discriminator (parity with tunnel_listening).
442    let v = json!({
443        "ok": true,
444        "event": "scp-transfer",
445        "direction": direction,
446        "vps": vps,
447        "local": local,
448        "remote": remote,
449        "bytes": bytes,
450        "duration_ms": duration_ms,
451    });
452    match serde_json::to_string_pretty(&v) {
453        Ok(s) => {
454            let _ = write_line(&s);
455        }
456        Err(e) => eprintln!("failed to serialize JSON: {e}"),
457    }
458}
459
460/// JSON event when the local tunnel listener comes up (GAP-SSH-IO-008).
461pub fn print_tunnel_listening_json(
462    vps: &str,
463    local_port: u16,
464    remote_host: &str,
465    remote_port: u16,
466    timeout_ms: u64,
467) {
468    let v = json!({
469        "ok": true,
470        "event": "tunnel_listening",
471        "vps": vps,
472        "local_port": local_port,
473        "remote_host": remote_host,
474        "remote_port": remote_port,
475        "timeout_ms": timeout_ms,
476    });
477    match serde_json::to_string_pretty(&v) {
478        Ok(s) => {
479            let _ = write_line(&s);
480        }
481        Err(e) => eprintln!("failed to serialize JSON: {e}"),
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::ssh::ExecutionOutput;
489    use crate::vps::model::VpsRecord;
490    use secrecy::SecretString;
491
492    fn registro_teste() -> VpsRecord {
493        VpsRecord::new(
494            "vps-teste".into(),
495            "1.2.3.4".into(),
496            22,
497            "root".into(),
498            SecretString::from("senha-super-secreta".to_string()),
499            None,
500            None,
501            Some(5000),
502            Some(1000),
503            Some(1000),
504            Some(SecretString::from("sudo-password-longa-aqui".to_string())),
505            None,
506            false,
507        )
508    }
509
510    #[test]
511    fn masked_json_contains_required_fields() {
512        let r = registro_teste();
513        let json = record_to_masked_json(&r);
514        assert_eq!(json["name"], "vps-teste");
515        assert_eq!(json["host"], "1.2.3.4");
516        assert_eq!(json["port"], 22);
517        assert_eq!(json["user"], "root");
518        assert_eq!(json["password"].as_str().unwrap(), "***");
519        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
520        assert!(json["su_password"].is_null());
521        assert_eq!(json["timeout_ms"], 5000);
522        assert_eq!(json["max_command_chars"], 1000);
523        assert_eq!(json["max_output_chars"], 1000);
524        assert_eq!(json["schema_version"], 3);
525    }
526
527    #[test]
528    fn masked_json_sudo_null_when_unset() {
529        let mut r = registro_teste();
530        r.sudo_password = None;
531        let json = record_to_masked_json(&r);
532        assert!(json["sudo_password"].is_null());
533    }
534
535    #[test]
536    fn masked_json_su_password_present() {
537        let mut r = registro_teste();
538        r.su_password = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
539        let json = record_to_masked_json(&r);
540        assert_eq!(json["su_password"].as_str().unwrap(), "***");
541    }
542
543    #[test]
544    fn masked_json_password_null_when_empty() {
545        let mut r = registro_teste();
546        r.password = SecretString::from(String::new());
547        let json = record_to_masked_json(&r);
548        assert!(json["password"].is_null());
549    }
550
551    #[test]
552    fn write_line_ok() {
553        let result = write_line("write test");
554        assert!(result.is_ok());
555    }
556
557    #[test]
558    fn write_line_special_chars() {
559        let result = write_line("line with \t tab and \"quotes\"");
560        assert!(result.is_ok());
561    }
562
563    #[test]
564    fn execution_output_fully_formatted() {
565        let output = ExecutionOutput {
566            stdout: "output do comando".to_string(),
567            stderr: "command error".to_string(),
568            exit_code: Some(0),
569            truncated_stdout: false,
570            truncated_stderr: false,
571            duration_ms: 150,
572        };
573        let result = write_line(&format!(
574            "stdout: {}, stderr: {}, exit: {:?}",
575            output.stdout, output.stderr, output.exit_code
576        ));
577        assert!(result.is_ok());
578    }
579
580    #[test]
581    fn execution_output_without_exit_code() {
582        let output = ExecutionOutput {
583            stdout: "".to_string(),
584            stderr: "".to_string(),
585            exit_code: None,
586            truncated_stdout: false,
587            truncated_stderr: false,
588            duration_ms: 0,
589        };
590        let code_str = output
591            .exit_code
592            .map(|c| c.to_string())
593            .unwrap_or_else(|| "N/A".to_string());
594        assert_eq!(code_str, "N/A");
595    }
596
597    #[test]
598    fn vps_record_debug_does_not_expose_password() {
599        let r = registro_teste();
600        let json = record_to_masked_json(&r);
601        let json_str = serde_json::to_string(&json).unwrap();
602        assert!(!json_str.contains("senha-super-secreta"));
603        assert!(!json_str.contains("sudo-password-longa-aqui"));
604    }
605
606    #[test]
607    fn execution_output_truncated_shows_warning() {
608        let output = ExecutionOutput {
609            stdout: "output".to_string(),
610            stderr: "error".to_string(),
611            exit_code: Some(1),
612            truncated_stdout: true,
613            truncated_stderr: true,
614            duration_ms: 100,
615        };
616        assert!(output.truncated_stdout);
617        assert!(output.truncated_stderr);
618    }
619
620    #[test]
621    fn execution_output_numeric_exit_code() {
622        let output = ExecutionOutput {
623            stdout: "".to_string(),
624            stderr: "".to_string(),
625            exit_code: Some(127),
626            truncated_stdout: false,
627            truncated_stderr: false,
628            duration_ms: 0,
629        };
630        let code_str = output
631            .exit_code
632            .map(|c| c.to_string())
633            .unwrap_or_else(|| "N/A".to_string());
634        assert_eq!(code_str, "127");
635    }
636
637    #[test]
638    fn write_line_empty_string() {
639        let result = write_line("");
640        assert!(result.is_ok());
641    }
642
643    #[test]
644    fn write_line_brazilian_unicode() {
645        let result = write_line("ação você está Itaú");
646        assert!(result.is_ok());
647    }
648
649    #[test]
650    fn write_line_with_emojis() {
651        let result = write_line("texto com 🚀 e 🔐");
652        assert!(result.is_ok());
653    }
654
655    #[test]
656    fn write_line_with_newlines() {
657        let result = write_line("linha1\nlinha2\nlinha3");
658        assert!(result.is_ok());
659    }
660
661    #[test]
662    fn write_line_long_text() {
663        let long_text = "a".repeat(10000);
664        let result = write_line(&long_text);
665        assert!(result.is_ok());
666    }
667
668    #[test]
669    fn masked_json_short_password_asterisks() {
670        let mut r = registro_teste();
671        r.password = SecretString::from("curta".to_string());
672        let json = record_to_masked_json(&r);
673        let password_str = json["password"].as_str().unwrap();
674        assert_eq!(password_str, "***");
675    }
676
677    #[test]
678    fn masked_json_with_sudo_and_su_set() {
679        let mut r = registro_teste();
680        r.sudo_password = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
681        r.su_password = Some(SecretString::from("su-pass-longa-aqui".to_string()));
682        let json = record_to_masked_json(&r);
683        assert!(!json["sudo_password"].is_null());
684        assert!(!json["su_password"].is_null());
685        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
686        assert_eq!(json["su_password"].as_str().unwrap(), "***");
687    }
688
689    #[test]
690    fn execution_output_full_formatting() {
691        let output = ExecutionOutput {
692            stdout: "comando executado".to_string(),
693            stderr: "aviso harmless".to_string(),
694            exit_code: Some(0),
695            truncated_stdout: false,
696            truncated_stderr: false,
697            duration_ms: 1000,
698        };
699        assert_eq!(output.stdout, "comando executado");
700        assert_eq!(output.stderr, "aviso harmless");
701        assert_eq!(output.exit_code, Some(0));
702        assert_eq!(output.duration_ms, 1000);
703        assert!(!output.truncated_stdout);
704        assert!(!output.truncated_stderr);
705    }
706
707    #[test]
708    fn execution_output_without_stderr() {
709        let output = ExecutionOutput {
710            stdout: "ok".to_string(),
711            stderr: String::new(),
712            exit_code: Some(0),
713            truncated_stdout: false,
714            truncated_stderr: false,
715            duration_ms: 50,
716        };
717        assert!(output.stderr.is_empty());
718    }
719
720    #[test]
721    fn execution_output_signal_instead_of_exit() {
722        let output = ExecutionOutput {
723            stdout: String::new(),
724            stderr: "signal received".to_string(),
725            exit_code: None,
726            truncated_stdout: false,
727            truncated_stderr: false,
728            duration_ms: 5000,
729        };
730        assert!(output.exit_code.is_none());
731    }
732
733    #[test]
734    fn execution_output_json_required_fields() {
735        let output = ExecutionOutput {
736            stdout: "output".to_string(),
737            stderr: "error".to_string(),
738            exit_code: Some(0),
739            truncated_stdout: false,
740            truncated_stderr: false,
741            duration_ms: 100,
742        };
743        print_execution_output_json(&output);
744    }
745}