Skip to main content

ssh_cli/output/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Only module allowed to emit stdout for VPS CRUD.
5//!
6//! Centralizes all CRUD formatting for text and JSON emission.
7//!
8//! Logs (tracing) go to stderr, managed by `tracing-subscriber`.
9//!
10//! # JSON wire
11//!
12//! Agent JSON is **compact** single-root RFC 8259 (see [`crate::json_wire`]).
13//! Pretty-print is intentionally not used on the machine path.
14
15mod batch;
16mod emit;
17mod json;
18mod text;
19
20pub use batch::{
21    build_tunnel_closed, build_tunnel_listening, print_exec_batch, print_health_batch,
22    print_scp_batch, print_transfer_json, print_tunnel_closed_json, print_tunnel_listening_json,
23    TunnelClosedInput,
24};
25// A6: re-exported separately because the SFTP emitters only exist with `ssh-real`.
26// Folding them into the list above is what made `--no-default-features` fail here.
27#[cfg(feature = "ssh-real")]
28pub use batch::{
29    print_sftp_batch, print_sftp_fs_op_json, print_sftp_list_json, print_sftp_stat_json,
30    print_sftp_transfer_json,
31};
32pub(crate) use emit::report_json_serialize_error;
33pub use emit::{
34    emit_success, emit_success_fmt, is_quiet, print_error, print_error_envelope, print_error_fmt,
35    print_human_banner, print_json_value, print_success, print_success_fmt, print_warning,
36    print_warning_fmt, set_json_errors, set_quiet, wants_json_errors, write_line, write_line_fmt,
37    write_line_to, write_line_to_fmt, write_lines, write_stderr_fmt, write_stderr_line,
38    write_stderr_line_to, write_stderr_line_to_fmt,
39};
40pub use json::{
41    export_envelope_json, export_hosts_to_json, print_details_json, print_execution_output_json,
42    print_health_check_json, print_list_json, record_to_masked_json,
43};
44pub use text::{
45    print_details_text, print_doctor_text, print_execution_output, print_health_check,
46    print_list_text,
47};
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::ssh::ExecutionOutput;
53    use crate::vps::model::VpsRecord;
54    use secrecy::SecretString;
55
56    fn registro_teste() -> VpsRecord {
57        VpsRecord::test_new(
58            "vps-teste",
59            "1.2.3.4",
60            22,
61            "root",
62            SecretString::from("senha-super-secreta".to_string()),
63            None,
64            None,
65            Some(5000),
66            Some(1000),
67            Some(1000),
68            Some(SecretString::from("sudo-password-longa-aqui".to_string())),
69            None,
70            false,
71        )
72    }
73
74    #[test]
75    fn masked_json_contains_required_fields() {
76        let r = registro_teste();
77        let m = record_to_masked_json(&r);
78        let json = serde_json::to_value(&m).unwrap();
79        assert_eq!(json["name"], "vps-teste");
80        assert_eq!(json["host"], "1.2.3.4");
81        assert_eq!(json["port"], 22);
82        assert_eq!(json["user"], "root");
83        assert_eq!(json["password"].as_str().unwrap(), "***");
84        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
85        assert!(json["su_password"].is_null());
86        assert_eq!(json["timeout_ms"], 5000);
87        assert_eq!(json["max_command_chars"], 1000);
88        assert_eq!(json["max_output_chars"], 1000);
89        assert_eq!(json["schema_version"], 3);
90    }
91
92    #[test]
93    fn masked_json_sudo_null_when_unset() {
94        let mut r = registro_teste();
95        r.sudo_password = None;
96        let json = serde_json::to_value(record_to_masked_json(&r)).unwrap();
97        assert!(json["sudo_password"].is_null());
98    }
99
100    #[test]
101    fn masked_json_su_password_present() {
102        let mut r = registro_teste();
103        r.su_password = Some(SecretString::from("senha-su-muito-longa-aqui".to_string()));
104        let json = serde_json::to_value(record_to_masked_json(&r)).unwrap();
105        assert_eq!(json["su_password"].as_str().unwrap(), "***");
106    }
107
108    #[test]
109    fn masked_json_password_null_when_empty() {
110        let mut r = registro_teste();
111        r.password = SecretString::from(String::new());
112        let json = serde_json::to_value(record_to_masked_json(&r)).unwrap();
113        assert!(json["password"].is_null());
114    }
115
116    #[test]
117    fn write_line_ok() {
118        let result = write_line("write test");
119        assert!(result.is_ok());
120    }
121
122    #[test]
123    fn write_line_special_chars() {
124        let result = write_line("line with \t tab and \"quotes\"");
125        assert!(result.is_ok());
126    }
127
128    #[test]
129    fn execution_output_fully_formatted() {
130        let output = ExecutionOutput {
131            stdout: "output do comando".to_string(),
132            stderr: "command error".to_string(),
133            exit_code: Some(0),
134            truncated_stdout: false,
135            truncated_stderr: false,
136            duration_ms: 150,
137        };
138        let result = write_line_fmt(format_args!(
139            "stdout: {}, stderr: {}, exit: {:?}",
140            output.stdout, output.stderr, output.exit_code
141        ));
142        assert!(result.is_ok());
143    }
144
145    #[test]
146    fn print_warning_fmt_composes_prefix_without_owned_string() {
147        // Smoke: must not panic; prefix composition uses Arguments: Display.
148        print_warning_fmt(format_args!("timeout={t}", t = 5u64));
149    }
150
151    #[test]
152    fn execution_output_without_exit_code() {
153        let output = ExecutionOutput {
154            stdout: "".to_string(),
155            stderr: "".to_string(),
156            exit_code: None,
157            truncated_stdout: false,
158            truncated_stderr: false,
159            duration_ms: 0,
160        };
161        let code_str = output
162            .exit_code
163            .map(|c| c.to_string())
164            .unwrap_or_else(|| "N/A".to_string());
165        assert_eq!(code_str, "N/A");
166    }
167
168    #[test]
169    fn vps_record_debug_does_not_expose_password() {
170        let r = registro_teste();
171        let json_str = serde_json::to_string(&record_to_masked_json(&r)).unwrap();
172        assert!(!json_str.contains("senha-super-secreta"));
173        assert!(!json_str.contains("sudo-password-longa-aqui"));
174        assert!(!json_str.contains('\n'), "agent wire must be compact");
175    }
176
177    #[test]
178    fn execution_output_truncated_shows_warning() {
179        let output = ExecutionOutput {
180            stdout: "output".to_string(),
181            stderr: "error".to_string(),
182            exit_code: Some(1),
183            truncated_stdout: true,
184            truncated_stderr: true,
185            duration_ms: 100,
186        };
187        assert!(output.truncated_stdout);
188        assert!(output.truncated_stderr);
189    }
190
191    #[test]
192    fn execution_output_numeric_exit_code() {
193        let output = ExecutionOutput {
194            stdout: "".to_string(),
195            stderr: "".to_string(),
196            exit_code: Some(127),
197            truncated_stdout: false,
198            truncated_stderr: false,
199            duration_ms: 0,
200        };
201        let code_str = output
202            .exit_code
203            .map(|c| c.to_string())
204            .unwrap_or_else(|| "N/A".to_string());
205        assert_eq!(code_str, "127");
206    }
207
208    #[test]
209    fn write_line_empty_string() {
210        let result = write_line("");
211        assert!(result.is_ok());
212    }
213
214    #[test]
215    fn write_line_brazilian_unicode() {
216        let result = write_line("ação você está Itaú");
217        assert!(result.is_ok());
218    }
219
220    #[test]
221    fn write_line_with_emojis() {
222        let result = write_line("texto com 🚀 e 🔐");
223        assert!(result.is_ok());
224    }
225
226    #[test]
227    fn write_line_with_newlines() {
228        let result = write_line("linha1\nlinha2\nlinha3");
229        assert!(result.is_ok());
230    }
231
232    #[test]
233    fn write_line_long_text() {
234        let long_text = "a".repeat(10000);
235        let result = write_line(&long_text);
236        assert!(result.is_ok());
237    }
238
239    #[test]
240    fn masked_json_short_password_asterisks() {
241        let mut r = registro_teste();
242        r.password = SecretString::from("curta".to_string());
243        let json = serde_json::to_value(record_to_masked_json(&r)).unwrap();
244        let password_str = json["password"].as_str().unwrap();
245        assert_eq!(password_str, "***");
246    }
247
248    #[test]
249    fn masked_json_with_sudo_and_su_set() {
250        let mut r = registro_teste();
251        r.sudo_password = Some(SecretString::from("sudo-pass-longa-aqui".to_string()));
252        r.su_password = Some(SecretString::from("su-pass-longa-aqui".to_string()));
253        let json = serde_json::to_value(record_to_masked_json(&r)).unwrap();
254        assert!(!json["sudo_password"].is_null());
255        assert!(!json["su_password"].is_null());
256        assert_eq!(json["sudo_password"].as_str().unwrap(), "***");
257        assert_eq!(json["su_password"].as_str().unwrap(), "***");
258    }
259
260    #[test]
261    fn write_line_to_appends_lf_and_flushes() {
262        use std::io::Cursor;
263        let mut buf = Cursor::new(Vec::new());
264        write_line_to(&mut buf, "agent-ok").expect("write");
265        assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "agent-ok\n");
266    }
267
268    #[test]
269    fn write_line_to_fmt_avoids_owned_string() {
270        use std::io::Cursor;
271        let mut buf = Cursor::new(Vec::new());
272        let port = 22u16;
273        write_line_to_fmt(&mut buf, format_args!("port={port}")).expect("write_fmt");
274        assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "port=22\n");
275    }
276
277    #[test]
278    fn write_stderr_line_to_fmt_treats_broken_pipe_as_ok() {
279        use std::io::{self, Write};
280
281        struct Broken;
282        impl Write for Broken {
283            fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
284                Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
285            }
286            fn flush(&mut self) -> io::Result<()> {
287                Ok(())
288            }
289        }
290
291        write_stderr_line_to_fmt(&mut Broken, format_args!("x"))
292            .expect("EPIPE is ok on stderr fmt path");
293    }
294
295    #[test]
296    fn write_stderr_line_to_treats_broken_pipe_as_ok() {
297        use std::io::{self, Write};
298
299        struct Broken;
300        impl Write for Broken {
301            fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
302                Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
303            }
304            fn flush(&mut self) -> io::Result<()> {
305                Ok(())
306            }
307        }
308
309        write_stderr_line_to(&mut Broken, "x").expect("EPIPE is ok on stderr path");
310    }
311
312    #[test]
313    fn execution_output_full_formatting() {
314        let output = ExecutionOutput {
315            stdout: "comando executado".to_string(),
316            stderr: "aviso harmless".to_string(),
317            exit_code: Some(0),
318            truncated_stdout: false,
319            truncated_stderr: false,
320            duration_ms: 1000,
321        };
322        assert_eq!(output.stdout, "comando executado");
323        assert_eq!(output.stderr, "aviso harmless");
324        assert_eq!(output.exit_code, Some(0));
325        assert_eq!(output.duration_ms, 1000);
326        assert!(!output.truncated_stdout);
327        assert!(!output.truncated_stderr);
328    }
329
330    #[test]
331    fn execution_output_without_stderr() {
332        let output = ExecutionOutput {
333            stdout: "ok".to_string(),
334            stderr: String::new(),
335            exit_code: Some(0),
336            truncated_stdout: false,
337            truncated_stderr: false,
338            duration_ms: 50,
339        };
340        assert!(output.stderr.is_empty());
341    }
342
343    #[test]
344    fn execution_output_signal_instead_of_exit() {
345        let output = ExecutionOutput {
346            stdout: String::new(),
347            stderr: "signal received".to_string(),
348            exit_code: None,
349            truncated_stdout: false,
350            truncated_stderr: false,
351            duration_ms: 5000,
352        };
353        assert!(output.exit_code.is_none());
354    }
355
356    #[test]
357    fn execution_output_json_required_fields() {
358        let output = ExecutionOutput {
359            stdout: "output".to_string(),
360            stderr: "error".to_string(),
361            exit_code: Some(0),
362            truncated_stdout: false,
363            truncated_stderr: false,
364            duration_ms: 100,
365        };
366        print_execution_output_json(
367            &output,
368            &crate::json_wire::ExecTarget::new("t", crate::json_wire::TargetSource::Argv),
369        )
370        .expect("json print in unit test");
371    }
372}