1#![deny(unsafe_code)]
7#![cfg_attr(test, allow(clippy::panic, clippy::unwrap_used, clippy::expect_used))]
8#![warn(rust_2018_idioms)]
9#![warn(missing_docs)]
10#![warn(clippy::all)]
11
12use std::fmt;
13#[cfg(all(not(target_arch = "wasm32"), windows))]
14use std::path::Path;
15
16#[derive(Debug, Clone)]
18pub struct SubprocessOutput {
19 pub stdout: Vec<u8>,
21 pub stderr: Vec<u8>,
23 pub status_code: i32,
25}
26
27impl SubprocessOutput {
28 pub fn success(&self) -> bool {
30 self.status_code == 0
31 }
32
33 pub fn stdout_lossy(&self) -> String {
35 String::from_utf8_lossy(&self.stdout).into_owned()
36 }
37
38 pub fn stderr_lossy(&self) -> String {
40 String::from_utf8_lossy(&self.stderr).into_owned()
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct SubprocessError {
47 pub message: String,
49}
50
51impl SubprocessError {
52 pub fn new(message: impl Into<String>) -> Self {
54 Self { message: message.into() }
55 }
56}
57
58impl fmt::Display for SubprocessError {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 write!(f, "{}", self.message)
61 }
62}
63
64impl std::error::Error for SubprocessError {}
65
66pub trait SubprocessRuntime: Send + Sync {
68 fn run_command(
70 &self,
71 program: &str,
72 args: &[&str],
73 stdin: Option<&[u8]>,
74 ) -> Result<SubprocessOutput, SubprocessError>;
75}
76
77#[cfg(not(target_arch = "wasm32"))]
79pub struct OsSubprocessRuntime {
80 timeout_secs: Option<u64>,
81}
82
83#[cfg(not(target_arch = "wasm32"))]
84impl OsSubprocessRuntime {
85 pub fn new() -> Self {
87 Self { timeout_secs: None }
88 }
89
90 pub fn with_timeout(timeout_secs: u64) -> Self {
109 assert!(timeout_secs > 0, "timeout_secs must be greater than zero");
110 Self { timeout_secs: Some(timeout_secs) }
111 }
112}
113
114#[cfg(not(target_arch = "wasm32"))]
115impl Default for OsSubprocessRuntime {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122impl SubprocessRuntime for OsSubprocessRuntime {
123 fn run_command(
124 &self,
125 program: &str,
126 args: &[&str],
127 stdin: Option<&[u8]>,
128 ) -> Result<SubprocessOutput, SubprocessError> {
129 use std::io::Write;
130 use std::process::{Command, Stdio};
131
132 validate_command_input(program, args)?;
133
134 let (resolved_program, resolved_args) = resolve_command_invocation(program, args);
135 let mut cmd = Command::new(&resolved_program);
136 cmd.args(resolved_args.iter().map(String::as_str));
137
138 if stdin.is_some() {
139 cmd.stdin(Stdio::piped());
140 }
141
142 cmd.stdout(Stdio::piped());
143 cmd.stderr(Stdio::piped());
144
145 let mut child = cmd
146 .spawn()
147 .map_err(|e| SubprocessError::new(format!("Failed to start {}: {}", program, e)))?;
148
149 if let Some(input) = stdin
150 && let Some(mut child_stdin) = child.stdin.take()
151 {
152 child_stdin.write_all(input).map_err(|e| {
153 SubprocessError::new(format!("Failed to write to {} stdin: {}", program, e))
154 })?;
155 }
156
157 match self.timeout_secs {
158 None => {
159 let output = child.wait_with_output().map_err(|e| {
160 SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
161 })?;
162 Ok(SubprocessOutput {
163 stdout: output.stdout,
164 stderr: output.stderr,
165 status_code: output.status.code().unwrap_or(-1),
166 })
167 }
168 Some(secs) => {
169 use std::time::{Duration, Instant};
170
171 let deadline = Instant::now() + Duration::from_secs(secs);
172 loop {
173 if child
174 .try_wait()
175 .map_err(|e| {
176 SubprocessError::new(format!("Failed to poll {}: {}", program, e))
177 })?
178 .is_some()
179 {
180 let output = child.wait_with_output().map_err(|e| {
181 SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
182 })?;
183 return Ok(SubprocessOutput {
184 stdout: output.stdout,
185 stderr: output.stderr,
186 status_code: output.status.code().unwrap_or(-1),
187 });
188 }
189
190 if Instant::now() >= deadline {
191 if let Err(kill_err) = child.kill() {
192 let already_exited = child
195 .try_wait()
196 .map_err(|e| {
197 SubprocessError::new(format!(
198 "Failed to poll {}: {}",
199 program, e
200 ))
201 })?
202 .is_some();
203 if !already_exited {
204 return Err(SubprocessError::new(format!(
205 "subprocess timed out after {} seconds and failed to terminate {}: {}",
206 secs, program, kill_err
207 )));
208 }
209 }
210 let _ = child.wait();
211 return Err(SubprocessError::new(format!(
212 "subprocess timed out after {} seconds",
213 secs
214 )));
215 }
216
217 std::thread::sleep(Duration::from_millis(50));
218 }
219 }
220 }
221 }
222}
223
224#[cfg(not(target_arch = "wasm32"))]
225fn validate_command_input(program: &str, args: &[&str]) -> Result<(), SubprocessError> {
226 if program.trim().is_empty() {
227 return Err(SubprocessError::new("program name must not be empty"));
228 }
229 if program.contains('\0') {
230 return Err(SubprocessError::new("program name must not contain NUL bytes"));
231 }
232 if args.iter().any(|arg| arg.contains('\0')) {
233 return Err(SubprocessError::new("arguments must not contain NUL bytes"));
234 }
235 Ok(())
236}
237
238#[cfg(not(target_arch = "wasm32"))]
239fn resolve_command_invocation(program: &str, args: &[&str]) -> (String, Vec<String>) {
240 #[cfg(windows)]
241 {
242 let resolved_program =
243 resolve_windows_program(program).unwrap_or_else(|| program.to_string());
244
245 if windows_requires_cmd_shell(&resolved_program) {
246 let command_line = std::iter::once(resolved_program.as_str())
247 .chain(args.iter().copied())
248 .map(windows_quote_for_cmd)
249 .collect::<Vec<_>>()
250 .join(" ");
251 let shell_args = vec![
259 "/D".to_string(),
260 "/V:OFF".to_string(),
261 "/S".to_string(),
262 "/C".to_string(),
263 command_line,
264 ];
265 return ("cmd.exe".to_string(), shell_args);
266 }
267
268 (resolved_program, args.iter().map(|arg| (*arg).to_string()).collect())
269 }
270
271 #[cfg(not(windows))]
272 {
273 (program.to_string(), args.iter().map(|arg| (*arg).to_string()).collect())
274 }
275}
276
277#[cfg(all(not(target_arch = "wasm32"), windows))]
304fn windows_quote_for_cmd(arg: &str) -> String {
305 let mut escaped = String::with_capacity(arg.len() + 2);
306 escaped.push('"');
307 for ch in arg.chars() {
308 match ch {
309 '%' => escaped.push_str("%%"),
312 '"' => escaped.push_str("\"\""),
317 _ => escaped.push(ch),
321 }
322 }
323 escaped.push('"');
324 escaped
325}
326
327#[cfg(all(not(target_arch = "wasm32"), windows))]
328fn resolve_windows_program(program: &str) -> Option<String> {
329 let program_path = Path::new(program);
330 let has_separator = program.contains('\\') || program.contains('/');
331 let has_extension = program_path.extension().is_some();
332
333 if has_separator || has_extension {
334 return Some(program.to_string());
335 }
336
337 let output = std::process::Command::new("where")
338 .arg(program)
339 .stdout(std::process::Stdio::piped())
340 .stderr(std::process::Stdio::null())
341 .output()
342 .ok()?;
343
344 if !output.status.success() {
345 return None;
346 }
347
348 String::from_utf8(output.stdout)
349 .ok()?
350 .lines()
351 .map(str::trim)
352 .filter(|line| !line.is_empty())
353 .max_by_key(|candidate| windows_program_priority(candidate))
354 .map(String::from)
355}
356
357#[cfg(all(not(target_arch = "wasm32"), windows))]
358fn windows_program_priority(candidate: &str) -> u8 {
359 match Path::new(candidate)
360 .extension()
361 .and_then(|ext| ext.to_str())
362 .map(|ext| ext.to_ascii_lowercase())
363 {
364 Some(ext) if ext == "exe" => 5,
365 Some(ext) if ext == "com" => 4,
366 Some(ext) if ext == "cmd" => 3,
367 Some(ext) if ext == "bat" => 2,
368 Some(_) => 1,
369 None => 0,
370 }
371}
372
373#[cfg(all(not(target_arch = "wasm32"), windows))]
374fn windows_requires_cmd_shell(program: &str) -> bool {
375 Path::new(program)
376 .extension()
377 .and_then(|ext| ext.to_str())
378 .map(|ext| ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd"))
379 .unwrap_or(false)
380}
381
382pub mod mock {
384 use super::*;
385 use std::sync::{Arc, Mutex, MutexGuard};
386
387 fn lock<'a, T>(mutex: &'a Mutex<T>) -> MutexGuard<'a, T> {
388 match mutex.lock() {
389 Ok(guard) => guard,
390 Err(poisoned) => poisoned.into_inner(),
391 }
392 }
393
394 #[derive(Debug, Clone)]
396 pub struct CommandInvocation {
397 pub program: String,
399 pub args: Vec<String>,
401 pub stdin: Option<Vec<u8>>,
403 }
404
405 #[derive(Debug, Clone)]
407 pub struct MockResponse {
408 pub stdout: Vec<u8>,
410 pub stderr: Vec<u8>,
412 pub status_code: i32,
414 }
415
416 impl MockResponse {
417 pub fn success(stdout: impl Into<Vec<u8>>) -> Self {
419 Self { stdout: stdout.into(), stderr: Vec::new(), status_code: 0 }
420 }
421
422 pub fn failure(stderr: impl Into<Vec<u8>>, status_code: i32) -> Self {
424 Self { stdout: Vec::new(), stderr: stderr.into(), status_code }
425 }
426 }
427
428 pub struct MockSubprocessRuntime {
430 invocations: Arc<Mutex<Vec<CommandInvocation>>>,
431 responses: Arc<Mutex<Vec<MockResponse>>>,
432 default_response: MockResponse,
433 }
434
435 impl MockSubprocessRuntime {
436 pub fn new() -> Self {
438 Self {
439 invocations: Arc::new(Mutex::new(Vec::new())),
440 responses: Arc::new(Mutex::new(Vec::new())),
441 default_response: MockResponse::success(Vec::new()),
442 }
443 }
444
445 pub fn add_response(&self, response: MockResponse) {
447 lock(&self.responses).push(response);
448 }
449
450 pub fn set_default_response(&mut self, response: MockResponse) {
452 self.default_response = response;
453 }
454
455 pub fn invocations(&self) -> Vec<CommandInvocation> {
457 lock(&self.invocations).clone()
458 }
459
460 pub fn clear_invocations(&self) {
462 lock(&self.invocations).clear();
463 }
464 }
465
466 impl Default for MockSubprocessRuntime {
467 fn default() -> Self {
468 Self::new()
469 }
470 }
471
472 impl SubprocessRuntime for MockSubprocessRuntime {
473 fn run_command(
474 &self,
475 program: &str,
476 args: &[&str],
477 stdin: Option<&[u8]>,
478 ) -> Result<SubprocessOutput, SubprocessError> {
479 lock(&self.invocations).push(CommandInvocation {
480 program: program.to_string(),
481 args: args.iter().map(|s| s.to_string()).collect(),
482 stdin: stdin.map(|s| s.to_vec()),
483 });
484
485 let response = {
486 let mut responses = lock(&self.responses);
487 if responses.is_empty() {
488 self.default_response.clone()
489 } else {
490 responses.remove(0)
491 }
492 };
493
494 Ok(SubprocessOutput {
495 stdout: response.stdout,
496 stderr: response.stderr,
497 status_code: response.status_code,
498 })
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_subprocess_output_success() {
509 let output = SubprocessOutput { stdout: vec![1, 2, 3], stderr: vec![], status_code: 0 };
510 assert!(output.success());
511 }
512
513 #[test]
514 fn test_subprocess_output_failure() {
515 let output = SubprocessOutput { stdout: vec![], stderr: b"error".to_vec(), status_code: 1 };
516 assert!(!output.success());
517 assert_eq!(output.stderr_lossy(), "error");
518 }
519
520 #[test]
521 fn test_subprocess_error_display() {
522 let error = SubprocessError::new("test error");
523 assert_eq!(format!("{}", error), "test error");
524 }
525
526 #[test]
527 fn test_mock_runtime() {
528 use mock::*;
529
530 let runtime = MockSubprocessRuntime::new();
531 runtime.add_response(MockResponse::success(b"formatted code".to_vec()));
532
533 let result = runtime.run_command("perltidy", &["-st"], Some(b"my $x = 1;"));
534
535 assert!(result.is_ok());
536 let output = perl_tdd_support::must(result);
537 assert!(output.success());
538 assert_eq!(output.stdout_lossy(), "formatted code");
539
540 let invocations = runtime.invocations();
541 assert_eq!(invocations.len(), 1);
542 assert_eq!(invocations[0].program, "perltidy");
543 assert_eq!(invocations[0].args, vec!["-st"]);
544 assert_eq!(invocations[0].stdin, Some(b"my $x = 1;".to_vec()));
545 }
546
547 #[cfg(not(target_arch = "wasm32"))]
548 #[test]
549 fn test_os_runtime_echo() {
550 let runtime = OsSubprocessRuntime::new();
551 #[cfg(windows)]
552 let result = runtime.run_command("cmd.exe", &["/C", "echo", "hello"], None);
553 #[cfg(not(windows))]
554 let result = runtime.run_command("echo", &["hello"], None);
555
556 assert!(result.is_ok());
557 let output = perl_tdd_support::must(result);
558 assert!(output.success());
559 assert!(output.stdout_lossy().trim() == "hello");
560 }
561
562 #[cfg(not(target_arch = "wasm32"))]
563 #[test]
564 fn test_os_runtime_nonexistent() {
565 let runtime = OsSubprocessRuntime::new();
566
567 let result = runtime.run_command("nonexistent_program_xyz", &[], None);
568
569 assert!(result.is_err());
570 }
571
572 #[cfg(not(target_arch = "wasm32"))]
573 #[test]
574 fn test_os_runtime_rejects_empty_program_name() {
575 let runtime = OsSubprocessRuntime::new();
576 let result = runtime.run_command(" ", &["--version"], None);
577 assert!(result.is_err());
578 let err = result.expect_err("empty program name must be rejected");
579 assert!(err.message.contains("must not be empty"));
580 }
581
582 #[cfg(not(target_arch = "wasm32"))]
583 #[test]
584 fn test_os_runtime_rejects_nul_bytes_in_program_or_args() {
585 let runtime = OsSubprocessRuntime::new();
586
587 let bad_program = runtime.run_command("perl\0", &["--version"], None);
588 assert!(bad_program.is_err());
589 let bad_program_err = bad_program.expect_err("NUL in program must be rejected");
590 assert!(bad_program_err.message.contains("NUL"));
591
592 let bad_arg = runtime.run_command("perl", &["-e", "print \"ok\"\0"], None);
593 assert!(bad_arg.is_err());
594 let bad_arg_err = bad_arg.expect_err("NUL in arg must be rejected");
595 assert!(bad_arg_err.message.contains("NUL"));
596 }
597
598 #[cfg(windows)]
599 #[test]
600 fn test_resolve_command_invocation_uses_cmd_for_batch_wrappers() {
601 let (program, args) =
602 resolve_command_invocation(r"C:\Strawberry\perl\bin\perltidy.bat", &["-st", "-se"]);
603
604 assert_eq!(program, "cmd.exe");
605 assert_eq!(
606 args,
607 vec![
608 "/D".to_string(),
609 "/V:OFF".to_string(),
610 "/S".to_string(),
611 "/C".to_string(),
612 "\"C:\\Strawberry\\perl\\bin\\perltidy.bat\" \"-st\" \"-se\"".to_string(),
613 ]
614 );
615 }
616
617 #[cfg(windows)]
626 #[test]
627 fn test_windows_quote_for_cmd_metacharacters_are_literal_inside_quotes() {
628 let quoted = windows_quote_for_cmd(r#"profile&name|1>%TEMP%^"x""#);
633 assert_eq!(quoted, r#""profile&name|1>%%TEMP%%^""x""""#);
634 }
635
636 #[cfg(windows)]
643 #[test]
644 fn test_windows_quote_for_cmd_caret_not_doubled() {
645 let quoted = windows_quote_for_cmd(r"foo^bar");
646 assert_eq!(quoted, r#""foo^bar""#);
647 }
648
649 #[cfg(windows)]
655 #[test]
656 fn test_windows_quote_for_cmd_embedded_quote_uses_doubling() {
657 let quoted = windows_quote_for_cmd(r#"arg"with"quotes"#);
658 assert_eq!(quoted, r#""arg""with""quotes""#);
660 }
661
662 #[cfg(windows)]
668 #[test]
669 fn test_windows_quote_for_cmd_injection_attempt_is_inert() {
670 let quoted = windows_quote_for_cmd("&calc.exe");
671 assert_eq!(quoted, "\"&calc.exe\"");
672 }
673
674 #[cfg(windows)]
680 #[test]
681 fn test_resolve_command_invocation_includes_v_off_flag() {
682 let (program, args) =
683 resolve_command_invocation(r"C:\tools\perlcritic.bat", &["--profile=!TEMP!"]);
684
685 assert_eq!(program, "cmd.exe");
686 assert!(
687 args.contains(&"/V:OFF".to_string()),
688 "/V:OFF must be present to disable delayed expansion; got: {:?}",
689 args
690 );
691 }
692
693 #[cfg(windows)]
694 #[test]
695 fn test_resolve_command_invocation_preserves_executable_paths() {
696 let (program, args) =
697 resolve_command_invocation(r"C:\tools\perlcritic.exe", &["--version"]);
698
699 assert_eq!(program, r"C:\tools\perlcritic.exe");
700 assert_eq!(args, vec!["--version".to_string()]);
701 }
702
703 #[cfg(windows)]
704 #[test]
705 fn test_windows_program_priority_prefers_real_wrappers_over_extensionless_shims() {
706 let mut candidates = vec![
707 r"C:\Strawberry\perl\bin\perltidy".to_string(),
708 r"C:\Strawberry\perl\bin\perltidy.bat".to_string(),
709 r"C:\tools\perltidy.exe".to_string(),
710 ];
711 candidates.sort_by_key(|candidate| windows_program_priority(candidate));
712
713 assert_eq!(candidates.last().map(String::as_str), Some(r"C:\tools\perltidy.exe"));
714 assert!(
715 windows_program_priority(r"C:\Strawberry\perl\bin\perltidy.bat")
716 > windows_program_priority(r"C:\Strawberry\perl\bin\perltidy")
717 );
718 }
719}