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