1#![forbid(unsafe_code)]
4mod 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 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}